@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.
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/autotrack/index.js +1 -1
- package/dist/builtins/autotrack/index.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/camera-grid/addon.d.ts +13 -2
- package/dist/builtins/camera-grid/grid-canvas-derivation.d.ts +19 -0
- package/dist/builtins/camera-grid/grid-compositor.d.ts +8 -0
- package/dist/builtins/camera-grid/grid-instances.d.ts +5 -2
- package/dist/builtins/camera-grid/grid-plan.d.ts +8 -6
- package/dist/builtins/camera-grid/grid-profiles.d.ts +42 -39
- package/dist/builtins/camera-grid/grid-stream-descriptor.d.ts +7 -4
- package/dist/builtins/camera-grid/grid-stream-relay.d.ts +10 -7
- package/dist/builtins/camera-grid/grid-tile-feed.d.ts +12 -0
- package/dist/builtins/camera-grid/index.d.ts +3 -2
- package/dist/builtins/camera-grid/index.js +438 -265
- package/dist/builtins/camera-grid/index.mjs +435 -263
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +2 -2
- package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-BbPssgI6.js → dist-BTpfLiUX.js} +31 -6
- package/dist/{dist-Ck0rjYOQ.mjs → dist-CqmImT7i.mjs} +31 -6
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{retired-settings-keys-BZpDuJVh.mjs → retired-settings-keys-C8nV-AUi.mjs} +1 -1
- package/dist/{retired-settings-keys-Dg_69cOY.js → retired-settings-keys-D_DIuT_N.js} +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $t as streamCatalogCapability, At as motionCapability, Jt as snapshotCapability, Pn as EventCategory, U as cameraGridLayoutCapability, Y as declarationOwnerNodeId, an as errMsg, b as MOTION_CLOSE_AFTER_MS, bn as createEvent, dn as DeviceFeature, f as DERIVED_CAMERA_SILENCED_CAP_NAMES, i as BaseDevice, on as buildFfmpegArgs, p as DeclaredDevices, pn as DeviceType, sn as BaseAddon, wn as hydrateSchema } from "../../dist-
|
|
1
|
+
import { $t as streamCatalogCapability, At as motionCapability, Jt as snapshotCapability, Pn as EventCategory, U as cameraGridLayoutCapability, Y as declarationOwnerNodeId, an as errMsg, b as MOTION_CLOSE_AFTER_MS, bn as createEvent, dn as DeviceFeature, f as DERIVED_CAMERA_SILENCED_CAP_NAMES, i as BaseDevice, on as buildFfmpegArgs, p as DeclaredDevices, pn as DeviceType, sn as BaseAddon, wn as hydrateSchema } from "../../dist-CqmImT7i.mjs";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { FfmpegProcess } from "@camstack/types/node";
|
|
4
4
|
import { join } from "node:path";
|
|
@@ -255,11 +255,36 @@ var GridInstanceSchema = z.object({
|
|
|
255
255
|
cameraStableId: z.string().min(1).max(256),
|
|
256
256
|
name: z.string().min(1).max(160),
|
|
257
257
|
enabled: z.boolean(),
|
|
258
|
-
/**
|
|
259
|
-
|
|
260
|
-
|
|
258
|
+
/**
|
|
259
|
+
* NO canvas size. It is DERIVED, per profile, from the cells and the sources'
|
|
260
|
+
* own dimensions (`grid-canvas-derivation.ts`) — the two numbers that used to
|
|
261
|
+
* live here were typed into the editor and silently decided both how much the
|
|
262
|
+
* composite cost and how sharp it was.
|
|
263
|
+
*
|
|
264
|
+
* Rows written before that change still carry `width`/`height`; zod drops
|
|
265
|
+
* unknown keys, so they are ignored rather than migrated. Nothing reads them,
|
|
266
|
+
* and a migration that rewrote every row to delete two fields would be a
|
|
267
|
+
* write with no reader.
|
|
268
|
+
*/
|
|
261
269
|
fps: z.number().int().min(1).max(60),
|
|
262
270
|
/**
|
|
271
|
+
* Which profiles this grid PUBLISHES, of the ones it could serve.
|
|
272
|
+
*
|
|
273
|
+
* Default `mid` + `low`, and the default is the finding: a grid's `high` is
|
|
274
|
+
* composed of its sources' `high`, so on this fleet it is a 4K canvas built
|
|
275
|
+
* from 4K decodes — a thing to opt into, not to be handed by a viewer's
|
|
276
|
+
* adaptive climbing to the top rung it can see.
|
|
277
|
+
*
|
|
278
|
+
* Absent on rows written before this, which is why the default lives in the
|
|
279
|
+
* schema rather than at the call site: an old grid starts publishing the two
|
|
280
|
+
* cheap rungs without anyone touching it.
|
|
281
|
+
*/
|
|
282
|
+
publishedProfiles: z.array(z.enum([
|
|
283
|
+
"high",
|
|
284
|
+
"mid",
|
|
285
|
+
"low"
|
|
286
|
+
])).max(3).default(["mid", "low"]),
|
|
287
|
+
/**
|
|
263
288
|
* Empty is the NORMAL freshly-created state: a grid is created from the
|
|
264
289
|
* integration page with a name only, and laid out afterwards in the camera's
|
|
265
290
|
* own settings. A grid with no cells declares its camera and emits nothing.
|
|
@@ -320,10 +345,7 @@ function gridPlanFor(instance, encode) {
|
|
|
320
345
|
cell: cell.cell
|
|
321
346
|
};
|
|
322
347
|
});
|
|
323
|
-
const canvas = encode.canvas
|
|
324
|
-
width: instance.width,
|
|
325
|
-
height: instance.height
|
|
326
|
-
};
|
|
348
|
+
const canvas = encode.canvas;
|
|
327
349
|
return {
|
|
328
350
|
layout: {
|
|
329
351
|
width: canvas.width,
|
|
@@ -921,92 +943,240 @@ var GridLastFrameFileStore = class {
|
|
|
921
943
|
}
|
|
922
944
|
};
|
|
923
945
|
//#endregion
|
|
924
|
-
//#region src/builtins/camera-grid/grid-
|
|
946
|
+
//#region src/builtins/camera-grid/grid-canvas.ts
|
|
947
|
+
/** Bytes one yuv420p frame of this size occupies. */
|
|
948
|
+
function yuv420pSize(width, height) {
|
|
949
|
+
return width * height + 2 * (width / 2) * (height / 2);
|
|
950
|
+
}
|
|
925
951
|
/**
|
|
926
|
-
*
|
|
952
|
+
* Round DOWN to even.
|
|
927
953
|
*
|
|
928
|
-
*
|
|
929
|
-
*
|
|
930
|
-
*
|
|
931
|
-
* detour and it is `high` again, so nothing an operator assigned is disturbed.
|
|
954
|
+
* Down and not nearest: a tile that rounded UP would claim a row of samples the
|
|
955
|
+
* decoder was never asked to produce, and the copy would either overrun or be
|
|
956
|
+
* refused at the moment the picture is wanted.
|
|
932
957
|
*/
|
|
933
|
-
|
|
958
|
+
function evenDown(value) {
|
|
959
|
+
return Math.max(0, Math.floor(value / 2) * 2);
|
|
960
|
+
}
|
|
961
|
+
var GridCanvas = class {
|
|
962
|
+
width;
|
|
963
|
+
height;
|
|
964
|
+
buffer;
|
|
965
|
+
lumaBytes;
|
|
966
|
+
chromaBytes;
|
|
967
|
+
constructor(width, height) {
|
|
968
|
+
this.width = width;
|
|
969
|
+
this.height = height;
|
|
970
|
+
this.lumaBytes = width * height;
|
|
971
|
+
this.chromaBytes = width / 2 * (height / 2);
|
|
972
|
+
this.buffer = Buffer.alloc(yuv420pSize(width, height));
|
|
973
|
+
this.clear();
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Legible black — Y 0, chroma 128 (neutral).
|
|
977
|
+
*
|
|
978
|
+
* `Buffer.alloc` already zeroes, and zeroed CHROMA is not black: it is a
|
|
979
|
+
* strong green. A cell nobody has filled yet has to look like an empty cell,
|
|
980
|
+
* not like a fault.
|
|
981
|
+
*/
|
|
982
|
+
clear() {
|
|
983
|
+
this.buffer.fill(0, 0, this.lumaBytes);
|
|
984
|
+
this.buffer.fill(128, this.lumaBytes);
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Copy one decoded tile in at `origin`.
|
|
988
|
+
*
|
|
989
|
+
* Returns false — never throws, never writes a partial tile — when the tile
|
|
990
|
+
* does not fit, is the wrong size for the dimensions it claims, or sits on an
|
|
991
|
+
* odd boundary. This runs on the output tick, so a bad frame from one source
|
|
992
|
+
* must cost that source's rectangle and nothing else.
|
|
993
|
+
*/
|
|
994
|
+
blit(tile, size, origin) {
|
|
995
|
+
if (!isEven(size.width) || !isEven(size.height)) return false;
|
|
996
|
+
if (!isEven(origin.x) || !isEven(origin.y)) return false;
|
|
997
|
+
if (size.width <= 0 || size.height <= 0) return false;
|
|
998
|
+
if (origin.x < 0 || origin.y < 0) return false;
|
|
999
|
+
if (origin.x + size.width > this.width) return false;
|
|
1000
|
+
if (origin.y + size.height > this.height) return false;
|
|
1001
|
+
if (tile.length !== yuv420pSize(size.width, size.height)) return false;
|
|
1002
|
+
for (let row = 0; row < size.height; row += 1) {
|
|
1003
|
+
const from = row * size.width;
|
|
1004
|
+
const to = (origin.y + row) * this.width + origin.x;
|
|
1005
|
+
tile.copy(this.buffer, to, from, from + size.width);
|
|
1006
|
+
}
|
|
1007
|
+
const tileLuma = size.width * size.height;
|
|
1008
|
+
const tileChroma = size.width / 2 * (size.height / 2);
|
|
1009
|
+
const halfW = size.width / 2;
|
|
1010
|
+
const halfH = size.height / 2;
|
|
1011
|
+
const canvasHalfW = this.width / 2;
|
|
1012
|
+
for (let plane = 0; plane < 2; plane += 1) {
|
|
1013
|
+
const tileBase = tileLuma + plane * tileChroma;
|
|
1014
|
+
const canvasBase = this.lumaBytes + plane * this.chromaBytes;
|
|
1015
|
+
for (let row = 0; row < halfH; row += 1) {
|
|
1016
|
+
const from = tileBase + row * halfW;
|
|
1017
|
+
const to = canvasBase + (origin.y / 2 + row) * canvasHalfW + origin.x / 2;
|
|
1018
|
+
tile.copy(this.buffer, to, from, from + halfW);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
return true;
|
|
1022
|
+
}
|
|
1023
|
+
};
|
|
1024
|
+
function isEven(value) {
|
|
1025
|
+
return Number.isInteger(value) && value % 2 === 0;
|
|
1026
|
+
}
|
|
1027
|
+
//#endregion
|
|
1028
|
+
//#region src/builtins/camera-grid/grid-canvas-derivation.ts
|
|
934
1029
|
/**
|
|
935
|
-
* The
|
|
1030
|
+
* The canvas is a RESULT, not a field.
|
|
1031
|
+
*
|
|
1032
|
+
* ## Why the operator stopped typing it
|
|
1033
|
+
*
|
|
1034
|
+
* A grid's resolution used to be two numbers in the editor, beside FPS. They
|
|
1035
|
+
* looked harmless and were not: that number silently decided how much the
|
|
1036
|
+
* composite cost and how sharp it was. An operator typing 1920x1080 was, without
|
|
1037
|
+
* being told, committing the hub to reading two 4 MP streams — or, with the
|
|
1038
|
+
* rule before it, to upscaling a 640x360 sub-stream by 3x. Neither is a thing
|
|
1039
|
+
* anyone can be expected to work out from a text field.
|
|
1040
|
+
*
|
|
1041
|
+
* ## What it is derived from
|
|
1042
|
+
*
|
|
1043
|
+
* The operator draws two rectangles per camera — which part of the source to
|
|
1044
|
+
* take, and where it lands — and both are fractions, so nothing is pixels yet.
|
|
1045
|
+
* Exactly one number is missing: how many pixels the whole canvas is worth.
|
|
1046
|
+
*
|
|
1047
|
+
* A cell has `sourceResolution x crop` REAL pixels behind it. Showing them 1:1
|
|
1048
|
+
* neither invents nor discards anything, so the cell should be exactly that
|
|
1049
|
+
* many pixels — and since the cell is a fraction `f` of the canvas, the canvas
|
|
1050
|
+
* it implies is `real / f`. Each cell proposes one, and the LARGEST wins: the
|
|
1051
|
+
* hungriest cell is served 1:1 and the others scale down by a hair, never up.
|
|
1052
|
+
*
|
|
1053
|
+
* Gaps change nothing. Cells need not cover the canvas, and an uncovered part
|
|
1054
|
+
* is background — it has no pixels to propose.
|
|
1055
|
+
*
|
|
1056
|
+
* ## One per profile
|
|
936
1057
|
*
|
|
937
|
-
*
|
|
938
|
-
*
|
|
939
|
-
*
|
|
940
|
-
*
|
|
941
|
-
*
|
|
1058
|
+
* A source is a different size at each profile, so each profile derives its own
|
|
1059
|
+
* canvas: a grid's `low` composes its sources' `low` onto the canvas their
|
|
1060
|
+
* `low` implies. That is what makes the three outputs 1:1 with what they are
|
|
1061
|
+
* made of, and what lets a viewer's adaptive pick a rung without the grid
|
|
1062
|
+
* needing to know it exists.
|
|
942
1063
|
*/
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1064
|
+
/**
|
|
1065
|
+
* What an encoder will take. Not a taste: `libx264` refuses beyond 8192 in
|
|
1066
|
+
* either axis, and a canvas that large is a composition nobody can decode
|
|
1067
|
+
* anyway. Reached only by a cell that shows a sliver of a source at full
|
|
1068
|
+
* canvas width, which is a layout, not a mistake — so it is CLAMPED rather
|
|
1069
|
+
* than refused, and clamped in both axes together so the picture the operator
|
|
1070
|
+
* drew keeps its shape.
|
|
1071
|
+
*/
|
|
1072
|
+
var MAX_CANVAS_WIDTH = 7680;
|
|
1073
|
+
var MAX_CANVAS_HEIGHT = 4320;
|
|
1074
|
+
/** Below this a composition is not a cheaper picture, it is no picture. */
|
|
1075
|
+
var MIN_CANVAS_EDGE = 160;
|
|
1076
|
+
/**
|
|
1077
|
+
* The canvas these cells imply, or `null` when they imply none.
|
|
1078
|
+
*
|
|
1079
|
+
* `null` for a grid with no cells — the normal freshly-created state — and for
|
|
1080
|
+
* a layout whose every rectangle is degenerate. Both are "there is no picture
|
|
1081
|
+
* to size", which the caller turns into "this profile is not on offer" rather
|
|
1082
|
+
* than into a guess.
|
|
1083
|
+
*/
|
|
1084
|
+
function deriveGridCanvas(cells) {
|
|
1085
|
+
let width = 0;
|
|
1086
|
+
let height = 0;
|
|
1087
|
+
for (const entry of cells) {
|
|
1088
|
+
if (entry.cell.width <= 0 || entry.cell.height <= 0) continue;
|
|
1089
|
+
if (entry.sourceSize.width <= 0 || entry.sourceSize.height <= 0) continue;
|
|
1090
|
+
const realWidth = entry.sourceSize.width * entry.source.width;
|
|
1091
|
+
const realHeight = entry.sourceSize.height * entry.source.height;
|
|
1092
|
+
if (realWidth <= 0 || realHeight <= 0) continue;
|
|
1093
|
+
width = Math.max(width, realWidth / entry.cell.width);
|
|
1094
|
+
height = Math.max(height, realHeight / entry.cell.height);
|
|
1095
|
+
}
|
|
1096
|
+
if (width <= 0 || height <= 0) return null;
|
|
1097
|
+
const overshoot = Math.max(width / MAX_CANVAS_WIDTH, height / MAX_CANVAS_HEIGHT, 1);
|
|
1098
|
+
const factor = Math.max(MIN_CANVAS_EDGE / width, MIN_CANVAS_EDGE / height, 1) / overshoot;
|
|
960
1099
|
return {
|
|
961
|
-
width:
|
|
962
|
-
height:
|
|
1100
|
+
width: Math.max(2, evenDown(width * factor)),
|
|
1101
|
+
height: Math.max(2, evenDown(height * factor))
|
|
963
1102
|
};
|
|
964
1103
|
}
|
|
1104
|
+
//#endregion
|
|
1105
|
+
//#region src/builtins/camera-grid/grid-profiles.ts
|
|
965
1106
|
/**
|
|
966
|
-
*
|
|
1107
|
+
* One id per profile, for every grid, forever.
|
|
967
1108
|
*
|
|
968
|
-
*
|
|
969
|
-
*
|
|
970
|
-
*
|
|
971
|
-
*
|
|
972
|
-
* A stream that states NO size cannot be compared, so it is ordered as cheapest
|
|
973
|
-
* rather than discarded — an unmeasurable stream is still a stream, and the
|
|
974
|
-
* picture is what the operator asked for.
|
|
1109
|
+
* D519's reason is what keeps `high`'s id bare: a newly-spelled id mints a
|
|
1110
|
+
* second cam-stream and orphans whatever profile assignment the operator had
|
|
1111
|
+
* made. `grid` was `high` on the live hub before any of this and still is.
|
|
975
1112
|
*/
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1113
|
+
var GRID_CAM_STREAM_ID = "grid";
|
|
1114
|
+
/** Every profile a grid can compose, in descending quality. */
|
|
1115
|
+
var GRID_PROFILES = [
|
|
1116
|
+
"high",
|
|
1117
|
+
"mid",
|
|
1118
|
+
"low"
|
|
1119
|
+
];
|
|
1120
|
+
/** The cam-stream id a grid publishes for one profile. */
|
|
1121
|
+
function gridCamStreamId(profile) {
|
|
1122
|
+
return profile === "high" ? GRID_CAM_STREAM_ID : `${GRID_CAM_STREAM_ID}-${profile}`;
|
|
985
1123
|
}
|
|
986
|
-
/**
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
const
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1124
|
+
/**
|
|
1125
|
+
* What this grid can honestly publish, per profile, with the canvas or the
|
|
1126
|
+
* refusal.
|
|
1127
|
+
*
|
|
1128
|
+
* Every profile gets a row whether it is offered or not: an absent row says
|
|
1129
|
+
* nothing, and the caller logging a refusal needs the reason more than the
|
|
1130
|
+
* caller publishing an offer needs the offer.
|
|
1131
|
+
*/
|
|
1132
|
+
function gridProfilePlans(input) {
|
|
1133
|
+
const sourceDeviceIds = [...new Set(input.cells.map((cell) => cell.deviceId))];
|
|
1134
|
+
const servedBy = /* @__PURE__ */ new Map();
|
|
1135
|
+
for (const deviceId of sourceDeviceIds) servedBy.set(deviceId, input.profilesServedBy(deviceId));
|
|
1136
|
+
return GRID_PROFILES.map((profile) => {
|
|
1137
|
+
if (sourceDeviceIds.length === 0) return {
|
|
1138
|
+
profile,
|
|
1139
|
+
offered: false,
|
|
1140
|
+
missingSources: []
|
|
1141
|
+
};
|
|
1142
|
+
const sizeBySource = /* @__PURE__ */ new Map();
|
|
1143
|
+
const missingSources = [];
|
|
1144
|
+
for (const deviceId of sourceDeviceIds) {
|
|
1145
|
+
const match = (servedBy.get(deviceId) ?? null)?.find((entry) => entry.profile === profile);
|
|
1146
|
+
if (match === void 0 || match.width <= 0 || match.height <= 0) {
|
|
1147
|
+
missingSources.push(deviceId);
|
|
1148
|
+
continue;
|
|
1149
|
+
}
|
|
1150
|
+
sizeBySource.set(deviceId, {
|
|
1151
|
+
width: match.width,
|
|
1152
|
+
height: match.height
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
if (missingSources.length > 0) return {
|
|
1156
|
+
profile,
|
|
1157
|
+
offered: false,
|
|
1158
|
+
missingSources
|
|
1159
|
+
};
|
|
1160
|
+
const canvas = deriveGridCanvas(input.cells.map((cell) => ({
|
|
1161
|
+
source: cell.source,
|
|
1162
|
+
cell: cell.cell,
|
|
1163
|
+
sourceSize: sizeBySource.get(cell.deviceId) ?? {
|
|
1164
|
+
width: 0,
|
|
1165
|
+
height: 0
|
|
1166
|
+
}
|
|
1167
|
+
})));
|
|
1168
|
+
if (canvas === null) return {
|
|
1169
|
+
profile,
|
|
1170
|
+
offered: false,
|
|
1171
|
+
missingSources: []
|
|
1172
|
+
};
|
|
1173
|
+
return {
|
|
1174
|
+
profile,
|
|
1175
|
+
offered: true,
|
|
1176
|
+
canvas,
|
|
1177
|
+
published: input.publishedProfiles.includes(profile)
|
|
1178
|
+
};
|
|
1179
|
+
});
|
|
1010
1180
|
}
|
|
1011
1181
|
//#endregion
|
|
1012
1182
|
//#region src/builtins/camera-grid/grid-sentry-sources.ts
|
|
@@ -1171,15 +1341,24 @@ var PATH_PREFIX = "/grid/";
|
|
|
1171
1341
|
var PATH_SUFFIX = GRID_WIRE_PATH_SUFFIX;
|
|
1172
1342
|
/** How long a reader may refuse to drain before it is cut. */
|
|
1173
1343
|
var SLOW_READER_MS = 5e3;
|
|
1174
|
-
|
|
1344
|
+
function asProfile(raw) {
|
|
1345
|
+
return GRID_PROFILES.find((profile) => profile === raw) ?? null;
|
|
1346
|
+
}
|
|
1347
|
+
/** `/grid/<instanceId>/<profile>.flv`, or `null` when it is anything else. */
|
|
1175
1348
|
function parseGridStreamPath(url) {
|
|
1176
1349
|
const path = (url ?? "").split("?")[0] ?? "";
|
|
1177
1350
|
if (!path.startsWith(PATH_PREFIX) || !path.endsWith(PATH_SUFFIX)) return null;
|
|
1178
1351
|
const raw = path.slice(6, path.length - PATH_SUFFIX.length);
|
|
1179
|
-
|
|
1352
|
+
const at = raw.lastIndexOf("/");
|
|
1353
|
+
if (at <= 0) return null;
|
|
1354
|
+
const profile = asProfile(raw.slice(at + 1));
|
|
1355
|
+
if (profile === null) return null;
|
|
1180
1356
|
try {
|
|
1181
|
-
const instanceId = decodeURIComponent(raw);
|
|
1182
|
-
return instanceId.length > 0 ? {
|
|
1357
|
+
const instanceId = decodeURIComponent(raw.slice(0, at));
|
|
1358
|
+
return instanceId.length > 0 ? {
|
|
1359
|
+
instanceId,
|
|
1360
|
+
profile
|
|
1361
|
+
} : null;
|
|
1183
1362
|
} catch {
|
|
1184
1363
|
return null;
|
|
1185
1364
|
}
|
|
@@ -1218,13 +1397,16 @@ var GridStreamRelay = class {
|
|
|
1218
1397
|
streamUrl(request) {
|
|
1219
1398
|
if (!this.baseUrl) throw new Error("camera-grid: the stream relay is not started");
|
|
1220
1399
|
const instance = encodeURIComponent(request.instanceId);
|
|
1221
|
-
return `${this.baseUrl}${PATH_PREFIX}${instance}${PATH_SUFFIX}`;
|
|
1400
|
+
return `${this.baseUrl}${PATH_PREFIX}${instance}/${request.profile}${PATH_SUFFIX}`;
|
|
1222
1401
|
}
|
|
1223
1402
|
async serve(request, res) {
|
|
1224
|
-
const { instanceId } = request;
|
|
1403
|
+
const { instanceId, profile } = request;
|
|
1225
1404
|
const session = this.deps.sessionFor(request);
|
|
1226
1405
|
if (session === null) {
|
|
1227
|
-
this.deps.logger.warn("camera grid: a stream was requested for an unknown grid", { meta: {
|
|
1406
|
+
this.deps.logger.warn("camera grid: a stream was requested for an unknown grid", { meta: {
|
|
1407
|
+
instanceId,
|
|
1408
|
+
profile
|
|
1409
|
+
} });
|
|
1228
1410
|
res.writeHead(404).end();
|
|
1229
1411
|
return;
|
|
1230
1412
|
}
|
|
@@ -1258,6 +1440,7 @@ var GridStreamRelay = class {
|
|
|
1258
1440
|
tags: { deviceId: session.deviceId },
|
|
1259
1441
|
meta: {
|
|
1260
1442
|
instanceId,
|
|
1443
|
+
profile,
|
|
1261
1444
|
stalledForMs: SLOW_READER_MS
|
|
1262
1445
|
}
|
|
1263
1446
|
});
|
|
@@ -1270,6 +1453,7 @@ var GridStreamRelay = class {
|
|
|
1270
1453
|
tags: { deviceId: session.deviceId },
|
|
1271
1454
|
meta: {
|
|
1272
1455
|
instanceId,
|
|
1456
|
+
profile,
|
|
1273
1457
|
reason
|
|
1274
1458
|
}
|
|
1275
1459
|
});
|
|
@@ -1282,6 +1466,7 @@ var GridStreamRelay = class {
|
|
|
1282
1466
|
tags: { deviceId: session.deviceId },
|
|
1283
1467
|
meta: {
|
|
1284
1468
|
instanceId,
|
|
1469
|
+
profile,
|
|
1285
1470
|
error: errMsg(error)
|
|
1286
1471
|
}
|
|
1287
1472
|
});
|
|
@@ -1317,13 +1502,13 @@ var GridStreamRelay = class {
|
|
|
1317
1502
|
* scrub never reads. The Dreame robot's one 720p relay landed exactly there and
|
|
1318
1503
|
* its footage was written nowhere anything looked.
|
|
1319
1504
|
*
|
|
1320
|
-
*
|
|
1321
|
-
*
|
|
1322
|
-
*
|
|
1505
|
+
* With several compositions published the hint does a second job: it says which
|
|
1506
|
+
* slot each one is FOR. Ranked by pixels a grid's `mid` would take the `high`
|
|
1507
|
+
* slot on any grid whose `high` was not offered.
|
|
1323
1508
|
*/
|
|
1324
1509
|
function gridStreamDescriptor(input) {
|
|
1325
1510
|
return {
|
|
1326
|
-
camStreamId:
|
|
1511
|
+
camStreamId: gridCamStreamId(input.profile),
|
|
1327
1512
|
kind: GRID_WIRE_STREAM_KIND,
|
|
1328
1513
|
url: input.url,
|
|
1329
1514
|
codec: "h264",
|
|
@@ -1332,8 +1517,8 @@ function gridStreamDescriptor(input) {
|
|
|
1332
1517
|
height: input.height
|
|
1333
1518
|
},
|
|
1334
1519
|
fps: input.fps,
|
|
1335
|
-
label:
|
|
1336
|
-
profileHint:
|
|
1520
|
+
label: `Grid composition (${input.profile})`,
|
|
1521
|
+
profileHint: input.profile,
|
|
1337
1522
|
autoEligible: true
|
|
1338
1523
|
};
|
|
1339
1524
|
}
|
|
@@ -1836,88 +2021,6 @@ async function silenceAnalysisFor(deps, deviceId) {
|
|
|
1836
2021
|
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("; ")})`);
|
|
1837
2022
|
}
|
|
1838
2023
|
//#endregion
|
|
1839
|
-
//#region src/builtins/camera-grid/grid-canvas.ts
|
|
1840
|
-
/** Bytes one yuv420p frame of this size occupies. */
|
|
1841
|
-
function yuv420pSize(width, height) {
|
|
1842
|
-
return width * height + 2 * (width / 2) * (height / 2);
|
|
1843
|
-
}
|
|
1844
|
-
/**
|
|
1845
|
-
* Round DOWN to even.
|
|
1846
|
-
*
|
|
1847
|
-
* Down and not nearest: a tile that rounded UP would claim a row of samples the
|
|
1848
|
-
* decoder was never asked to produce, and the copy would either overrun or be
|
|
1849
|
-
* refused at the moment the picture is wanted.
|
|
1850
|
-
*/
|
|
1851
|
-
function evenDown(value) {
|
|
1852
|
-
return Math.max(0, Math.floor(value / 2) * 2);
|
|
1853
|
-
}
|
|
1854
|
-
var GridCanvas = class {
|
|
1855
|
-
width;
|
|
1856
|
-
height;
|
|
1857
|
-
buffer;
|
|
1858
|
-
lumaBytes;
|
|
1859
|
-
chromaBytes;
|
|
1860
|
-
constructor(width, height) {
|
|
1861
|
-
this.width = width;
|
|
1862
|
-
this.height = height;
|
|
1863
|
-
this.lumaBytes = width * height;
|
|
1864
|
-
this.chromaBytes = width / 2 * (height / 2);
|
|
1865
|
-
this.buffer = Buffer.alloc(yuv420pSize(width, height));
|
|
1866
|
-
this.clear();
|
|
1867
|
-
}
|
|
1868
|
-
/**
|
|
1869
|
-
* Legible black — Y 0, chroma 128 (neutral).
|
|
1870
|
-
*
|
|
1871
|
-
* `Buffer.alloc` already zeroes, and zeroed CHROMA is not black: it is a
|
|
1872
|
-
* strong green. A cell nobody has filled yet has to look like an empty cell,
|
|
1873
|
-
* not like a fault.
|
|
1874
|
-
*/
|
|
1875
|
-
clear() {
|
|
1876
|
-
this.buffer.fill(0, 0, this.lumaBytes);
|
|
1877
|
-
this.buffer.fill(128, this.lumaBytes);
|
|
1878
|
-
}
|
|
1879
|
-
/**
|
|
1880
|
-
* Copy one decoded tile in at `origin`.
|
|
1881
|
-
*
|
|
1882
|
-
* Returns false — never throws, never writes a partial tile — when the tile
|
|
1883
|
-
* does not fit, is the wrong size for the dimensions it claims, or sits on an
|
|
1884
|
-
* odd boundary. This runs on the output tick, so a bad frame from one source
|
|
1885
|
-
* must cost that source's rectangle and nothing else.
|
|
1886
|
-
*/
|
|
1887
|
-
blit(tile, size, origin) {
|
|
1888
|
-
if (!isEven(size.width) || !isEven(size.height)) return false;
|
|
1889
|
-
if (!isEven(origin.x) || !isEven(origin.y)) return false;
|
|
1890
|
-
if (size.width <= 0 || size.height <= 0) return false;
|
|
1891
|
-
if (origin.x < 0 || origin.y < 0) return false;
|
|
1892
|
-
if (origin.x + size.width > this.width) return false;
|
|
1893
|
-
if (origin.y + size.height > this.height) return false;
|
|
1894
|
-
if (tile.length !== yuv420pSize(size.width, size.height)) return false;
|
|
1895
|
-
for (let row = 0; row < size.height; row += 1) {
|
|
1896
|
-
const from = row * size.width;
|
|
1897
|
-
const to = (origin.y + row) * this.width + origin.x;
|
|
1898
|
-
tile.copy(this.buffer, to, from, from + size.width);
|
|
1899
|
-
}
|
|
1900
|
-
const tileLuma = size.width * size.height;
|
|
1901
|
-
const tileChroma = size.width / 2 * (size.height / 2);
|
|
1902
|
-
const halfW = size.width / 2;
|
|
1903
|
-
const halfH = size.height / 2;
|
|
1904
|
-
const canvasHalfW = this.width / 2;
|
|
1905
|
-
for (let plane = 0; plane < 2; plane += 1) {
|
|
1906
|
-
const tileBase = tileLuma + plane * tileChroma;
|
|
1907
|
-
const canvasBase = this.lumaBytes + plane * this.chromaBytes;
|
|
1908
|
-
for (let row = 0; row < halfH; row += 1) {
|
|
1909
|
-
const from = tileBase + row * halfW;
|
|
1910
|
-
const to = canvasBase + (origin.y / 2 + row) * canvasHalfW + origin.x / 2;
|
|
1911
|
-
tile.copy(this.buffer, to, from, from + halfW);
|
|
1912
|
-
}
|
|
1913
|
-
}
|
|
1914
|
-
return true;
|
|
1915
|
-
}
|
|
1916
|
-
};
|
|
1917
|
-
function isEven(value) {
|
|
1918
|
-
return Number.isInteger(value) && value % 2 === 0;
|
|
1919
|
-
}
|
|
1920
|
-
//#endregion
|
|
1921
2024
|
//#region src/builtins/camera-grid/grid-tile-feed.ts
|
|
1922
2025
|
/**
|
|
1923
2026
|
* One source's decoded tiles, of which only the LATEST is ever wanted.
|
|
@@ -1969,6 +2072,20 @@ var GridTileFeed = class {
|
|
|
1969
2072
|
hasFresh() {
|
|
1970
2073
|
return this.fresh;
|
|
1971
2074
|
}
|
|
2075
|
+
/**
|
|
2076
|
+
* Forget the half frame, keep the picture.
|
|
2077
|
+
*
|
|
2078
|
+
* Raw video has no framing: the feed cuts on a fixed length, so bytes from a
|
|
2079
|
+
* NEW decoder concatenated onto a partial frame from the old one would be
|
|
2080
|
+
* misaligned for ever — every frame after it sheared, with nothing to
|
|
2081
|
+
* re-sync on. Called whenever a tile's decoder is replaced.
|
|
2082
|
+
*
|
|
2083
|
+
* The newest frame is kept deliberately: the cell holds its last picture
|
|
2084
|
+
* while its decoder comes back, instead of blinking to black.
|
|
2085
|
+
*/
|
|
2086
|
+
resetPartial() {
|
|
2087
|
+
this.partial = Buffer.alloc(0);
|
|
2088
|
+
}
|
|
1972
2089
|
/** For the bound in the spec: one frame plus at most a partial one. */
|
|
1973
2090
|
bufferedBytes() {
|
|
1974
2091
|
return (this.newest?.length ?? 0) + this.partial.length;
|
|
@@ -2052,6 +2169,16 @@ var GridCompositor = class {
|
|
|
2052
2169
|
}
|
|
2053
2170
|
return this.deps.write(this.canvas.buffer);
|
|
2054
2171
|
}
|
|
2172
|
+
/**
|
|
2173
|
+
* A tile's decoder was replaced: drop its half frame, keep its picture.
|
|
2174
|
+
*
|
|
2175
|
+
* Without this the new decoder's bytes would be appended to the old one's
|
|
2176
|
+
* partial frame and every frame after would be sheared — raw video has no
|
|
2177
|
+
* framing to re-sync on.
|
|
2178
|
+
*/
|
|
2179
|
+
resetTile(tileId) {
|
|
2180
|
+
this.placed.get(tileId)?.feed.resetPartial();
|
|
2181
|
+
}
|
|
2055
2182
|
/** After this, a tick emits nothing — a dead child must not keep writing. */
|
|
2056
2183
|
close() {
|
|
2057
2184
|
this.closed = true;
|
|
@@ -2238,13 +2365,22 @@ function gridCanvasEncoderInvocation(input) {
|
|
|
2238
2365
|
* is how a stalled encoder becomes an OOM, and a dropped frame in a live
|
|
2239
2366
|
* composite costs nothing anybody can see.
|
|
2240
2367
|
*
|
|
2241
|
-
* ##
|
|
2368
|
+
* ## A tile comes back; only the ENCODER ends the composition
|
|
2369
|
+
*
|
|
2370
|
+
* Every child exiting used to end it, on the reasoning that a frozen tile is a
|
|
2371
|
+
* nicer picture and a worse fact. In practice it produced neither: a 4K source
|
|
2372
|
+
* ended mid-run (`code=0` — the input simply finished), the composition tore
|
|
2373
|
+
* down, the consumer re-dialled, and the grid served nothing but restarts. That
|
|
2374
|
+
* is what the operator saw as the `auto` rung being stuck.
|
|
2242
2375
|
*
|
|
2243
|
-
*
|
|
2244
|
-
*
|
|
2245
|
-
*
|
|
2246
|
-
*
|
|
2247
|
-
*
|
|
2376
|
+
* A canvas makes the better answer cheap: while a decoder comes back, its cell
|
|
2377
|
+
* holds the last frame it produced and every other cell carries on. The bound
|
|
2378
|
+
* (`TILE_MAX_RESTARTS`) keeps a source that is genuinely gone from respawning
|
|
2379
|
+
* for ever — and when it is reached, the composition does end, with the tile
|
|
2380
|
+
* named.
|
|
2381
|
+
*
|
|
2382
|
+
* The encoder is different: nothing downstream of it exists without it, so its
|
|
2383
|
+
* exit is the composition's exit, first time.
|
|
2248
2384
|
*/
|
|
2249
2385
|
/**
|
|
2250
2386
|
* The encoder must produce its first bytes quickly, because the tick feeds it a
|
|
@@ -2258,6 +2394,15 @@ var ENCODER_FIRST_DATA_MS = 5e3;
|
|
|
2258
2394
|
* already on screen.
|
|
2259
2395
|
*/
|
|
2260
2396
|
var DECODER_FIRST_DATA_MS = 15e3;
|
|
2397
|
+
/**
|
|
2398
|
+
* How many times a tile's decoder may come back before the composition gives up
|
|
2399
|
+
* on it.
|
|
2400
|
+
*
|
|
2401
|
+
* The bound exists so a source that is genuinely gone still ends the
|
|
2402
|
+
* composition rather than respawning for ever; below it, a decoder that dies is
|
|
2403
|
+
* simply replaced while its cell holds the last frame it produced.
|
|
2404
|
+
*/
|
|
2405
|
+
var TILE_MAX_RESTARTS = 5;
|
|
2261
2406
|
async function startGridCompositorChild(ctx, plan, sources, onEnded) {
|
|
2262
2407
|
const tilePlan = buildGridTilePlan({
|
|
2263
2408
|
canvas: {
|
|
@@ -2367,14 +2512,17 @@ async function startGridCompositorChild(ctx, plan, sources, onEnded) {
|
|
|
2367
2512
|
height: tile.size.height
|
|
2368
2513
|
},
|
|
2369
2514
|
firstDataTimeoutMs: DECODER_FIRST_DATA_MS,
|
|
2370
|
-
maxRestarts:
|
|
2515
|
+
maxRestarts: TILE_MAX_RESTARTS,
|
|
2371
2516
|
onChild: (child) => {
|
|
2372
2517
|
child.stdout?.on("data", (chunk) => {
|
|
2373
2518
|
compositor.onTile(tile.id, chunk);
|
|
2374
2519
|
});
|
|
2375
2520
|
child.stderr?.on("data", () => {});
|
|
2376
2521
|
},
|
|
2377
|
-
onExit: (
|
|
2522
|
+
onExit: () => {
|
|
2523
|
+
compositor.resetTile(tile.id);
|
|
2524
|
+
},
|
|
2525
|
+
onGiveUp: (error) => end(`tile ${tile.id}: ${error.message}`)
|
|
2378
2526
|
});
|
|
2379
2527
|
decoders.push(decoder);
|
|
2380
2528
|
await decoder.start();
|
|
@@ -2419,18 +2567,26 @@ var DEFAULTS = {
|
|
|
2419
2567
|
cameraGrids: [],
|
|
2420
2568
|
cameraGridTombstones: []
|
|
2421
2569
|
};
|
|
2570
|
+
/** One composition per (grid, profile) — a grid composes each separately. */
|
|
2571
|
+
function sessionKey(instanceId, profile) {
|
|
2572
|
+
return `${instanceId}::${profile}`;
|
|
2573
|
+
}
|
|
2422
2574
|
/**
|
|
2423
|
-
* One grid's composition
|
|
2575
|
+
* One grid's composition of one profile.
|
|
2424
2576
|
*
|
|
2425
|
-
*
|
|
2426
|
-
*
|
|
2427
|
-
*
|
|
2577
|
+
* The profile is back in the key after a spell with a single output. What made
|
|
2578
|
+
* the per-profile shape painful was never the count: it was that a rebuild
|
|
2579
|
+
* meant a blank screen until the slowest source produced a key frame. An output
|
|
2580
|
+
* clock (D530) shows the picture at once and fills the cells in, so a rung the
|
|
2581
|
+
* adaptive can stand on costs what it is worth.
|
|
2428
2582
|
*/
|
|
2429
2583
|
var GridSessionEntry = class {
|
|
2430
2584
|
instanceId;
|
|
2585
|
+
profile;
|
|
2431
2586
|
session;
|
|
2432
|
-
constructor(instanceId, session) {
|
|
2587
|
+
constructor(instanceId, profile, session) {
|
|
2433
2588
|
this.instanceId = instanceId;
|
|
2589
|
+
this.profile = profile;
|
|
2434
2590
|
this.session = session;
|
|
2435
2591
|
}
|
|
2436
2592
|
isRunning() {
|
|
@@ -2454,7 +2610,7 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2454
2610
|
* (D224), and 60 s is the window for a fact — "does 615 have a low assigned"
|
|
2455
2611
|
* — that only changes when an operator changes it.
|
|
2456
2612
|
*/
|
|
2457
|
-
|
|
2613
|
+
plansByInstanceId = /* @__PURE__ */ new Map();
|
|
2458
2614
|
activity = null;
|
|
2459
2615
|
snapshots = null;
|
|
2460
2616
|
frameSampler = null;
|
|
@@ -2687,9 +2843,9 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2687
2843
|
if (instance.cells.filter((cell) => cell.deviceId === sourceDeviceId).length === 0) continue;
|
|
2688
2844
|
targets.push({
|
|
2689
2845
|
deviceId,
|
|
2690
|
-
canvas: {
|
|
2691
|
-
width:
|
|
2692
|
-
height:
|
|
2846
|
+
canvas: this.canvasFor(instanceId) ?? {
|
|
2847
|
+
width: 0,
|
|
2848
|
+
height: 0
|
|
2693
2849
|
},
|
|
2694
2850
|
cellsForSource: (id) => instance.cells.filter((cell) => cell.deviceId === id).map((cell) => ({
|
|
2695
2851
|
source: cell.source,
|
|
@@ -2732,11 +2888,8 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2732
2888
|
servedBy.set(slot.deviceId, profiles);
|
|
2733
2889
|
}
|
|
2734
2890
|
for (const instance of instances) {
|
|
2735
|
-
const
|
|
2736
|
-
|
|
2737
|
-
width: instance.width,
|
|
2738
|
-
height: instance.height
|
|
2739
|
-
},
|
|
2891
|
+
const plans = gridProfilePlans({
|
|
2892
|
+
publishedProfiles: instance.publishedProfiles,
|
|
2740
2893
|
cells: instance.cells.map((cell) => ({
|
|
2741
2894
|
deviceId: cell.deviceId,
|
|
2742
2895
|
source: cell.source,
|
|
@@ -2744,9 +2897,9 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2744
2897
|
})),
|
|
2745
2898
|
profilesServedBy: (deviceId) => servedBy.get(deviceId) ?? null
|
|
2746
2899
|
});
|
|
2747
|
-
const previous = this.
|
|
2748
|
-
this.
|
|
2749
|
-
this.logSourcePlan(instance,
|
|
2900
|
+
const previous = this.plansByInstanceId.get(instance.id);
|
|
2901
|
+
this.plansByInstanceId.set(instance.id, plans);
|
|
2902
|
+
this.logSourcePlan(instance, plans, previous);
|
|
2750
2903
|
}
|
|
2751
2904
|
}
|
|
2752
2905
|
/**
|
|
@@ -2762,48 +2915,65 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2762
2915
|
* Logged on the EDGE: a grid that cannot be composed would otherwise write a
|
|
2763
2916
|
* line a minute for ever.
|
|
2764
2917
|
*/
|
|
2765
|
-
logSourcePlan(instance,
|
|
2766
|
-
const shape = (value) => JSON.stringify(
|
|
2767
|
-
|
|
2768
|
-
[...value.profileBySource],
|
|
2769
|
-
value.missingSources
|
|
2770
|
-
]);
|
|
2771
|
-
if (previous !== void 0 && shape(previous) === shape(plan)) return;
|
|
2918
|
+
logSourcePlan(instance, plans, previous) {
|
|
2919
|
+
const shape = (value) => JSON.stringify(value);
|
|
2920
|
+
if (previous !== void 0 && shape(previous) === shape(plans)) return;
|
|
2772
2921
|
const deviceId = this.deviceIdByInstanceId.get(instance.id) ?? 0;
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2922
|
+
for (const row of plans) {
|
|
2923
|
+
if (row.offered || row.missingSources.length === 0) continue;
|
|
2924
|
+
this.ctx.logger.info("camera grid: a profile is NOT on offer", {
|
|
2925
|
+
tags: { deviceId },
|
|
2926
|
+
meta: {
|
|
2927
|
+
instanceId: instance.id,
|
|
2928
|
+
profile: row.profile,
|
|
2929
|
+
missingSources: [...row.missingSources]
|
|
2930
|
+
}
|
|
2931
|
+
});
|
|
2932
|
+
}
|
|
2933
|
+
this.ctx.logger.info("camera grid: published profiles", {
|
|
2781
2934
|
tags: { deviceId },
|
|
2782
2935
|
meta: {
|
|
2783
2936
|
instanceId: instance.id,
|
|
2784
|
-
offered:
|
|
2785
|
-
readAt: Object.fromEntries([...plan.profileBySource].map(([id, p]) => [String(id), p]))
|
|
2937
|
+
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)}` : ""]))
|
|
2786
2938
|
}
|
|
2787
2939
|
});
|
|
2788
2940
|
}
|
|
2789
|
-
|
|
2790
|
-
return this.
|
|
2941
|
+
plansFor(instanceId) {
|
|
2942
|
+
return this.plansByInstanceId.get(instanceId) ?? null;
|
|
2943
|
+
}
|
|
2944
|
+
planFor(instanceId, profile) {
|
|
2945
|
+
return this.plansFor(instanceId)?.find((row) => row.profile === profile) ?? null;
|
|
2946
|
+
}
|
|
2947
|
+
/**
|
|
2948
|
+
* The canvas of the BEST profile this grid offers.
|
|
2949
|
+
*
|
|
2950
|
+
* Used where a single geometry is needed for something that is not a
|
|
2951
|
+
* composition — the detection boxes a grid forwards (`grid-activity`), which
|
|
2952
|
+
* are normalized against whatever picture the viewer is looking at. The best
|
|
2953
|
+
* offered one is the honest default; a grid offering nothing has no geometry
|
|
2954
|
+
* and says so.
|
|
2955
|
+
*/
|
|
2956
|
+
canvasFor(instanceId) {
|
|
2957
|
+
for (const row of this.plansFor(instanceId) ?? []) if (row.offered) return row.canvas;
|
|
2958
|
+
return null;
|
|
2791
2959
|
}
|
|
2792
2960
|
ensureSessions(instanceId, deviceId) {
|
|
2793
|
-
const
|
|
2794
|
-
|
|
2795
|
-
const
|
|
2796
|
-
if (
|
|
2797
|
-
this.sessions.
|
|
2798
|
-
stale
|
|
2961
|
+
for (const profile of GRID_PROFILES) {
|
|
2962
|
+
const key = sessionKey(instanceId, profile);
|
|
2963
|
+
const plan = this.planFor(instanceId, profile);
|
|
2964
|
+
if (plan === null || !plan.offered || !plan.published) {
|
|
2965
|
+
const stale = this.sessions.get(key);
|
|
2966
|
+
if (stale) {
|
|
2967
|
+
this.sessions.delete(key);
|
|
2968
|
+
stale.session.dispose();
|
|
2969
|
+
}
|
|
2970
|
+
continue;
|
|
2799
2971
|
}
|
|
2800
|
-
|
|
2972
|
+
if (this.sessions.has(key)) continue;
|
|
2973
|
+
this.sessions.set(key, new GridSessionEntry(instanceId, profile, this.makeSession(instanceId, deviceId, profile)));
|
|
2801
2974
|
}
|
|
2802
|
-
if (this.sessions.has(instanceId)) return;
|
|
2803
|
-
const session = this.makeSession(instanceId, deviceId);
|
|
2804
|
-
this.sessions.set(instanceId, new GridSessionEntry(instanceId, session));
|
|
2805
2975
|
}
|
|
2806
|
-
makeSession(instanceId, deviceId) {
|
|
2976
|
+
makeSession(instanceId, deviceId, profile) {
|
|
2807
2977
|
const childContext = {
|
|
2808
2978
|
binaryPath: this.ffmpegBinaryPath,
|
|
2809
2979
|
logger: this.ctx.logger,
|
|
@@ -2814,8 +2984,6 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2814
2984
|
deps: {
|
|
2815
2985
|
logger: this.ctx.logger,
|
|
2816
2986
|
acquireSource: async (sourceDeviceId) => {
|
|
2817
|
-
const profile = this.planFor(instanceId)?.profileBySource.get(sourceDeviceId);
|
|
2818
|
-
if (profile === void 0) throw new Error(`camera-grid: no stream is assigned for source ${String(sourceDeviceId)}`);
|
|
2819
2987
|
const dial = gridSourceDial(await acquireWithRetry(() => this.ctx.api.streamBroker.getStreamWithCodec.mutate({
|
|
2820
2988
|
deviceId: sourceDeviceId,
|
|
2821
2989
|
video: "copy",
|
|
@@ -2862,10 +3030,12 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2862
3030
|
},
|
|
2863
3031
|
resolvePlan: () => {
|
|
2864
3032
|
const instance = this.instanceById(instanceId);
|
|
2865
|
-
|
|
3033
|
+
const plan = this.planFor(instanceId, profile);
|
|
3034
|
+
if (!instance || plan === null || !plan.offered || !plan.published) return null;
|
|
2866
3035
|
return gridPlanFor(instance, {
|
|
2867
3036
|
encoder: "libx264",
|
|
2868
|
-
decodeHwAccel: null
|
|
3037
|
+
decodeHwAccel: null,
|
|
3038
|
+
canvas: plan.canvas
|
|
2869
3039
|
});
|
|
2870
3040
|
}
|
|
2871
3041
|
});
|
|
@@ -2873,14 +3043,18 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2873
3043
|
}
|
|
2874
3044
|
/** The session a dial is asking for, or `null` - which the relay answers 404 to. */
|
|
2875
3045
|
sessionFor(request) {
|
|
2876
|
-
return this.sessions.get(request.instanceId)?.session ?? null;
|
|
3046
|
+
return this.sessions.get(sessionKey(request.instanceId, request.profile))?.session ?? null;
|
|
2877
3047
|
}
|
|
2878
|
-
/** This grid's
|
|
3048
|
+
/** This grid's sessions, in DESCENDING quality — the snapshot takes the first running one. */
|
|
2879
3049
|
sessionsForDevice(deviceId) {
|
|
2880
3050
|
const instanceId = this.instanceIdForDevice(deviceId);
|
|
2881
3051
|
if (instanceId === null) return [];
|
|
2882
|
-
const
|
|
2883
|
-
|
|
3052
|
+
const entries = [];
|
|
3053
|
+
for (const profile of GRID_PROFILES) {
|
|
3054
|
+
const entry = this.sessions.get(sessionKey(instanceId, profile));
|
|
3055
|
+
if (entry) entries.push(entry);
|
|
3056
|
+
}
|
|
3057
|
+
return entries;
|
|
2884
3058
|
}
|
|
2885
3059
|
/** A grid that is gone stops immediately - it must not keep N sources dialled. */
|
|
2886
3060
|
async retireWithdrawnSessions(instances) {
|
|
@@ -2898,7 +3072,7 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2898
3072
|
if (live.has(instanceId)) continue;
|
|
2899
3073
|
this.activity?.forget(this.deviceIdByInstanceId.get(instanceId) ?? 0);
|
|
2900
3074
|
this.deviceIdByInstanceId.delete(instanceId);
|
|
2901
|
-
this.
|
|
3075
|
+
this.plansByInstanceId.delete(instanceId);
|
|
2902
3076
|
}
|
|
2903
3077
|
}
|
|
2904
3078
|
/**
|
|
@@ -2946,15 +3120,18 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2946
3120
|
descriptorsFor(instanceId) {
|
|
2947
3121
|
const instance = this.instanceById(instanceId);
|
|
2948
3122
|
const relay = this.relay;
|
|
2949
|
-
const
|
|
2950
|
-
if (!instance || !relay ||
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
width:
|
|
2954
|
-
height:
|
|
3123
|
+
const plans = this.plansFor(instanceId);
|
|
3124
|
+
if (!instance || !relay || plans === null) return null;
|
|
3125
|
+
return plans.filter((row) => row.offered && row.published).map((row) => gridStreamDescriptor({
|
|
3126
|
+
profile: row.profile,
|
|
3127
|
+
width: row.offered ? row.canvas.width : 0,
|
|
3128
|
+
height: row.offered ? row.canvas.height : 0,
|
|
2955
3129
|
fps: instance.fps,
|
|
2956
|
-
url: relay.streamUrl({
|
|
2957
|
-
|
|
3130
|
+
url: relay.streamUrl({
|
|
3131
|
+
instanceId,
|
|
3132
|
+
profile: row.profile
|
|
3133
|
+
})
|
|
3134
|
+
}));
|
|
2958
3135
|
}
|
|
2959
3136
|
instanceIdForDevice(deviceId) {
|
|
2960
3137
|
for (const [instanceId, id] of this.deviceIdByInstanceId) if (id === deviceId) return instanceId;
|
|
@@ -2971,23 +3148,19 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2971
3148
|
instanceId: instance.id,
|
|
2972
3149
|
deviceId,
|
|
2973
3150
|
name: instance.name,
|
|
2974
|
-
width: instance.width,
|
|
2975
|
-
height: instance.height,
|
|
2976
3151
|
fps: instance.fps,
|
|
2977
3152
|
cells: instance.cells.map((cell) => ({
|
|
2978
3153
|
deviceId: cell.deviceId,
|
|
2979
3154
|
source: cell.source,
|
|
2980
3155
|
cell: cell.cell
|
|
2981
3156
|
})),
|
|
2982
|
-
profiles: (() => {
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
}];
|
|
2990
|
-
})()
|
|
3157
|
+
profiles: (this.plansByInstanceId.get(instance.id) ?? []).map((row) => ({
|
|
3158
|
+
profile: row.profile,
|
|
3159
|
+
offered: row.offered,
|
|
3160
|
+
published: row.offered && row.published,
|
|
3161
|
+
missingSources: row.offered ? [] : [...row.missingSources],
|
|
3162
|
+
canvas: row.offered ? `${String(row.canvas.width)}x${String(row.canvas.height)}` : ""
|
|
3163
|
+
}))
|
|
2991
3164
|
};
|
|
2992
3165
|
}
|
|
2993
3166
|
async saveGridLayout(input) {
|
|
@@ -2996,9 +3169,8 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2996
3169
|
const updated = {
|
|
2997
3170
|
...instance,
|
|
2998
3171
|
...input.name !== void 0 ? { name: input.name.trim() } : {},
|
|
2999
|
-
...input.width !== void 0 ? { width: input.width } : {},
|
|
3000
|
-
...input.height !== void 0 ? { height: input.height } : {},
|
|
3001
3172
|
...input.fps !== void 0 ? { fps: input.fps } : {},
|
|
3173
|
+
...input.publishedProfiles !== void 0 ? { publishedProfiles: [...input.publishedProfiles] } : {},
|
|
3002
3174
|
cells: input.cells.map((cell) => ({ ...cell }))
|
|
3003
3175
|
};
|
|
3004
3176
|
this.ctx.logger.info("camera grid: layout saved", {
|
|
@@ -3006,7 +3178,7 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
3006
3178
|
meta: {
|
|
3007
3179
|
cells: updated.cells.length,
|
|
3008
3180
|
sources: [...new Set(updated.cells.map((cell) => cell.deviceId))],
|
|
3009
|
-
|
|
3181
|
+
fps: updated.fps
|
|
3010
3182
|
}
|
|
3011
3183
|
});
|
|
3012
3184
|
await this.updateGlobalSettings({ cameraGrids: this.config.cameraGrids.map((row) => row["id"] === instance.id ? {
|
|
@@ -3343,4 +3515,4 @@ async function startGridChild(ctx, plan, sources, onEnded) {
|
|
|
3343
3515
|
};
|
|
3344
3516
|
}
|
|
3345
3517
|
//#endregion
|
|
3346
|
-
export { CameraGridAddon, CameraGridAddon as default, GRID_CAM_STREAM_ID,
|
|
3518
|
+
export { CameraGridAddon, CameraGridAddon as default, GRID_CAM_STREAM_ID, GRID_PROFILES, GRID_SILENCED_CAP_NAMES, GridCameraDevice, GridFrameSampler, GridLastFrameFileStore, GridSnapshotSource, GridStreamRelay, GridStreamSession, buildGridCameraDeclarations, buildGridFilterGraph, deriveGridCanvas, gridCamStreamId, gridCameraReconciliationIndex, gridPlanFor, gridProfilePlans, gridSnapshotAnswerFor, gridSourceDial, gridStreamDescriptor, gridStreamInvocation, installGridCameraRuntime, newGridCameraStableId, normalizeGridRows, parseGridStreamPath, readGridInstances, silenceAnalysisFor, startGridChild };
|