@camstack/system 1.2.267 → 1.2.269

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 (65) 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-compositor.d.ts +8 -0
  14. package/dist/builtins/camera-grid/grid-instances.d.ts +5 -2
  15. package/dist/builtins/camera-grid/grid-plan.d.ts +8 -6
  16. package/dist/builtins/camera-grid/grid-profiles.d.ts +42 -39
  17. package/dist/builtins/camera-grid/grid-stream-descriptor.d.ts +7 -4
  18. package/dist/builtins/camera-grid/grid-stream-relay.d.ts +10 -7
  19. package/dist/builtins/camera-grid/grid-tile-feed.d.ts +12 -0
  20. package/dist/builtins/camera-grid/index.d.ts +3 -2
  21. package/dist/builtins/camera-grid/index.js +438 -265
  22. package/dist/builtins/camera-grid/index.mjs +435 -263
  23. package/dist/builtins/console-logging/index.js +1 -1
  24. package/dist/builtins/console-logging/index.mjs +1 -1
  25. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  26. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  27. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  28. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  29. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  30. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  31. package/dist/builtins/hub-forwarder/index.js +1 -1
  32. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  33. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  34. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  35. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  36. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  37. package/dist/builtins/local-network/local-network.addon.js +1 -1
  38. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  39. package/dist/builtins/loki-logging/index.js +1 -1
  40. package/dist/builtins/loki-logging/index.mjs +1 -1
  41. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  42. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  43. package/dist/builtins/platform-probe/index.js +1 -1
  44. package/dist/builtins/platform-probe/index.mjs +1 -1
  45. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  46. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  47. package/dist/builtins/snapshot/index.js +1 -1
  48. package/dist/builtins/snapshot/index.mjs +1 -1
  49. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  50. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  51. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  52. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  53. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  54. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  55. package/dist/builtins/system-config/system-config.addon.js +1 -1
  56. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  57. package/dist/builtins/winston-logging/index.js +1 -1
  58. package/dist/builtins/winston-logging/index.mjs +1 -1
  59. package/dist/{dist-BbPssgI6.js → dist-BTpfLiUX.js} +31 -6
  60. package/dist/{dist-Ck0rjYOQ.mjs → dist-CqmImT7i.mjs} +31 -6
  61. package/dist/index.js +1 -1
  62. package/dist/index.mjs +1 -1
  63. package/dist/{retired-settings-keys-BZpDuJVh.mjs → retired-settings-keys-C8nV-AUi.mjs} +1 -1
  64. package/dist/{retired-settings-keys-Dg_69cOY.js → retired-settings-keys-D_DIuT_N.js} +1 -1
  65. 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-BTpfLiUX.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,11 +260,36 @@ 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
  /**
276
+ * Which profiles this grid PUBLISHES, of the ones it could serve.
277
+ *
278
+ * Default `mid` + `low`, and the default is the finding: a grid's `high` is
279
+ * composed of its sources' `high`, so on this fleet it is a 4K canvas built
280
+ * from 4K decodes — a thing to opt into, not to be handed by a viewer's
281
+ * adaptive climbing to the top rung it can see.
282
+ *
283
+ * Absent on rows written before this, which is why the default lives in the
284
+ * schema rather than at the call site: an old grid starts publishing the two
285
+ * cheap rungs without anyone touching it.
286
+ */
287
+ publishedProfiles: zod.z.array(zod.z.enum([
288
+ "high",
289
+ "mid",
290
+ "low"
291
+ ])).max(3).default(["mid", "low"]),
292
+ /**
268
293
  * Empty is the NORMAL freshly-created state: a grid is created from the
269
294
  * integration page with a name only, and laid out afterwards in the camera's
270
295
  * own settings. A grid with no cells declares its camera and emits nothing.
@@ -325,10 +350,7 @@ function gridPlanFor(instance, encode) {
325
350
  cell: cell.cell
326
351
  };
327
352
  });
328
- const canvas = encode.canvas ?? {
329
- width: instance.width,
330
- height: instance.height
331
- };
353
+ const canvas = encode.canvas;
332
354
  return {
333
355
  layout: {
334
356
  width: canvas.width,
@@ -926,92 +948,240 @@ var GridLastFrameFileStore = class {
926
948
  }
927
949
  };
928
950
  //#endregion
929
- //#region src/builtins/camera-grid/grid-profiles.ts
951
+ //#region src/builtins/camera-grid/grid-canvas.ts
952
+ /** Bytes one yuv420p frame of this size occupies. */
953
+ function yuv420pSize(width, height) {
954
+ return width * height + 2 * (width / 2) * (height / 2);
955
+ }
930
956
  /**
931
- * One id, for every grid, forever.
957
+ * Round DOWN to even.
932
958
  *
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.
959
+ * Down and not nearest: a tile that rounded UP would claim a row of samples the
960
+ * decoder was never asked to produce, and the copy would either overrun or be
961
+ * refused at the moment the picture is wanted.
937
962
  */
938
- var GRID_CAM_STREAM_ID = "grid";
963
+ function evenDown(value) {
964
+ return Math.max(0, Math.floor(value / 2) * 2);
965
+ }
966
+ var GridCanvas = class {
967
+ width;
968
+ height;
969
+ buffer;
970
+ lumaBytes;
971
+ chromaBytes;
972
+ constructor(width, height) {
973
+ this.width = width;
974
+ this.height = height;
975
+ this.lumaBytes = width * height;
976
+ this.chromaBytes = width / 2 * (height / 2);
977
+ this.buffer = Buffer.alloc(yuv420pSize(width, height));
978
+ this.clear();
979
+ }
980
+ /**
981
+ * Legible black — Y 0, chroma 128 (neutral).
982
+ *
983
+ * `Buffer.alloc` already zeroes, and zeroed CHROMA is not black: it is a
984
+ * strong green. A cell nobody has filled yet has to look like an empty cell,
985
+ * not like a fault.
986
+ */
987
+ clear() {
988
+ this.buffer.fill(0, 0, this.lumaBytes);
989
+ this.buffer.fill(128, this.lumaBytes);
990
+ }
991
+ /**
992
+ * Copy one decoded tile in at `origin`.
993
+ *
994
+ * Returns false — never throws, never writes a partial tile — when the tile
995
+ * does not fit, is the wrong size for the dimensions it claims, or sits on an
996
+ * odd boundary. This runs on the output tick, so a bad frame from one source
997
+ * must cost that source's rectangle and nothing else.
998
+ */
999
+ blit(tile, size, origin) {
1000
+ if (!isEven(size.width) || !isEven(size.height)) return false;
1001
+ if (!isEven(origin.x) || !isEven(origin.y)) return false;
1002
+ if (size.width <= 0 || size.height <= 0) return false;
1003
+ if (origin.x < 0 || origin.y < 0) return false;
1004
+ if (origin.x + size.width > this.width) return false;
1005
+ if (origin.y + size.height > this.height) return false;
1006
+ if (tile.length !== yuv420pSize(size.width, size.height)) return false;
1007
+ for (let row = 0; row < size.height; row += 1) {
1008
+ const from = row * size.width;
1009
+ const to = (origin.y + row) * this.width + origin.x;
1010
+ tile.copy(this.buffer, to, from, from + size.width);
1011
+ }
1012
+ const tileLuma = size.width * size.height;
1013
+ const tileChroma = size.width / 2 * (size.height / 2);
1014
+ const halfW = size.width / 2;
1015
+ const halfH = size.height / 2;
1016
+ const canvasHalfW = this.width / 2;
1017
+ for (let plane = 0; plane < 2; plane += 1) {
1018
+ const tileBase = tileLuma + plane * tileChroma;
1019
+ const canvasBase = this.lumaBytes + plane * this.chromaBytes;
1020
+ for (let row = 0; row < halfH; row += 1) {
1021
+ const from = tileBase + row * halfW;
1022
+ const to = canvasBase + (origin.y / 2 + row) * canvasHalfW + origin.x / 2;
1023
+ tile.copy(this.buffer, to, from, from + halfW);
1024
+ }
1025
+ }
1026
+ return true;
1027
+ }
1028
+ };
1029
+ function isEven(value) {
1030
+ return Number.isInteger(value) && value % 2 === 0;
1031
+ }
1032
+ //#endregion
1033
+ //#region src/builtins/camera-grid/grid-canvas-derivation.ts
939
1034
  /**
940
- * The slot the single composition claims.
1035
+ * The canvas is a RESULT, not a field.
1036
+ *
1037
+ * ## Why the operator stopped typing it
1038
+ *
1039
+ * A grid's resolution used to be two numbers in the editor, beside FPS. They
1040
+ * looked harmless and were not: that number silently decided how much the
1041
+ * composite cost and how sharp it was. An operator typing 1920x1080 was, without
1042
+ * being told, committing the hub to reading two 4 MP streams — or, with the
1043
+ * rule before it, to upscaling a 640x360 sub-stream by 3x. Neither is a thing
1044
+ * anyone can be expected to work out from a text field.
1045
+ *
1046
+ * ## What it is derived from
1047
+ *
1048
+ * The operator draws two rectangles per camera — which part of the source to
1049
+ * take, and where it lands — and both are fractions, so nothing is pixels yet.
1050
+ * Exactly one number is missing: how many pixels the whole canvas is worth.
1051
+ *
1052
+ * A cell has `sourceResolution x crop` REAL pixels behind it. Showing them 1:1
1053
+ * neither invents nor discards anything, so the cell should be exactly that
1054
+ * many pixels — and since the cell is a fraction `f` of the canvas, the canvas
1055
+ * it implies is `real / f`. Each cell proposes one, and the LARGEST wins: the
1056
+ * hungriest cell is served 1:1 and the others scale down by a hair, never up.
1057
+ *
1058
+ * Gaps change nothing. Cells need not cover the canvas, and an uncovered part
1059
+ * is background — it has no pixels to propose.
1060
+ *
1061
+ * ## One per profile
941
1062
  *
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.
1063
+ * A source is a different size at each profile, so each profile derives its own
1064
+ * canvas: a grid's `low` composes its sources' `low` onto the canvas their
1065
+ * `low` implies. That is what makes the three outputs 1:1 with what they are
1066
+ * made of, and what lets a viewer's adaptive pick a rung without the grid
1067
+ * needing to know it exists.
947
1068
  */
948
- var GRID_OUTPUT_PROFILE = "high";
949
- /** Cheapest first — the order a source is read in. */
950
- var CHEAPEST_FIRST = [
951
- "low",
952
- "mid",
953
- "high"
954
- ];
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;
958
- }
959
- /** The pixels a cell needs BEHIND it, before the crop takes its share. */
960
- function requiredSourceSize(cell, canvas) {
961
- const tileWidth = cell.cell.width * canvas.width;
962
- const tileHeight = cell.cell.height * canvas.height;
963
- const cropWidth = cell.source.width > 0 ? cell.source.width : 1;
964
- const cropHeight = cell.source.height > 0 ? cell.source.height : 1;
1069
+ /**
1070
+ * What an encoder will take. Not a taste: `libx264` refuses beyond 8192 in
1071
+ * either axis, and a canvas that large is a composition nobody can decode
1072
+ * anyway. Reached only by a cell that shows a sliver of a source at full
1073
+ * canvas width, which is a layout, not a mistake — so it is CLAMPED rather
1074
+ * than refused, and clamped in both axes together so the picture the operator
1075
+ * drew keeps its shape.
1076
+ */
1077
+ var MAX_CANVAS_WIDTH = 7680;
1078
+ var MAX_CANVAS_HEIGHT = 4320;
1079
+ /** Below this a composition is not a cheaper picture, it is no picture. */
1080
+ var MIN_CANVAS_EDGE = 160;
1081
+ /**
1082
+ * The canvas these cells imply, or `null` when they imply none.
1083
+ *
1084
+ * `null` for a grid with no cells — the normal freshly-created state — and for
1085
+ * a layout whose every rectangle is degenerate. Both are "there is no picture
1086
+ * to size", which the caller turns into "this profile is not on offer" rather
1087
+ * than into a guess.
1088
+ */
1089
+ function deriveGridCanvas(cells) {
1090
+ let width = 0;
1091
+ let height = 0;
1092
+ for (const entry of cells) {
1093
+ if (entry.cell.width <= 0 || entry.cell.height <= 0) continue;
1094
+ if (entry.sourceSize.width <= 0 || entry.sourceSize.height <= 0) continue;
1095
+ const realWidth = entry.sourceSize.width * entry.source.width;
1096
+ const realHeight = entry.sourceSize.height * entry.source.height;
1097
+ if (realWidth <= 0 || realHeight <= 0) continue;
1098
+ width = Math.max(width, realWidth / entry.cell.width);
1099
+ height = Math.max(height, realHeight / entry.cell.height);
1100
+ }
1101
+ if (width <= 0 || height <= 0) return null;
1102
+ const overshoot = Math.max(width / MAX_CANVAS_WIDTH, height / MAX_CANVAS_HEIGHT, 1);
1103
+ const factor = Math.max(MIN_CANVAS_EDGE / width, MIN_CANVAS_EDGE / height, 1) / overshoot;
965
1104
  return {
966
- width: tileWidth / cropWidth,
967
- height: tileHeight / cropHeight
1105
+ width: Math.max(2, evenDown(width * factor)),
1106
+ height: Math.max(2, evenDown(height * factor))
968
1107
  };
969
1108
  }
1109
+ //#endregion
1110
+ //#region src/builtins/camera-grid/grid-profiles.ts
970
1111
  /**
971
- * The cheapest stream that covers `need`, or the BIGGEST when none does.
1112
+ * One id per profile, for every grid, forever.
972
1113
  *
973
- * Soft beats absent: a camera whose best stream cannot fill its cell still
974
- * composes, at the best it has. Refusing would take the whole grid down over
975
- * one cell being drawn larger than its source.
976
- *
977
- * A stream that states NO size cannot be compared, so it is ordered as cheapest
978
- * rather than discarded — an unmeasurable stream is still a stream, and the
979
- * picture is what the operator asked for.
1114
+ * D519's reason is what keeps `high`'s id bare: a newly-spelled id mints a
1115
+ * second cam-stream and orphans whatever profile assignment the operator had
1116
+ * made. `grid` was `high` on the live hub before any of this and still is.
980
1117
  */
981
- function bestFit(served, need) {
982
- if (served.length === 0) return null;
983
- const order = new Map(CHEAPEST_FIRST.map((profile, index) => [profile, index]));
984
- const cheapestFirst = [...served].sort((a, b) => (order.get(a.profile) ?? 0) - (order.get(b.profile) ?? 0));
985
- const unmeasurable = cheapestFirst.find((s) => s.width <= 0 || s.height <= 0);
986
- if (unmeasurable !== void 0) return unmeasurable.profile;
987
- const covering = cheapestFirst.find((s) => s.width >= need.width && s.height >= need.height);
988
- if (covering !== void 0) return covering.profile;
989
- return cheapestFirst.reduce((best, s) => s.width * s.height > best.width * best.height ? s : best).profile;
1118
+ var GRID_CAM_STREAM_ID = "grid";
1119
+ /** Every profile a grid can compose, in descending quality. */
1120
+ var GRID_PROFILES = [
1121
+ "high",
1122
+ "mid",
1123
+ "low"
1124
+ ];
1125
+ /** The cam-stream id a grid publishes for one profile. */
1126
+ function gridCamStreamId(profile) {
1127
+ return profile === "high" ? GRID_CAM_STREAM_ID : `${GRID_CAM_STREAM_ID}-${profile}`;
990
1128
  }
991
- /** Which stream to read each source at, and who cannot be read at all. */
992
- function gridSourceProfiles(input) {
993
- const neededBySource = /* @__PURE__ */ new Map();
994
- for (const cell of input.cells) {
995
- const need = requiredSourceSize(cell, input.canvas);
996
- const current = neededBySource.get(cell.deviceId);
997
- neededBySource.set(cell.deviceId, {
998
- width: Math.max(current?.width ?? 0, need.width),
999
- height: Math.max(current?.height ?? 0, need.height)
1000
- });
1001
- }
1002
- const profileBySource = /* @__PURE__ */ new Map();
1003
- const missingSources = [];
1004
- for (const [deviceId, need] of neededBySource) {
1005
- const served = input.profilesServedBy(deviceId);
1006
- const chosen = served === null ? null : bestFit(served, need);
1007
- if (chosen === null) missingSources.push(deviceId);
1008
- else profileBySource.set(deviceId, chosen);
1009
- }
1010
- return {
1011
- offered: neededBySource.size > 0 && missingSources.length === 0,
1012
- profileBySource,
1013
- missingSources
1014
- };
1129
+ /**
1130
+ * What this grid can honestly publish, per profile, with the canvas or the
1131
+ * refusal.
1132
+ *
1133
+ * Every profile gets a row whether it is offered or not: an absent row says
1134
+ * nothing, and the caller logging a refusal needs the reason more than the
1135
+ * caller publishing an offer needs the offer.
1136
+ */
1137
+ function gridProfilePlans(input) {
1138
+ const sourceDeviceIds = [...new Set(input.cells.map((cell) => cell.deviceId))];
1139
+ const servedBy = /* @__PURE__ */ new Map();
1140
+ for (const deviceId of sourceDeviceIds) servedBy.set(deviceId, input.profilesServedBy(deviceId));
1141
+ return GRID_PROFILES.map((profile) => {
1142
+ if (sourceDeviceIds.length === 0) return {
1143
+ profile,
1144
+ offered: false,
1145
+ missingSources: []
1146
+ };
1147
+ const sizeBySource = /* @__PURE__ */ new Map();
1148
+ const missingSources = [];
1149
+ for (const deviceId of sourceDeviceIds) {
1150
+ const match = (servedBy.get(deviceId) ?? null)?.find((entry) => entry.profile === profile);
1151
+ if (match === void 0 || match.width <= 0 || match.height <= 0) {
1152
+ missingSources.push(deviceId);
1153
+ continue;
1154
+ }
1155
+ sizeBySource.set(deviceId, {
1156
+ width: match.width,
1157
+ height: match.height
1158
+ });
1159
+ }
1160
+ if (missingSources.length > 0) return {
1161
+ profile,
1162
+ offered: false,
1163
+ missingSources
1164
+ };
1165
+ const canvas = deriveGridCanvas(input.cells.map((cell) => ({
1166
+ source: cell.source,
1167
+ cell: cell.cell,
1168
+ sourceSize: sizeBySource.get(cell.deviceId) ?? {
1169
+ width: 0,
1170
+ height: 0
1171
+ }
1172
+ })));
1173
+ if (canvas === null) return {
1174
+ profile,
1175
+ offered: false,
1176
+ missingSources: []
1177
+ };
1178
+ return {
1179
+ profile,
1180
+ offered: true,
1181
+ canvas,
1182
+ published: input.publishedProfiles.includes(profile)
1183
+ };
1184
+ });
1015
1185
  }
1016
1186
  //#endregion
1017
1187
  //#region src/builtins/camera-grid/grid-sentry-sources.ts
@@ -1176,15 +1346,24 @@ var PATH_PREFIX = "/grid/";
1176
1346
  var PATH_SUFFIX = GRID_WIRE_PATH_SUFFIX;
1177
1347
  /** How long a reader may refuse to drain before it is cut. */
1178
1348
  var SLOW_READER_MS = 5e3;
1179
- /** `/grid/<instanceId>.flv`, or `null` when it is anything else. */
1349
+ function asProfile(raw) {
1350
+ return GRID_PROFILES.find((profile) => profile === raw) ?? null;
1351
+ }
1352
+ /** `/grid/<instanceId>/<profile>.flv`, or `null` when it is anything else. */
1180
1353
  function parseGridStreamPath(url) {
1181
1354
  const path = (url ?? "").split("?")[0] ?? "";
1182
1355
  if (!path.startsWith(PATH_PREFIX) || !path.endsWith(PATH_SUFFIX)) return null;
1183
1356
  const raw = path.slice(6, path.length - PATH_SUFFIX.length);
1184
- if (raw.length === 0 || raw.includes("/")) return null;
1357
+ const at = raw.lastIndexOf("/");
1358
+ if (at <= 0) return null;
1359
+ const profile = asProfile(raw.slice(at + 1));
1360
+ if (profile === null) return null;
1185
1361
  try {
1186
- const instanceId = decodeURIComponent(raw);
1187
- return instanceId.length > 0 ? { instanceId } : null;
1362
+ const instanceId = decodeURIComponent(raw.slice(0, at));
1363
+ return instanceId.length > 0 ? {
1364
+ instanceId,
1365
+ profile
1366
+ } : null;
1188
1367
  } catch {
1189
1368
  return null;
1190
1369
  }
@@ -1223,13 +1402,16 @@ var GridStreamRelay = class {
1223
1402
  streamUrl(request) {
1224
1403
  if (!this.baseUrl) throw new Error("camera-grid: the stream relay is not started");
1225
1404
  const instance = encodeURIComponent(request.instanceId);
1226
- return `${this.baseUrl}${PATH_PREFIX}${instance}${PATH_SUFFIX}`;
1405
+ return `${this.baseUrl}${PATH_PREFIX}${instance}/${request.profile}${PATH_SUFFIX}`;
1227
1406
  }
1228
1407
  async serve(request, res) {
1229
- const { instanceId } = request;
1408
+ const { instanceId, profile } = request;
1230
1409
  const session = this.deps.sessionFor(request);
1231
1410
  if (session === null) {
1232
- this.deps.logger.warn("camera grid: a stream was requested for an unknown grid", { meta: { instanceId } });
1411
+ this.deps.logger.warn("camera grid: a stream was requested for an unknown grid", { meta: {
1412
+ instanceId,
1413
+ profile
1414
+ } });
1233
1415
  res.writeHead(404).end();
1234
1416
  return;
1235
1417
  }
@@ -1263,6 +1445,7 @@ var GridStreamRelay = class {
1263
1445
  tags: { deviceId: session.deviceId },
1264
1446
  meta: {
1265
1447
  instanceId,
1448
+ profile,
1266
1449
  stalledForMs: SLOW_READER_MS
1267
1450
  }
1268
1451
  });
@@ -1275,6 +1458,7 @@ var GridStreamRelay = class {
1275
1458
  tags: { deviceId: session.deviceId },
1276
1459
  meta: {
1277
1460
  instanceId,
1461
+ profile,
1278
1462
  reason
1279
1463
  }
1280
1464
  });
@@ -1287,6 +1471,7 @@ var GridStreamRelay = class {
1287
1471
  tags: { deviceId: session.deviceId },
1288
1472
  meta: {
1289
1473
  instanceId,
1474
+ profile,
1290
1475
  error: require_dist.errMsg(error)
1291
1476
  }
1292
1477
  });
@@ -1322,13 +1507,13 @@ var GridStreamRelay = class {
1322
1507
  * scrub never reads. The Dreame robot's one 720p relay landed exactly there and
1323
1508
  * its footage was written nowhere anything looked.
1324
1509
  *
1325
- * So the single composition claims `high` EXPLICITLY (`GRID_OUTPUT_PROFILE`).
1326
- * It is also the slot this stream held before the per-profile detour, which is
1327
- * what keeps an operator's existing assignment intact.
1510
+ * With several compositions published the hint does a second job: it says which
1511
+ * slot each one is FOR. Ranked by pixels a grid's `mid` would take the `high`
1512
+ * slot on any grid whose `high` was not offered.
1328
1513
  */
1329
1514
  function gridStreamDescriptor(input) {
1330
1515
  return {
1331
- camStreamId: GRID_CAM_STREAM_ID,
1516
+ camStreamId: gridCamStreamId(input.profile),
1332
1517
  kind: GRID_WIRE_STREAM_KIND,
1333
1518
  url: input.url,
1334
1519
  codec: "h264",
@@ -1337,8 +1522,8 @@ function gridStreamDescriptor(input) {
1337
1522
  height: input.height
1338
1523
  },
1339
1524
  fps: input.fps,
1340
- label: "Grid composition",
1341
- profileHint: GRID_OUTPUT_PROFILE,
1525
+ label: `Grid composition (${input.profile})`,
1526
+ profileHint: input.profile,
1342
1527
  autoEligible: true
1343
1528
  };
1344
1529
  }
@@ -1841,88 +2026,6 @@ async function silenceAnalysisFor(deps, deviceId) {
1841
2026
  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("; ")})`);
1842
2027
  }
1843
2028
  //#endregion
1844
- //#region src/builtins/camera-grid/grid-canvas.ts
1845
- /** Bytes one yuv420p frame of this size occupies. */
1846
- function yuv420pSize(width, height) {
1847
- return width * height + 2 * (width / 2) * (height / 2);
1848
- }
1849
- /**
1850
- * Round DOWN to even.
1851
- *
1852
- * Down and not nearest: a tile that rounded UP would claim a row of samples the
1853
- * decoder was never asked to produce, and the copy would either overrun or be
1854
- * refused at the moment the picture is wanted.
1855
- */
1856
- function evenDown(value) {
1857
- return Math.max(0, Math.floor(value / 2) * 2);
1858
- }
1859
- var GridCanvas = class {
1860
- width;
1861
- height;
1862
- buffer;
1863
- lumaBytes;
1864
- chromaBytes;
1865
- constructor(width, height) {
1866
- this.width = width;
1867
- this.height = height;
1868
- this.lumaBytes = width * height;
1869
- this.chromaBytes = width / 2 * (height / 2);
1870
- this.buffer = Buffer.alloc(yuv420pSize(width, height));
1871
- this.clear();
1872
- }
1873
- /**
1874
- * Legible black — Y 0, chroma 128 (neutral).
1875
- *
1876
- * `Buffer.alloc` already zeroes, and zeroed CHROMA is not black: it is a
1877
- * strong green. A cell nobody has filled yet has to look like an empty cell,
1878
- * not like a fault.
1879
- */
1880
- clear() {
1881
- this.buffer.fill(0, 0, this.lumaBytes);
1882
- this.buffer.fill(128, this.lumaBytes);
1883
- }
1884
- /**
1885
- * Copy one decoded tile in at `origin`.
1886
- *
1887
- * Returns false — never throws, never writes a partial tile — when the tile
1888
- * does not fit, is the wrong size for the dimensions it claims, or sits on an
1889
- * odd boundary. This runs on the output tick, so a bad frame from one source
1890
- * must cost that source's rectangle and nothing else.
1891
- */
1892
- blit(tile, size, origin) {
1893
- if (!isEven(size.width) || !isEven(size.height)) return false;
1894
- if (!isEven(origin.x) || !isEven(origin.y)) return false;
1895
- if (size.width <= 0 || size.height <= 0) return false;
1896
- if (origin.x < 0 || origin.y < 0) return false;
1897
- if (origin.x + size.width > this.width) return false;
1898
- if (origin.y + size.height > this.height) return false;
1899
- if (tile.length !== yuv420pSize(size.width, size.height)) return false;
1900
- for (let row = 0; row < size.height; row += 1) {
1901
- const from = row * size.width;
1902
- const to = (origin.y + row) * this.width + origin.x;
1903
- tile.copy(this.buffer, to, from, from + size.width);
1904
- }
1905
- const tileLuma = size.width * size.height;
1906
- const tileChroma = size.width / 2 * (size.height / 2);
1907
- const halfW = size.width / 2;
1908
- const halfH = size.height / 2;
1909
- const canvasHalfW = this.width / 2;
1910
- for (let plane = 0; plane < 2; plane += 1) {
1911
- const tileBase = tileLuma + plane * tileChroma;
1912
- const canvasBase = this.lumaBytes + plane * this.chromaBytes;
1913
- for (let row = 0; row < halfH; row += 1) {
1914
- const from = tileBase + row * halfW;
1915
- const to = canvasBase + (origin.y / 2 + row) * canvasHalfW + origin.x / 2;
1916
- tile.copy(this.buffer, to, from, from + halfW);
1917
- }
1918
- }
1919
- return true;
1920
- }
1921
- };
1922
- function isEven(value) {
1923
- return Number.isInteger(value) && value % 2 === 0;
1924
- }
1925
- //#endregion
1926
2029
  //#region src/builtins/camera-grid/grid-tile-feed.ts
1927
2030
  /**
1928
2031
  * One source's decoded tiles, of which only the LATEST is ever wanted.
@@ -1974,6 +2077,20 @@ var GridTileFeed = class {
1974
2077
  hasFresh() {
1975
2078
  return this.fresh;
1976
2079
  }
2080
+ /**
2081
+ * Forget the half frame, keep the picture.
2082
+ *
2083
+ * Raw video has no framing: the feed cuts on a fixed length, so bytes from a
2084
+ * NEW decoder concatenated onto a partial frame from the old one would be
2085
+ * misaligned for ever — every frame after it sheared, with nothing to
2086
+ * re-sync on. Called whenever a tile's decoder is replaced.
2087
+ *
2088
+ * The newest frame is kept deliberately: the cell holds its last picture
2089
+ * while its decoder comes back, instead of blinking to black.
2090
+ */
2091
+ resetPartial() {
2092
+ this.partial = Buffer.alloc(0);
2093
+ }
1977
2094
  /** For the bound in the spec: one frame plus at most a partial one. */
1978
2095
  bufferedBytes() {
1979
2096
  return (this.newest?.length ?? 0) + this.partial.length;
@@ -2057,6 +2174,16 @@ var GridCompositor = class {
2057
2174
  }
2058
2175
  return this.deps.write(this.canvas.buffer);
2059
2176
  }
2177
+ /**
2178
+ * A tile's decoder was replaced: drop its half frame, keep its picture.
2179
+ *
2180
+ * Without this the new decoder's bytes would be appended to the old one's
2181
+ * partial frame and every frame after would be sheared — raw video has no
2182
+ * framing to re-sync on.
2183
+ */
2184
+ resetTile(tileId) {
2185
+ this.placed.get(tileId)?.feed.resetPartial();
2186
+ }
2060
2187
  /** After this, a tick emits nothing — a dead child must not keep writing. */
2061
2188
  close() {
2062
2189
  this.closed = true;
@@ -2243,13 +2370,22 @@ function gridCanvasEncoderInvocation(input) {
2243
2370
  * is how a stalled encoder becomes an OOM, and a dropped frame in a live
2244
2371
  * composite costs nothing anybody can see.
2245
2372
  *
2246
- * ## Any child exiting ends the composition
2373
+ * ## A tile comes back; only the ENCODER ends the composition
2374
+ *
2375
+ * Every child exiting used to end it, on the reasoning that a frozen tile is a
2376
+ * nicer picture and a worse fact. In practice it produced neither: a 4K source
2377
+ * ended mid-run (`code=0` — the input simply finished), the composition tore
2378
+ * down, the consumer re-dialled, and the grid served nothing but restarts. That
2379
+ * is what the operator saw as the `auto` rung being stuck.
2247
2380
  *
2248
- * Same policy as the single child (`maxRestarts: 0`): the session tells its
2249
- * consumers why and tears down, and the next read starts a cold composition
2250
- * with freshly acquired sources. Leaving a dead tile frozen while the others
2251
- * run would be a nicer picture and a worse fact — the operator would be looking
2252
- * at a camera that stopped minutes ago with nothing saying so.
2381
+ * A canvas makes the better answer cheap: while a decoder comes back, its cell
2382
+ * holds the last frame it produced and every other cell carries on. The bound
2383
+ * (`TILE_MAX_RESTARTS`) keeps a source that is genuinely gone from respawning
2384
+ * for ever — and when it is reached, the composition does end, with the tile
2385
+ * named.
2386
+ *
2387
+ * The encoder is different: nothing downstream of it exists without it, so its
2388
+ * exit is the composition's exit, first time.
2253
2389
  */
2254
2390
  /**
2255
2391
  * The encoder must produce its first bytes quickly, because the tick feeds it a
@@ -2263,6 +2399,15 @@ var ENCODER_FIRST_DATA_MS = 5e3;
2263
2399
  * already on screen.
2264
2400
  */
2265
2401
  var DECODER_FIRST_DATA_MS = 15e3;
2402
+ /**
2403
+ * How many times a tile's decoder may come back before the composition gives up
2404
+ * on it.
2405
+ *
2406
+ * The bound exists so a source that is genuinely gone still ends the
2407
+ * composition rather than respawning for ever; below it, a decoder that dies is
2408
+ * simply replaced while its cell holds the last frame it produced.
2409
+ */
2410
+ var TILE_MAX_RESTARTS = 5;
2266
2411
  async function startGridCompositorChild(ctx, plan, sources, onEnded) {
2267
2412
  const tilePlan = buildGridTilePlan({
2268
2413
  canvas: {
@@ -2372,14 +2517,17 @@ async function startGridCompositorChild(ctx, plan, sources, onEnded) {
2372
2517
  height: tile.size.height
2373
2518
  },
2374
2519
  firstDataTimeoutMs: DECODER_FIRST_DATA_MS,
2375
- maxRestarts: 0,
2520
+ maxRestarts: TILE_MAX_RESTARTS,
2376
2521
  onChild: (child) => {
2377
2522
  child.stdout?.on("data", (chunk) => {
2378
2523
  compositor.onTile(tile.id, chunk);
2379
2524
  });
2380
2525
  child.stderr?.on("data", () => {});
2381
2526
  },
2382
- onExit: (exit) => end(`tile ${tile.id}:${exit.classification}`)
2527
+ onExit: () => {
2528
+ compositor.resetTile(tile.id);
2529
+ },
2530
+ onGiveUp: (error) => end(`tile ${tile.id}: ${error.message}`)
2383
2531
  });
2384
2532
  decoders.push(decoder);
2385
2533
  await decoder.start();
@@ -2424,18 +2572,26 @@ var DEFAULTS = {
2424
2572
  cameraGrids: [],
2425
2573
  cameraGridTombstones: []
2426
2574
  };
2575
+ /** One composition per (grid, profile) — a grid composes each separately. */
2576
+ function sessionKey(instanceId, profile) {
2577
+ return `${instanceId}::${profile}`;
2578
+ }
2427
2579
  /**
2428
- * One grid's composition — there is exactly one, keyed by the grid itself.
2580
+ * One grid's composition of one profile.
2429
2581
  *
2430
- * It used to be one per profile, and the key carried the profile. That shape
2431
- * is what made a viewer's adaptive step tear a composition down and build
2432
- * another; see `grid-profiles.ts` for the log that showed it.
2582
+ * The profile is back in the key after a spell with a single output. What made
2583
+ * the per-profile shape painful was never the count: it was that a rebuild
2584
+ * meant a blank screen until the slowest source produced a key frame. An output
2585
+ * clock (D530) shows the picture at once and fills the cells in, so a rung the
2586
+ * adaptive can stand on costs what it is worth.
2433
2587
  */
2434
2588
  var GridSessionEntry = class {
2435
2589
  instanceId;
2590
+ profile;
2436
2591
  session;
2437
- constructor(instanceId, session) {
2592
+ constructor(instanceId, profile, session) {
2438
2593
  this.instanceId = instanceId;
2594
+ this.profile = profile;
2439
2595
  this.session = session;
2440
2596
  }
2441
2597
  isRunning() {
@@ -2459,7 +2615,7 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2459
2615
  * (D224), and 60 s is the window for a fact — "does 615 have a low assigned"
2460
2616
  * — that only changes when an operator changes it.
2461
2617
  */
2462
- planByInstanceId = /* @__PURE__ */ new Map();
2618
+ plansByInstanceId = /* @__PURE__ */ new Map();
2463
2619
  activity = null;
2464
2620
  snapshots = null;
2465
2621
  frameSampler = null;
@@ -2692,9 +2848,9 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2692
2848
  if (instance.cells.filter((cell) => cell.deviceId === sourceDeviceId).length === 0) continue;
2693
2849
  targets.push({
2694
2850
  deviceId,
2695
- canvas: {
2696
- width: instance.width,
2697
- height: instance.height
2851
+ canvas: this.canvasFor(instanceId) ?? {
2852
+ width: 0,
2853
+ height: 0
2698
2854
  },
2699
2855
  cellsForSource: (id) => instance.cells.filter((cell) => cell.deviceId === id).map((cell) => ({
2700
2856
  source: cell.source,
@@ -2737,11 +2893,8 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2737
2893
  servedBy.set(slot.deviceId, profiles);
2738
2894
  }
2739
2895
  for (const instance of instances) {
2740
- const plan = gridSourceProfiles({
2741
- canvas: {
2742
- width: instance.width,
2743
- height: instance.height
2744
- },
2896
+ const plans = gridProfilePlans({
2897
+ publishedProfiles: instance.publishedProfiles,
2745
2898
  cells: instance.cells.map((cell) => ({
2746
2899
  deviceId: cell.deviceId,
2747
2900
  source: cell.source,
@@ -2749,9 +2902,9 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2749
2902
  })),
2750
2903
  profilesServedBy: (deviceId) => servedBy.get(deviceId) ?? null
2751
2904
  });
2752
- const previous = this.planByInstanceId.get(instance.id);
2753
- this.planByInstanceId.set(instance.id, plan);
2754
- this.logSourcePlan(instance, plan, previous);
2905
+ const previous = this.plansByInstanceId.get(instance.id);
2906
+ this.plansByInstanceId.set(instance.id, plans);
2907
+ this.logSourcePlan(instance, plans, previous);
2755
2908
  }
2756
2909
  }
2757
2910
  /**
@@ -2767,48 +2920,65 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2767
2920
  * Logged on the EDGE: a grid that cannot be composed would otherwise write a
2768
2921
  * line a minute for ever.
2769
2922
  */
2770
- logSourcePlan(instance, plan, previous) {
2771
- const shape = (value) => JSON.stringify([
2772
- value.offered,
2773
- [...value.profileBySource],
2774
- value.missingSources
2775
- ]);
2776
- if (previous !== void 0 && shape(previous) === shape(plan)) return;
2923
+ logSourcePlan(instance, plans, previous) {
2924
+ const shape = (value) => JSON.stringify(value);
2925
+ if (previous !== void 0 && shape(previous) === shape(plans)) return;
2777
2926
  const deviceId = this.deviceIdByInstanceId.get(instance.id) ?? 0;
2778
- if (plan.missingSources.length > 0) this.ctx.logger.info("camera grid: a source cannot be read — the grid is NOT on offer", {
2779
- tags: { deviceId },
2780
- meta: {
2781
- instanceId: instance.id,
2782
- missingSources: [...plan.missingSources]
2783
- }
2784
- });
2785
- this.ctx.logger.info("camera grid: composing", {
2927
+ for (const row of plans) {
2928
+ if (row.offered || row.missingSources.length === 0) continue;
2929
+ this.ctx.logger.info("camera grid: a profile is NOT on offer", {
2930
+ tags: { deviceId },
2931
+ meta: {
2932
+ instanceId: instance.id,
2933
+ profile: row.profile,
2934
+ missingSources: [...row.missingSources]
2935
+ }
2936
+ });
2937
+ }
2938
+ this.ctx.logger.info("camera grid: published profiles", {
2786
2939
  tags: { deviceId },
2787
2940
  meta: {
2788
2941
  instanceId: instance.id,
2789
- offered: plan.offered,
2790
- readAt: Object.fromEntries([...plan.profileBySource].map(([id, p]) => [String(id), p]))
2942
+ offered: Object.fromEntries(plans.filter((row) => row.offered && row.published).map((row) => [row.profile, row.offered ? `${String(row.canvas.width)}x${String(row.canvas.height)}` : ""]))
2791
2943
  }
2792
2944
  });
2793
2945
  }
2794
- planFor(instanceId) {
2795
- return this.planByInstanceId.get(instanceId) ?? null;
2946
+ plansFor(instanceId) {
2947
+ return this.plansByInstanceId.get(instanceId) ?? null;
2948
+ }
2949
+ planFor(instanceId, profile) {
2950
+ return this.plansFor(instanceId)?.find((row) => row.profile === profile) ?? null;
2951
+ }
2952
+ /**
2953
+ * The canvas of the BEST profile this grid offers.
2954
+ *
2955
+ * Used where a single geometry is needed for something that is not a
2956
+ * composition — the detection boxes a grid forwards (`grid-activity`), which
2957
+ * are normalized against whatever picture the viewer is looking at. The best
2958
+ * offered one is the honest default; a grid offering nothing has no geometry
2959
+ * and says so.
2960
+ */
2961
+ canvasFor(instanceId) {
2962
+ for (const row of this.plansFor(instanceId) ?? []) if (row.offered) return row.canvas;
2963
+ return null;
2796
2964
  }
2797
2965
  ensureSessions(instanceId, deviceId) {
2798
- const plan = this.planFor(instanceId);
2799
- if (plan === null || !plan.offered) {
2800
- const stale = this.sessions.get(instanceId);
2801
- if (stale) {
2802
- this.sessions.delete(instanceId);
2803
- stale.session.dispose();
2966
+ for (const profile of GRID_PROFILES) {
2967
+ const key = sessionKey(instanceId, profile);
2968
+ const plan = this.planFor(instanceId, profile);
2969
+ if (plan === null || !plan.offered || !plan.published) {
2970
+ const stale = this.sessions.get(key);
2971
+ if (stale) {
2972
+ this.sessions.delete(key);
2973
+ stale.session.dispose();
2974
+ }
2975
+ continue;
2804
2976
  }
2805
- return;
2977
+ if (this.sessions.has(key)) continue;
2978
+ this.sessions.set(key, new GridSessionEntry(instanceId, profile, this.makeSession(instanceId, deviceId, profile)));
2806
2979
  }
2807
- if (this.sessions.has(instanceId)) return;
2808
- const session = this.makeSession(instanceId, deviceId);
2809
- this.sessions.set(instanceId, new GridSessionEntry(instanceId, session));
2810
2980
  }
2811
- makeSession(instanceId, deviceId) {
2981
+ makeSession(instanceId, deviceId, profile) {
2812
2982
  const childContext = {
2813
2983
  binaryPath: this.ffmpegBinaryPath,
2814
2984
  logger: this.ctx.logger,
@@ -2819,8 +2989,6 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2819
2989
  deps: {
2820
2990
  logger: this.ctx.logger,
2821
2991
  acquireSource: async (sourceDeviceId) => {
2822
- const profile = this.planFor(instanceId)?.profileBySource.get(sourceDeviceId);
2823
- if (profile === void 0) throw new Error(`camera-grid: no stream is assigned for source ${String(sourceDeviceId)}`);
2824
2992
  const dial = gridSourceDial(await acquireWithRetry(() => this.ctx.api.streamBroker.getStreamWithCodec.mutate({
2825
2993
  deviceId: sourceDeviceId,
2826
2994
  video: "copy",
@@ -2867,10 +3035,12 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2867
3035
  },
2868
3036
  resolvePlan: () => {
2869
3037
  const instance = this.instanceById(instanceId);
2870
- if (!instance) return null;
3038
+ const plan = this.planFor(instanceId, profile);
3039
+ if (!instance || plan === null || !plan.offered || !plan.published) return null;
2871
3040
  return gridPlanFor(instance, {
2872
3041
  encoder: "libx264",
2873
- decodeHwAccel: null
3042
+ decodeHwAccel: null,
3043
+ canvas: plan.canvas
2874
3044
  });
2875
3045
  }
2876
3046
  });
@@ -2878,14 +3048,18 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2878
3048
  }
2879
3049
  /** The session a dial is asking for, or `null` - which the relay answers 404 to. */
2880
3050
  sessionFor(request) {
2881
- return this.sessions.get(request.instanceId)?.session ?? null;
3051
+ return this.sessions.get(sessionKey(request.instanceId, request.profile))?.session ?? null;
2882
3052
  }
2883
- /** This grid's composition, as the list the snapshot source expects. */
3053
+ /** This grid's sessions, in DESCENDING quality — the snapshot takes the first running one. */
2884
3054
  sessionsForDevice(deviceId) {
2885
3055
  const instanceId = this.instanceIdForDevice(deviceId);
2886
3056
  if (instanceId === null) return [];
2887
- const entry = this.sessions.get(instanceId);
2888
- return entry ? [entry] : [];
3057
+ const entries = [];
3058
+ for (const profile of GRID_PROFILES) {
3059
+ const entry = this.sessions.get(sessionKey(instanceId, profile));
3060
+ if (entry) entries.push(entry);
3061
+ }
3062
+ return entries;
2889
3063
  }
2890
3064
  /** A grid that is gone stops immediately - it must not keep N sources dialled. */
2891
3065
  async retireWithdrawnSessions(instances) {
@@ -2903,7 +3077,7 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2903
3077
  if (live.has(instanceId)) continue;
2904
3078
  this.activity?.forget(this.deviceIdByInstanceId.get(instanceId) ?? 0);
2905
3079
  this.deviceIdByInstanceId.delete(instanceId);
2906
- this.planByInstanceId.delete(instanceId);
3080
+ this.plansByInstanceId.delete(instanceId);
2907
3081
  }
2908
3082
  }
2909
3083
  /**
@@ -2951,15 +3125,18 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2951
3125
  descriptorsFor(instanceId) {
2952
3126
  const instance = this.instanceById(instanceId);
2953
3127
  const relay = this.relay;
2954
- const plan = this.planFor(instanceId);
2955
- if (!instance || !relay || plan === null) return null;
2956
- if (!plan.offered) return [];
2957
- return [gridStreamDescriptor({
2958
- width: instance.width,
2959
- height: instance.height,
3128
+ const plans = this.plansFor(instanceId);
3129
+ if (!instance || !relay || plans === null) return null;
3130
+ return plans.filter((row) => row.offered && row.published).map((row) => gridStreamDescriptor({
3131
+ profile: row.profile,
3132
+ width: row.offered ? row.canvas.width : 0,
3133
+ height: row.offered ? row.canvas.height : 0,
2960
3134
  fps: instance.fps,
2961
- url: relay.streamUrl({ instanceId })
2962
- })];
3135
+ url: relay.streamUrl({
3136
+ instanceId,
3137
+ profile: row.profile
3138
+ })
3139
+ }));
2963
3140
  }
2964
3141
  instanceIdForDevice(deviceId) {
2965
3142
  for (const [instanceId, id] of this.deviceIdByInstanceId) if (id === deviceId) return instanceId;
@@ -2976,23 +3153,19 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2976
3153
  instanceId: instance.id,
2977
3154
  deviceId,
2978
3155
  name: instance.name,
2979
- width: instance.width,
2980
- height: instance.height,
2981
3156
  fps: instance.fps,
2982
3157
  cells: instance.cells.map((cell) => ({
2983
3158
  deviceId: cell.deviceId,
2984
3159
  source: cell.source,
2985
3160
  cell: cell.cell
2986
3161
  })),
2987
- profiles: (() => {
2988
- const plan = this.planByInstanceId.get(instance.id);
2989
- if (plan === void 0) return [];
2990
- return [{
2991
- profile: GRID_OUTPUT_PROFILE,
2992
- offered: plan.offered,
2993
- missingSources: [...plan.missingSources]
2994
- }];
2995
- })()
3162
+ profiles: (this.plansByInstanceId.get(instance.id) ?? []).map((row) => ({
3163
+ profile: row.profile,
3164
+ offered: row.offered,
3165
+ published: row.offered && row.published,
3166
+ missingSources: row.offered ? [] : [...row.missingSources],
3167
+ canvas: row.offered ? `${String(row.canvas.width)}x${String(row.canvas.height)}` : ""
3168
+ }))
2996
3169
  };
2997
3170
  }
2998
3171
  async saveGridLayout(input) {
@@ -3001,9 +3174,8 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
3001
3174
  const updated = {
3002
3175
  ...instance,
3003
3176
  ...input.name !== void 0 ? { name: input.name.trim() } : {},
3004
- ...input.width !== void 0 ? { width: input.width } : {},
3005
- ...input.height !== void 0 ? { height: input.height } : {},
3006
3177
  ...input.fps !== void 0 ? { fps: input.fps } : {},
3178
+ ...input.publishedProfiles !== void 0 ? { publishedProfiles: [...input.publishedProfiles] } : {},
3007
3179
  cells: input.cells.map((cell) => ({ ...cell }))
3008
3180
  };
3009
3181
  this.ctx.logger.info("camera grid: layout saved", {
@@ -3011,7 +3183,7 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
3011
3183
  meta: {
3012
3184
  cells: updated.cells.length,
3013
3185
  sources: [...new Set(updated.cells.map((cell) => cell.deviceId))],
3014
- canvas: `${String(updated.width)}x${String(updated.height)}`
3186
+ fps: updated.fps
3015
3187
  }
3016
3188
  });
3017
3189
  await this.updateGlobalSettings({ cameraGrids: this.config.cameraGrids.map((row) => row["id"] === instance.id ? {
@@ -3350,7 +3522,7 @@ async function startGridChild(ctx, plan, sources, onEnded) {
3350
3522
  //#endregion
3351
3523
  exports.CameraGridAddon = CameraGridAddon;
3352
3524
  exports.GRID_CAM_STREAM_ID = GRID_CAM_STREAM_ID;
3353
- exports.GRID_OUTPUT_PROFILE = GRID_OUTPUT_PROFILE;
3525
+ exports.GRID_PROFILES = GRID_PROFILES;
3354
3526
  exports.GRID_SILENCED_CAP_NAMES = GRID_SILENCED_CAP_NAMES;
3355
3527
  exports.GridCameraDevice = GridCameraDevice;
3356
3528
  exports.GridFrameSampler = GridFrameSampler;
@@ -3361,15 +3533,16 @@ exports.GridStreamSession = GridStreamSession;
3361
3533
  exports.buildGridCameraDeclarations = buildGridCameraDeclarations;
3362
3534
  exports.buildGridFilterGraph = buildGridFilterGraph;
3363
3535
  exports.default = CameraGridAddon;
3536
+ exports.deriveGridCanvas = deriveGridCanvas;
3537
+ exports.gridCamStreamId = gridCamStreamId;
3364
3538
  exports.gridCameraReconciliationIndex = gridCameraReconciliationIndex;
3365
3539
  exports.gridPlanFor = gridPlanFor;
3540
+ exports.gridProfilePlans = gridProfilePlans;
3366
3541
  exports.gridSnapshotAnswerFor = gridSnapshotAnswerFor;
3367
3542
  exports.gridSourceDial = gridSourceDial;
3368
- exports.gridSourceProfiles = gridSourceProfiles;
3369
3543
  exports.gridStreamDescriptor = gridStreamDescriptor;
3370
3544
  exports.gridStreamInvocation = gridStreamInvocation;
3371
3545
  exports.installGridCameraRuntime = installGridCameraRuntime;
3372
- exports.lowestServedProfile = lowestServedProfile;
3373
3546
  exports.newGridCameraStableId = newGridCameraStableId;
3374
3547
  exports.normalizeGridRows = normalizeGridRows;
3375
3548
  exports.parseGridStreamPath = parseGridStreamPath;