@camstack/system 1.2.254 → 1.2.255

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 (88) 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/autotrack-cameras.d.ts +76 -0
  8. package/dist/builtins/autotrack/autotrack-config.d.ts +89 -0
  9. package/dist/builtins/autotrack/autotrack-decision.d.ts +99 -0
  10. package/dist/builtins/autotrack/autotrack-loop.d.ts +75 -0
  11. package/dist/builtins/autotrack/autotrack.addon.d.ts +71 -0
  12. package/dist/builtins/autotrack/frame-error.d.ts +50 -0
  13. package/dist/builtins/autotrack/frame-measurements.d.ts +97 -0
  14. package/dist/builtins/autotrack/index.d.ts +2 -0
  15. package/dist/builtins/autotrack/index.js +981 -0
  16. package/dist/builtins/autotrack/index.mjs +975 -0
  17. package/dist/builtins/autotrack/ptz-mirror.d.ts +25 -0
  18. package/dist/builtins/autotrack/target-selection.d.ts +60 -0
  19. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  20. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  21. package/dist/builtins/camera-grid/addon.d.ts +118 -0
  22. package/dist/builtins/camera-grid/flv-join.d.ts +66 -0
  23. package/dist/builtins/camera-grid/grid-camera-declarations.d.ts +59 -0
  24. package/dist/builtins/camera-grid/grid-camera-device.d.ts +32 -0
  25. package/dist/builtins/camera-grid/grid-child.d.ts +9 -0
  26. package/dist/builtins/camera-grid/grid-device-settings.d.ts +11 -0
  27. package/dist/builtins/camera-grid/grid-filter-graph.d.ts +53 -0
  28. package/dist/builtins/camera-grid/grid-frame-sample.d.ts +27 -0
  29. package/dist/builtins/camera-grid/grid-instances.d.ts +49 -0
  30. package/dist/builtins/camera-grid/grid-last-frame-store.d.ts +19 -0
  31. package/dist/builtins/camera-grid/grid-layout-native-provider.d.ts +8 -0
  32. package/dist/builtins/camera-grid/grid-plan.d.ts +22 -0
  33. package/dist/builtins/camera-grid/grid-profiles.d.ts +47 -0
  34. package/dist/builtins/camera-grid/grid-row-normalization.d.ts +10 -0
  35. package/dist/builtins/camera-grid/grid-sentry-sources.d.ts +52 -0
  36. package/dist/builtins/camera-grid/grid-snapshot.d.ts +62 -0
  37. package/dist/builtins/camera-grid/grid-stream-descriptor.d.ts +24 -0
  38. package/dist/builtins/camera-grid/grid-stream-invocation.d.ts +29 -0
  39. package/dist/builtins/camera-grid/grid-stream-relay.d.ts +36 -0
  40. package/dist/builtins/camera-grid/grid-stream-session.d.ts +113 -0
  41. package/dist/builtins/camera-grid/grid-wire-format.d.ts +14 -0
  42. package/dist/builtins/camera-grid/index.d.ts +44 -0
  43. package/dist/builtins/camera-grid/index.js +2309 -0
  44. package/dist/builtins/camera-grid/index.mjs +2276 -0
  45. package/dist/builtins/camera-grid/silence-analysis.d.ts +13 -0
  46. package/dist/builtins/console-logging/index.js +1 -1
  47. package/dist/builtins/console-logging/index.mjs +1 -1
  48. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  49. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  50. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  51. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  52. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  53. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  54. package/dist/builtins/hub-forwarder/index.js +1 -1
  55. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  56. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  57. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  58. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  59. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  60. package/dist/builtins/local-network/local-network.addon.js +1 -1
  61. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  62. package/dist/builtins/loki-logging/index.js +1 -1
  63. package/dist/builtins/loki-logging/index.mjs +1 -1
  64. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  65. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  66. package/dist/builtins/platform-probe/index.js +1 -1
  67. package/dist/builtins/platform-probe/index.mjs +1 -1
  68. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  69. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  70. package/dist/builtins/snapshot/index.js +1 -1
  71. package/dist/builtins/snapshot/index.mjs +1 -1
  72. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  73. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  74. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  75. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  76. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  77. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  78. package/dist/builtins/system-config/system-config.addon.js +1 -1
  79. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  80. package/dist/builtins/winston-logging/index.js +1 -1
  81. package/dist/builtins/winston-logging/index.mjs +1 -1
  82. package/dist/{dist-CnQsUe15.js → dist-BJj6Akye.js} +1319 -0
  83. package/dist/{dist-BhTy3CUQ.mjs → dist-Ch93tyHB.mjs} +1260 -1
  84. package/dist/index.js +1 -1
  85. package/dist/index.mjs +1 -1
  86. package/dist/{retired-settings-keys-DDU-VFTU.js → retired-settings-keys-BRCn6-mt.js} +1 -1
  87. package/dist/{retired-settings-keys-CMhNYMD9.mjs → retired-settings-keys-uCaSKuwd.mjs} +1 -1
  88. package/package.json +32 -1
@@ -0,0 +1,2276 @@
1
+ import { Cn as hydrateSchema, Qt as streamCatalogCapability, U as cameraGridLayoutCapability, Y as declarationOwnerNodeId, a as BaseDevice, an as buildFfmpegArgs, fn as DeviceType, in as errMsg, m as DeclaredDevices, n as AUDIO_ANALYSIS_CAP_NAME, on as BaseAddon, p as DETECTION_PIPELINE_CAP_NAME, qt as snapshotCapability, un as DeviceFeature } from "../../dist-Ch93tyHB.mjs";
2
+ import { z } from "zod";
3
+ import { FfmpegProcess } from "@camstack/types/node";
4
+ import { join } from "node:path";
5
+ import { promises } from "node:fs";
6
+ import { spawn } from "node:child_process";
7
+ import { createServer } from "node:http";
8
+ //#region src/builtins/camera-grid/grid-camera-declarations.ts
9
+ /**
10
+ * Turning persisted grid rows into the camera declarations `DeclaredDevices`
11
+ * reconciles.
12
+ *
13
+ * Modelled on `terminal-camera-declarations.ts`, and for the same reasons: the
14
+ * declaration set is the ONLY thing standing between a transient RPC failure
15
+ * and a sweep that deletes every camera this addon owns.
16
+ */
17
+ /** The prefix every stable id this addon has ever minted starts with. */
18
+ var GRID_CAMERA_STABLE_ID_PREFIX = "camera-grid-";
19
+ /**
20
+ * The stable id is `camera-grid-<instance uuid>` and NOTHING else.
21
+ *
22
+ * A stable id is permanent the instant it is minted: bindings, grid layouts in
23
+ * other addons, recording rows, the timeline and this addon's own tombstones
24
+ * all reference it, and a device whose stable id changes is a NEW camera with
25
+ * an empty history. So it may only be derived from something that itself never
26
+ * changes.
27
+ *
28
+ * The instance uuid is the only such thing here. Deriving it from the NAME
29
+ * would rename-break every reference; deriving it from the CELLS would mint a
30
+ * new camera every time the operator drags a rectangle — which is the entire
31
+ * activity this addon exists for. Terminal learned the same lesson from the
32
+ * other end: its original one-camera-per-node id is permanent, and its profile
33
+ * migration had to ENRICH that row rather than mint a replacement.
34
+ */
35
+ function newGridCameraStableId(instanceId) {
36
+ return `${GRID_CAMERA_STABLE_ID_PREFIX}${instanceId}`;
37
+ }
38
+ /**
39
+ * Explicit persisted instances only. A grid camera exists because an operator
40
+ * created a grid, never because some source camera happens to exist.
41
+ *
42
+ * A grid with NO cells is declared like any other: creation is name-only and
43
+ * the layout is authored afterwards in the camera's own device details, so
44
+ * "no cells yet" is the normal first state, not an invalid row.
45
+ */
46
+ function buildGridCameraDeclarations(instances) {
47
+ return instances.filter((instance) => instance.enabled).map((instance) => ({
48
+ stableId: instance.cameraStableId,
49
+ name: instance.name,
50
+ config: { instanceId: instance.id }
51
+ }));
52
+ }
53
+ /**
54
+ * The rows `DeclaredDevices` is allowed to consider — every live declaration
55
+ * plus ONE bounded batch of orphans.
56
+ *
57
+ * Two guards, both copied from Terminal because both were paid for there:
58
+ *
59
+ * 1. **An unknown integration id yields an EMPTY index.** `null`/`undefined`
60
+ * means "the integration lookup has not answered", which is not the same
61
+ * fact as "this addon owns no rows" — and only the second one may drive a
62
+ * withdrawal.
63
+ * 2. **The orphan batch is bounded and deterministically ordered.** The generic
64
+ * sweep refuses an over-limit set outright; selecting a batch here drains a
65
+ * large historical orphan set across passes without weakening that guard.
66
+ */
67
+ function gridCameraReconciliationIndex(rows, integrationId, declarations, managedStableIds = /* @__PURE__ */ new Set()) {
68
+ if (!integrationId) return [];
69
+ const declared = new Set(declarations.map((camera) => camera.stableId));
70
+ const owned = rows.filter((row) => row.integrationId === integrationId && (declared.has(row.stableId) || managedStableIds.has(row.stableId) || row.stableId.startsWith(GRID_CAMERA_STABLE_ID_PREFIX)));
71
+ return [...owned.filter((row) => declared.has(row.stableId)), ...owned.filter((row) => !declared.has(row.stableId)).sort((left, right) => left.id - right.id).slice(0, 32)];
72
+ }
73
+ //#endregion
74
+ //#region src/builtins/camera-grid/grid-device-settings.ts
75
+ /** The widget id, registered in `ui-library`'s `HOST_WIDGETS`. */
76
+ var GRID_LAYOUT_WIDGET_ID = "host/camera-grid-layout";
77
+ /**
78
+ * The layout section, or `null` when this device is not a grid.
79
+ *
80
+ * `isGrid` is the ANSWER to "is this a grid", never "do we know yet". A caller
81
+ * that cannot tell must not call this — an unresolved read rendered as `null`
82
+ * would hide the editor from a real grid (D315).
83
+ */
84
+ function buildGridDeviceSchema(isGrid) {
85
+ if (!isGrid) return null;
86
+ return { sections: [{
87
+ id: "camera-grid-layout",
88
+ tab: "general",
89
+ title: "Grid layout",
90
+ order: 20,
91
+ fields: [{
92
+ type: "widget",
93
+ key: "_gridLayout",
94
+ label: "",
95
+ widgetId: GRID_LAYOUT_WIDGET_ID
96
+ }]
97
+ }] };
98
+ }
99
+ //#endregion
100
+ //#region src/builtins/camera-grid/grid-layout-native-provider.ts
101
+ /**
102
+ * The `camera-grid-layout` provider, contribution included.
103
+ *
104
+ * ## Why the section lives on the DEVICE provider and not on the addon
105
+ *
106
+ * `camera-grid-layout` is registered per device, through
107
+ * `DeviceContext.registerNativeCap`. `child-cap-dispatch` resolves a call that
108
+ * carries a `deviceId` to the device-scoped provider FIRST, and when the method
109
+ * is absent there it throws rather than falling back to the singleton — the
110
+ * fallback exists only for a collection cap's list method. Every contribution
111
+ * method carries a `deviceId` by definition, so an addon-level wrapper is
112
+ * simply never reached: it registered, it type-checked, and the live hub
113
+ * answered HTTP 500 with
114
+ * `method 'getDeviceSettingsContribution' not found on provider
115
+ * 'camera-grid-layout'`.
116
+ *
117
+ * That is why the whole provider is built here, in one place, and handed to
118
+ * `registerNativeCap` as a single object. `exposesDeviceSettings` widens
119
+ * `InferProvider` and not `InferNativeProvider`, so the type below is the wider
120
+ * one; registering it is safe because a native registration only ever needs a
121
+ * SUBSET of what this carries.
122
+ *
123
+ * The refusal (D526) rides along: a device the addon cannot name as a grid gets
124
+ * `null`, so a camera that is not a grid gets no section at all rather than a
125
+ * panel that renders its own emptiness.
126
+ */
127
+ function buildGridLayoutNativeProvider(source) {
128
+ return {
129
+ getLayout: async ({ deviceId }) => source.getLayout(deviceId),
130
+ saveLayout: async (patch) => source.saveLayout(patch),
131
+ getDeviceSettingsContribution: async ({ deviceId }) => {
132
+ const schema = buildGridDeviceSchema(source.getLayout(deviceId) !== null);
133
+ return schema === null ? null : hydrateSchema(schema, {});
134
+ },
135
+ getDeviceLiveContribution: async () => null,
136
+ applyDeviceSettingsPatch: async () => ({ success: true })
137
+ };
138
+ }
139
+ //#endregion
140
+ //#region src/builtins/camera-grid/grid-camera-device.ts
141
+ /**
142
+ * The composite camera device.
143
+ *
144
+ * An ORDINARY camera in every respect the rest of the system can see: it
145
+ * records, it appears in the timeline, it has profile slots, it is bound like
146
+ * any other. What makes it a grid is invisible from here — its streams are
147
+ * composed rather than dialled, and object detection is switched off at
148
+ * CREATION (see `silence-analysis.ts`), never re-asserted.
149
+ *
150
+ * ## The three native caps, and why each is native
151
+ *
152
+ * - **`stream-catalog`** — what this grid OFFERS, one descriptor per profile it
153
+ * can honestly compose. Named on every pull, idle included: the broker sweeps
154
+ * anything its registry holds that a catalog does not name.
155
+ * - **`camera-grid-layout`** — the geometry, on the camera's own page. Device
156
+ * scoped so the widget asks THE CAMERA; the addon-scoped custom actions it
157
+ * replaces could only be reached by a caller that already knew the addon id,
158
+ * which is why the editor rendered nowhere.
159
+ * - **`snapshot`** — a frame of the composition. It used to be deliberately
160
+ * ABSENT, on the reasoning that the broker's ordinary grab takes one from the
161
+ * profile slot and taking one IS a consumer. That is true and it is the
162
+ * defect: it means a thumbnail in a list starts N decodes, and on a battery
163
+ * source it would be a wake. A native provider runs BEFORE that grab and
164
+ * answers from the live composition or from the frame it kept
165
+ * (`grid-snapshot.ts`).
166
+ */
167
+ var gridCameraSchema = z.object({
168
+ /** The persisted grid row this camera was declared from. */
169
+ instanceId: z.string().min(1) });
170
+ var runtime = null;
171
+ function installGridCameraRuntime(next) {
172
+ runtime = next;
173
+ }
174
+ function activeRuntime() {
175
+ if (runtime === null) throw new Error("camera-grid: the addon runtime is not installed");
176
+ return runtime;
177
+ }
178
+ var GridCameraDevice = class extends BaseDevice {
179
+ features = [DeviceFeature.NativeSnapshot];
180
+ constructor(ctx) {
181
+ super(ctx, gridCameraSchema, { type: ctx.deviceMeta.type });
182
+ this.ctx.registerNativeCap(streamCatalogCapability, { getCatalog: async ({ deviceId }) => {
183
+ if (deviceId !== this.id) return [];
184
+ return this.catalog();
185
+ } });
186
+ const layoutProvider = buildGridLayoutNativeProvider({
187
+ getLayout: (deviceId) => activeRuntime().getLayout(deviceId),
188
+ saveLayout: (patch) => activeRuntime().saveLayout(patch)
189
+ });
190
+ this.ctx.registerNativeCap(cameraGridLayoutCapability, layoutProvider);
191
+ this.ctx.registerNativeCap(snapshotCapability, {
192
+ getSnapshot: async ({ deviceId }) => activeRuntime().getSnapshot(deviceId),
193
+ invalidateCache: async () => {}
194
+ });
195
+ this.markOnline(true);
196
+ }
197
+ catalog() {
198
+ const descriptors = activeRuntime().descriptorsFor(this.config.get("instanceId"));
199
+ if (descriptors === null) throw new Error(`camera-grid: cannot describe the streams of instance ${this.config.get("instanceId")}`);
200
+ return descriptors;
201
+ }
202
+ setAddonOnline(online) {
203
+ this.markOnline(online);
204
+ }
205
+ };
206
+ //#endregion
207
+ //#region src/builtins/camera-grid/grid-instances.ts
208
+ /**
209
+ * The operator-written grid rows.
210
+ *
211
+ * Shape follows `readTerminalInstances`: a grid is a persisted row that
212
+ * DECLARES a camera, and the declaration reconcile turns rows into devices.
213
+ *
214
+ * Config is operator-writable, so a malformed or duplicate row is IGNORED
215
+ * rather than allowed to make the reconcile destructive. One bad row must never
216
+ * delete every camera this addon declares — that failure mode is why the
217
+ * Terminal addon reads its rows the same way.
218
+ */
219
+ /** A rectangle in normalized [0,1] coordinates of whatever contains it. */
220
+ var NormalizedRectSchema = z.object({
221
+ x: z.number().min(0).max(1),
222
+ y: z.number().min(0).max(1),
223
+ width: z.number().gt(0).max(1),
224
+ height: z.number().gt(0).max(1)
225
+ });
226
+ /**
227
+ * One source camera, the part of its picture taken, and where that part lands
228
+ * on the composite's canvas. Both rectangles are NORMALIZED so a source that
229
+ * changes resolution does not silently start cutting the wrong region.
230
+ */
231
+ var GridCellSchema = z.object({
232
+ deviceId: z.number().int().positive(),
233
+ source: NormalizedRectSchema,
234
+ cell: NormalizedRectSchema
235
+ });
236
+ var GridInstanceSchema = z.object({
237
+ id: z.string().uuid(),
238
+ cameraStableId: z.string().min(1).max(256),
239
+ name: z.string().min(1).max(160),
240
+ enabled: z.boolean(),
241
+ /** Canvas size — the composite camera's own resolution. */
242
+ width: z.number().int().min(160).max(7680),
243
+ height: z.number().int().min(120).max(4320),
244
+ fps: z.number().int().min(1).max(60),
245
+ /**
246
+ * Empty is the NORMAL freshly-created state: a grid is created from the
247
+ * integration page with a name only, and laid out afterwards in the camera's
248
+ * own settings. A grid with no cells declares its camera and emits nothing.
249
+ */
250
+ cells: z.array(GridCellSchema).max(16).default([])
251
+ });
252
+ function readGridInstances(raw, onInvalid) {
253
+ const ids = /* @__PURE__ */ new Set();
254
+ const stableIds = /* @__PURE__ */ new Set();
255
+ const instances = [];
256
+ for (const value of raw) {
257
+ const parsed = GridInstanceSchema.safeParse(value);
258
+ if (!parsed.success) {
259
+ onInvalid?.("Ignoring malformed camera-grid configuration");
260
+ continue;
261
+ }
262
+ const instance = parsed.data;
263
+ if (ids.has(instance.id) || stableIds.has(instance.cameraStableId)) {
264
+ onInvalid?.(`Ignoring duplicate camera-grid instance ${instance.id}`);
265
+ continue;
266
+ }
267
+ ids.add(instance.id);
268
+ stableIds.add(instance.cameraStableId);
269
+ instances.push(instance);
270
+ }
271
+ return instances;
272
+ }
273
+ //#endregion
274
+ //#region src/builtins/camera-grid/grid-plan.ts
275
+ /**
276
+ * Bits per pixel per frame. A composite is mostly static furniture with a few
277
+ * moving subjects, so it compresses well; this lands a 1920x1080@10 grid near
278
+ * 3 Mbps and a 640x360@10 one near 0.35 Mbps. A single flat number would either
279
+ * starve a 4K canvas or spend a 4K budget on a thumbnail-sized one.
280
+ */
281
+ var BITS_PER_PIXEL_PER_FRAME = .15;
282
+ var MIN_BITRATE_KBPS = 256;
283
+ var MAX_BITRATE_KBPS = 16e3;
284
+ function bitrateFor(width, height, fps) {
285
+ const bps = width * height * fps * BITS_PER_PIXEL_PER_FRAME;
286
+ return Math.min(MAX_BITRATE_KBPS, Math.max(MIN_BITRATE_KBPS, Math.round(bps / 1e3)));
287
+ }
288
+ /** `null` when the grid has nothing to compose — the normal freshly-created state. */
289
+ function gridPlanFor(instance, encode) {
290
+ if (instance.cells.length === 0) return null;
291
+ const ordinalByDeviceId = /* @__PURE__ */ new Map();
292
+ const sourceDeviceIds = [];
293
+ const cells = instance.cells.map((cell) => {
294
+ let ordinal = ordinalByDeviceId.get(cell.deviceId);
295
+ if (ordinal === void 0) {
296
+ ordinal = sourceDeviceIds.length;
297
+ ordinalByDeviceId.set(cell.deviceId, ordinal);
298
+ sourceDeviceIds.push(cell.deviceId);
299
+ }
300
+ return {
301
+ inputIndex: ordinal,
302
+ source: cell.source,
303
+ cell: cell.cell
304
+ };
305
+ });
306
+ const canvas = encode.canvas ?? {
307
+ width: instance.width,
308
+ height: instance.height
309
+ };
310
+ return {
311
+ layout: {
312
+ width: canvas.width,
313
+ height: canvas.height,
314
+ cells
315
+ },
316
+ sourceDeviceIds,
317
+ encoder: encode.encoder,
318
+ decodeHwAccel: encode.decodeHwAccel,
319
+ fps: instance.fps,
320
+ bitrateKbps: bitrateFor(canvas.width, canvas.height, instance.fps)
321
+ };
322
+ }
323
+ //#endregion
324
+ //#region src/builtins/camera-grid/grid-row-normalization.ts
325
+ /**
326
+ * Creation is NAME ONLY.
327
+ *
328
+ * The operator adds a row on the integration page and types a name. Everything
329
+ * else — the identity the rest of the cluster will reference forever, the
330
+ * canvas, the frame rate, the cells — is filled in here on the way past, and
331
+ * then edited in the camera's own device details.
332
+ *
333
+ * This runs BEFORE `readGridInstances`, which deliberately IGNORES a row it
334
+ * cannot parse so one bad row can never make the declaration reconcile
335
+ * destructive. A freshly typed name is exactly such a row, so without this step
336
+ * a new grid would be silently discarded at the moment of creation.
337
+ */
338
+ /** Canvas and cadence a grid starts life with, until the layout editor changes them. */
339
+ var DEFAULT_WIDTH = 1920;
340
+ var DEFAULT_HEIGHT = 1080;
341
+ var DEFAULT_FPS = 10;
342
+ function nonEmptyString(value) {
343
+ if (typeof value !== "string") return null;
344
+ const trimmed = value.trim();
345
+ return trimmed.length > 0 ? trimmed : null;
346
+ }
347
+ function positiveInt(value, fallback) {
348
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
349
+ }
350
+ /**
351
+ * @param mintId a UUID factory — injected so the normalization is testable
352
+ * without reaching for `crypto`.
353
+ */
354
+ function normalizeGridRows(raw, mintId) {
355
+ let changed = false;
356
+ const rows = [];
357
+ for (const row of raw) {
358
+ const name = nonEmptyString(row["name"]);
359
+ if (name === null) {
360
+ changed = true;
361
+ continue;
362
+ }
363
+ const id = nonEmptyString(row["id"]) ?? mintId();
364
+ const cameraStableId = nonEmptyString(row["cameraStableId"]) ?? newGridCameraStableId(id);
365
+ const normalized = {
366
+ ...row,
367
+ id,
368
+ cameraStableId,
369
+ name,
370
+ enabled: typeof row["enabled"] === "boolean" ? row["enabled"] : true,
371
+ width: positiveInt(row["width"], DEFAULT_WIDTH),
372
+ height: positiveInt(row["height"], DEFAULT_HEIGHT),
373
+ fps: positiveInt(row["fps"], DEFAULT_FPS),
374
+ cells: Array.isArray(row["cells"]) ? row["cells"] : []
375
+ };
376
+ if (!changed) changed = Object.keys(normalized).some((key) => normalized[key] !== row[key]);
377
+ rows.push(normalized);
378
+ }
379
+ return {
380
+ rows,
381
+ changed
382
+ };
383
+ }
384
+ //#endregion
385
+ //#region src/builtins/camera-grid/grid-wire-format.ts
386
+ /** What the relay answers with, and what the sampler must demux. */
387
+ var GRID_WIRE_CONTENT_TYPE = "video/x-flv";
388
+ /** `/grid/<instanceId>/<profile>.flv`. */
389
+ var GRID_WIRE_PATH_SUFFIX = ".flv";
390
+ /**
391
+ * How the broker must ingest it: `pull-flv` is documented as ALWAYS read
392
+ * through the ffmpeg source reader with `-c copy`. `pull-http` would have been
393
+ * the intuitive choice and means MJPEG in this broker — it answers with
394
+ * `HTTP_MJPEG_H264_OUTPUT_ARGS` and re-encodes an already-encoded H.264.
395
+ */
396
+ var GRID_WIRE_STREAM_KIND = "pull-flv";
397
+ //#endregion
398
+ //#region src/builtins/camera-grid/grid-frame-sample.ts
399
+ /**
400
+ * ONE JPEG out of a composition that is already running.
401
+ *
402
+ * ## Why this is not the ffmpeg primitive
403
+ *
404
+ * `FfmpegProcess` + `buildFfmpegArgs` own the STREAM job: a long-lived encode
405
+ * with a hardware cascade, a first-data deadline and a bounded restart. This is
406
+ * the other kind — run-to-completion, no encode plan worth sharing, finishes or
407
+ * is killed — which is the class `scripts/check-ffmpeg-primitive.ts` already
408
+ * carves out by name for `recorder/still/still-frame-service.ts` ("single-JPEG
409
+ * still grab") and `builtins/snapshot/snapshot.addon.ts` ("keyframe snapshot
410
+ * grab"). It is the same job as those two, on a pipe instead of a URL.
411
+ *
412
+ * ## Why a pipe and not the grid's own URL
413
+ *
414
+ * Dialling the relay would be a CONSUMER, and a consumer of a cold grid starts
415
+ * the composition — N broker acquisitions for a thumbnail, which is the one
416
+ * thing the operator ruled out. Reading the bytes of a session that is ALREADY
417
+ * running costs nothing: the child is up, the packets are being produced, and
418
+ * this just takes a copy of them.
419
+ *
420
+ * Bounded on both ends. A composition whose first key frame is far away must
421
+ * not hold an ffmpeg (or this promise) open for ever, so the grab has a
422
+ * deadline and the child is killed at it — a snapshot that did not arrive is
423
+ * `null`, and `null` is a real answer.
424
+ */
425
+ /** Long enough for a 2 s GOP plus a probe, short enough that a list does not hang. */
426
+ var DEFAULT_TIMEOUT_MS = 8e3;
427
+ /** JPEG quality. 4 is visually clean at thumbnail and list sizes and ~40 KB at 1080p. */
428
+ var JPEG_QUALITY = "4";
429
+ var GridFrameSampler = class {
430
+ options;
431
+ constructor(options) {
432
+ this.options = options;
433
+ }
434
+ /**
435
+ * @returns the JPEG, or `null` — a grab that produced nothing, could not be
436
+ * attached, or ran out of time. Every one of those is logged with the
437
+ * device: a branch that accepts the request and produces nothing must say so.
438
+ */
439
+ async sample(deviceId, source) {
440
+ const spawnFn = this.options.spawnFn ?? spawn;
441
+ const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
442
+ const child = spawnFn(this.options.binaryPath, [
443
+ "-hide_banner",
444
+ "-loglevel",
445
+ "error",
446
+ "-f",
447
+ "flv",
448
+ "-i",
449
+ "pipe:0",
450
+ "-frames:v",
451
+ "1",
452
+ "-q:v",
453
+ JPEG_QUALITY,
454
+ "-f",
455
+ "mjpeg",
456
+ "pipe:1"
457
+ ], { stdio: [
458
+ "pipe",
459
+ "pipe",
460
+ "pipe"
461
+ ] });
462
+ const chunks = [];
463
+ child.stdout?.on("data", (chunk) => chunks.push(chunk));
464
+ child.stderr?.on("data", () => {});
465
+ const hold = source.attach((chunk) => {
466
+ if (child.stdin?.writable === true) child.stdin.write(chunk);
467
+ });
468
+ if (hold === null) {
469
+ child.kill("SIGKILL");
470
+ this.options.logger.warn("camera grid: could not attach to sample a frame", { tags: { deviceId } });
471
+ return null;
472
+ }
473
+ const timer = setTimeout(() => {
474
+ child.kill("SIGKILL");
475
+ }, timeoutMs);
476
+ timer.unref?.();
477
+ try {
478
+ await new Promise((resolve) => {
479
+ child.once("exit", () => resolve());
480
+ child.once("error", () => resolve());
481
+ });
482
+ } finally {
483
+ clearTimeout(timer);
484
+ hold.close();
485
+ try {
486
+ child.stdin?.end();
487
+ } catch (error) {
488
+ this.options.logger.debug("camera grid: the frame grab stdin refused to close", {
489
+ tags: { deviceId },
490
+ meta: { error: errMsg(error) }
491
+ });
492
+ }
493
+ }
494
+ const jpeg = Buffer.concat(chunks);
495
+ if (jpeg.length === 0) {
496
+ this.options.logger.warn("camera grid: the frame grab produced no bytes", {
497
+ tags: { deviceId },
498
+ meta: { timeoutMs }
499
+ });
500
+ return null;
501
+ }
502
+ return jpeg;
503
+ }
504
+ };
505
+ //#endregion
506
+ //#region src/builtins/camera-grid/grid-last-frame-store.ts
507
+ /**
508
+ * The last COMPOSED frame per grid, on disk, so it outlives the process.
509
+ *
510
+ * ## Why not `@camstack/system`'s SnapshotDurableStore
511
+ *
512
+ * Because the shape is right and the reach is not: that class lives under
513
+ * `builtins/snapshot/` and `@camstack/system` does not export it — a builtin's
514
+ * internals are not an addon-facing module, and an addon that reached into one
515
+ * would be importing another addon, which this repo forbids outright. What is
516
+ * shared is the REASONING, and it is worth restating rather than linking,
517
+ * because the failure it prevents here is the same one measured on 2026-09-17:
518
+ * the cache is RAM, three server updates emptied it, and a camera that cannot
519
+ * be asked for a fresh frame showed the courtesy card all morning with nothing
520
+ * broken.
521
+ *
522
+ * A grid is that camera by construction. It has no fresh frame whenever nobody
523
+ * is watching it, which is most of the time and is the entire point of D519.
524
+ *
525
+ * ## Shape
526
+ *
527
+ * One file per device under `ctx.dataDir` — `<nodeDataDir>/addons-data/
528
+ * camera-grid`, which this addon owns exclusively — overwritten rather than
529
+ * appended: what is wanted is A recent frame, not a history. A history here
530
+ * would grow without a reaper and compete with the recordings for the disk.
531
+ *
532
+ * Writes are THROTTLED. A composition emits 10 frames a second and a process
533
+ * restarts a few times a week; persisting every frame buys nothing and costs a
534
+ * write per frame.
535
+ *
536
+ * NOTHING here throws. The caller already has its picture; persistence is a
537
+ * bonus for the NEXT boot, and a disk that refuses must never cost a snapshot
538
+ * that succeeded. A frame that cannot be read is reported ABSENT — absent is
539
+ * the truth, and the courtesy card is the correct answer to it.
540
+ */
541
+ /** One recent frame is the value; a newer one is not worth the disk. */
542
+ var DEFAULT_MIN_INTERVAL_MS = 6e4;
543
+ /** Its own subdirectory, so the addon's other state is never mistaken for a frame. */
544
+ var SUBDIR = "last-frame";
545
+ var GridLastFrameFileStore = class {
546
+ dir;
547
+ minIntervalMs;
548
+ now;
549
+ /** Last write per device, so the throttle survives without touching disk. */
550
+ lastWriteMs = /* @__PURE__ */ new Map();
551
+ constructor(options) {
552
+ this.dir = join(options.dataDir, SUBDIR);
553
+ this.minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
554
+ this.now = options.now ?? (() => Date.now());
555
+ }
556
+ async put(deviceId, jpeg) {
557
+ if (jpeg.length === 0) return;
558
+ const at = this.now();
559
+ const previous = this.lastWriteMs.get(deviceId);
560
+ if (previous !== void 0 && at - previous < this.minIntervalMs) return;
561
+ try {
562
+ await promises.mkdir(this.dir, { recursive: true });
563
+ const target = this.pathFor(deviceId);
564
+ const staging = `${target}.writing`;
565
+ await promises.writeFile(staging, jpeg);
566
+ await promises.rename(staging, target);
567
+ this.lastWriteMs.set(deviceId, at);
568
+ } catch {}
569
+ }
570
+ async get(deviceId) {
571
+ try {
572
+ const jpeg = await promises.readFile(this.pathFor(deviceId));
573
+ return jpeg.length > 0 ? jpeg : null;
574
+ } catch {
575
+ return null;
576
+ }
577
+ }
578
+ pathFor(deviceId) {
579
+ return join(this.dir, `${String(deviceId)}.jpg`);
580
+ }
581
+ };
582
+ //#endregion
583
+ //#region src/builtins/camera-grid/grid-profiles.ts
584
+ /**
585
+ * One id, for every grid, forever — for the HIGH composition.
586
+ *
587
+ * D519's reason still holds and is what keeps this id bare: a per-instance or
588
+ * newly-spelled id mints a second cam-stream and orphans whatever profile
589
+ * assignment the operator had made. `grid` was `high` on the live hub before
590
+ * this change, so `high` keeps it and the other two are new.
591
+ */
592
+ var GRID_CAM_STREAM_ID = "grid";
593
+ /** Every profile a grid could conceivably compose, in descending quality. */
594
+ var GRID_PROFILES = [
595
+ "high",
596
+ "mid",
597
+ "low"
598
+ ];
599
+ /**
600
+ * Canvas scale per profile. Named constants and not a computation: the numbers
601
+ * are a policy (what a `mid` grid is FOR), and a policy that lives in an
602
+ * expression is a policy nobody can find.
603
+ */
604
+ var CANVAS_SCALE = {
605
+ high: 1,
606
+ mid: .5,
607
+ low: .25
608
+ };
609
+ /**
610
+ * The smallest canvas a composition is allowed to shrink to. A grid of four
611
+ * cells at 40x30 is not a cheaper picture, it is no picture.
612
+ */
613
+ var MIN_CANVAS_WIDTH = 160;
614
+ var MIN_CANVAS_HEIGHT = 90;
615
+ /** The cam-stream id a grid publishes for one profile. */
616
+ function gridCamStreamId(profile) {
617
+ return profile === "high" ? GRID_CAM_STREAM_ID : `${GRID_CAM_STREAM_ID}-${profile}`;
618
+ }
619
+ /** Even, floored, scaled by the profile. libx264 refuses an odd dimension. */
620
+ function gridCanvasFor(canvas, profile) {
621
+ const scale = CANVAS_SCALE[profile];
622
+ return {
623
+ width: evenAtLeast(canvas.width * scale, MIN_CANVAS_WIDTH),
624
+ height: evenAtLeast(canvas.height * scale, MIN_CANVAS_HEIGHT)
625
+ };
626
+ }
627
+ function evenAtLeast(value, floor) {
628
+ const rounded = Math.max(floor, Math.round(value));
629
+ return rounded % 2 === 0 ? rounded : rounded + 1;
630
+ }
631
+ /**
632
+ * What this grid can honestly publish, per profile, with the refusal named.
633
+ *
634
+ * Every profile gets a row whether it is offered or not: an absent row says
635
+ * nothing, and the caller logging a refusal needs the reason more than the
636
+ * caller publishing an offer needs the offer.
637
+ */
638
+ function offeredGridProfiles(input) {
639
+ const served = /* @__PURE__ */ new Map();
640
+ for (const deviceId of input.sourceDeviceIds) served.set(deviceId, input.profilesServedBy(deviceId));
641
+ return GRID_PROFILES.map((profile) => {
642
+ if (input.sourceDeviceIds.length === 0) return {
643
+ profile,
644
+ offered: false,
645
+ missingSources: []
646
+ };
647
+ const missingSources = input.sourceDeviceIds.filter((deviceId) => {
648
+ const profiles = served.get(deviceId);
649
+ return profiles === null || profiles === void 0 || !profiles.includes(profile);
650
+ });
651
+ return missingSources.length === 0 ? {
652
+ profile,
653
+ offered: true
654
+ } : {
655
+ profile,
656
+ offered: false,
657
+ missingSources
658
+ };
659
+ });
660
+ }
661
+ //#endregion
662
+ //#region src/builtins/camera-grid/grid-sentry-sources.ts
663
+ function gridSourceDial(acquired) {
664
+ const sentryUrl = acquired.sentryUrl;
665
+ if (typeof sentryUrl === "string" && sentryUrl.length > 0) return {
666
+ url: sentryUrl,
667
+ pipelineKey: acquired.pipelineKey,
668
+ mode: "sentry"
669
+ };
670
+ return {
671
+ url: acquired.url,
672
+ pipelineKey: acquired.pipelineKey,
673
+ mode: "live"
674
+ };
675
+ }
676
+ //#endregion
677
+ //#region src/builtins/camera-grid/grid-snapshot.ts
678
+ /**
679
+ * Which of the three answers applies. Pure, so the rule can be read — and
680
+ * sabotaged — without a socket, a file or an ffmpeg.
681
+ */
682
+ function gridSnapshotAnswerFor(input) {
683
+ if (input.running) return { kind: "live" };
684
+ if (input.hasStoredFrame) return { kind: "stored" };
685
+ return {
686
+ kind: "none",
687
+ reason: "never-composed"
688
+ };
689
+ }
690
+ var GridSnapshotSource = class {
691
+ deps;
692
+ constructor(deps) {
693
+ this.deps = deps;
694
+ }
695
+ async getSnapshot(deviceId) {
696
+ const sessions = this.deps.sessionsFor(deviceId);
697
+ if (sessions.length === 0) return null;
698
+ const live = sessions.find((session) => session.isRunning()) ?? null;
699
+ const stored = await this.readStored(deviceId);
700
+ const answer = gridSnapshotAnswerFor({
701
+ running: live !== null,
702
+ hasStoredFrame: stored !== null
703
+ });
704
+ if (answer.kind === "live" && live !== null) {
705
+ const sampled = await this.sample(deviceId, live);
706
+ if (sampled !== null) {
707
+ await this.keep(deviceId, sampled);
708
+ return toImage(sampled);
709
+ }
710
+ if (stored !== null) return toImage(stored);
711
+ this.deps.logger.warn("camera grid: the composition is running but produced no frame, and none was kept", {
712
+ tags: { deviceId },
713
+ meta: { profile: live.profile }
714
+ });
715
+ return null;
716
+ }
717
+ if (answer.kind === "stored" && stored !== null) return toImage(stored);
718
+ this.deps.logger.warn("camera grid: no frame for this grid — nothing is composing it and none was ever kept", {
719
+ tags: { deviceId },
720
+ meta: {
721
+ reason: "never-composed",
722
+ profiles: sessions.length
723
+ }
724
+ });
725
+ return null;
726
+ }
727
+ async sample(deviceId, session) {
728
+ try {
729
+ const jpeg = await this.deps.sampleFrame(session);
730
+ if (jpeg !== null && jpeg.length > 0) return jpeg;
731
+ this.deps.logger.warn("camera grid: could not sample a frame from the live composition", {
732
+ tags: { deviceId },
733
+ meta: { profile: session.profile }
734
+ });
735
+ return null;
736
+ } catch (error) {
737
+ this.deps.logger.warn("camera grid: could not sample a frame from the live composition", {
738
+ tags: { deviceId },
739
+ meta: {
740
+ profile: session.profile,
741
+ error: errMsg(error)
742
+ }
743
+ });
744
+ return null;
745
+ }
746
+ }
747
+ async readStored(deviceId) {
748
+ try {
749
+ const jpeg = await this.deps.store.get(deviceId);
750
+ return jpeg !== null && jpeg.length > 0 ? jpeg : null;
751
+ } catch (error) {
752
+ this.deps.logger.warn("camera grid: the kept frame could not be read", {
753
+ tags: { deviceId },
754
+ meta: { error: errMsg(error) }
755
+ });
756
+ return null;
757
+ }
758
+ }
759
+ async keep(deviceId, jpeg) {
760
+ try {
761
+ await this.deps.store.put(deviceId, jpeg);
762
+ } catch (error) {
763
+ this.deps.logger.warn("camera grid: the composed frame could not be kept", {
764
+ tags: { deviceId },
765
+ meta: { error: errMsg(error) }
766
+ });
767
+ }
768
+ }
769
+ };
770
+ function toImage(jpeg) {
771
+ return {
772
+ base64: jpeg.toString("base64"),
773
+ contentType: "image/jpeg"
774
+ };
775
+ }
776
+ //#endregion
777
+ //#region src/builtins/camera-grid/grid-stream-relay.ts
778
+ /**
779
+ * The loopback HTTP endpoint the broker dials to consume a grid.
780
+ *
781
+ * A GET is the demand signal and the transport in one: the connection opening
782
+ * is the first consumer arriving, and the connection closing is it leaving.
783
+ * There is no other way to start or stop a composition, which is what makes
784
+ * "off when nobody watches" true by construction rather than by a policy
785
+ * somebody has to remember to apply.
786
+ *
787
+ * Bound on 127.0.0.1 only. The broker is hub-only and so is this addon
788
+ * ({@link ../package.json} `placement: hub-only`), so the endpoint never needs
789
+ * to be reachable from another node — and an MPEG-TS of every camera in the
790
+ * grid is not something to expose on a LAN interface by accident.
791
+ */
792
+ var PATH_PREFIX = "/grid/";
793
+ var PATH_SUFFIX = GRID_WIRE_PATH_SUFFIX;
794
+ /** How long a reader may refuse to drain before it is cut. */
795
+ var SLOW_READER_MS = 5e3;
796
+ function asProfile(raw) {
797
+ return GRID_PROFILES.find((profile) => profile === raw) ?? null;
798
+ }
799
+ /** `/grid/<instanceId>/<profile>.flv`, or `null` when it is anything else. */
800
+ function parseGridStreamPath(url) {
801
+ const path = (url ?? "").split("?")[0] ?? "";
802
+ if (!path.startsWith(PATH_PREFIX) || !path.endsWith(PATH_SUFFIX)) return null;
803
+ const raw = path.slice(6, path.length - PATH_SUFFIX.length);
804
+ const at = raw.lastIndexOf("/");
805
+ if (at <= 0) return null;
806
+ const profile = asProfile(raw.slice(at + 1));
807
+ if (profile === null) return null;
808
+ try {
809
+ const instanceId = decodeURIComponent(raw.slice(0, at));
810
+ return instanceId.length > 0 ? {
811
+ instanceId,
812
+ profile
813
+ } : null;
814
+ } catch {
815
+ return null;
816
+ }
817
+ }
818
+ var GridStreamRelay = class {
819
+ deps;
820
+ server = null;
821
+ baseUrl = "";
822
+ open = /* @__PURE__ */ new Set();
823
+ constructor(deps) {
824
+ this.deps = deps;
825
+ }
826
+ async start() {
827
+ if (this.server) return;
828
+ const server = createServer((req, res) => {
829
+ const request = parseGridStreamPath(req.url);
830
+ if (req.method !== "GET" || request === null) {
831
+ res.writeHead(404).end();
832
+ return;
833
+ }
834
+ this.serve(request, res);
835
+ });
836
+ await new Promise((resolve, reject) => {
837
+ server.once("error", reject);
838
+ server.listen(0, "127.0.0.1", resolve);
839
+ });
840
+ const address = server.address();
841
+ if (typeof address !== "object" || address === null) {
842
+ server.close();
843
+ throw new Error("camera-grid: the stream relay did not bind a TCP port");
844
+ }
845
+ this.server = server;
846
+ this.baseUrl = `http://127.0.0.1:${String(address.port)}`;
847
+ }
848
+ /** The URL a grid's catalog descriptor advertises. Empty before {@link start}. */
849
+ streamUrl(request) {
850
+ if (!this.baseUrl) throw new Error("camera-grid: the stream relay is not started");
851
+ const instance = encodeURIComponent(request.instanceId);
852
+ return `${this.baseUrl}${PATH_PREFIX}${instance}/${request.profile}${PATH_SUFFIX}`;
853
+ }
854
+ async serve(request, res) {
855
+ const { instanceId, profile } = request;
856
+ const session = this.deps.sessionFor(request);
857
+ if (session === null) {
858
+ this.deps.logger.warn("camera grid: a stream was requested for an unknown grid", { meta: {
859
+ instanceId,
860
+ profile
861
+ } });
862
+ res.writeHead(404).end();
863
+ return;
864
+ }
865
+ this.open.add(res);
866
+ let hold = null;
867
+ let stallTimer = null;
868
+ const sendHead = () => {
869
+ if (res.headersSent) return;
870
+ res.writeHead(200, {
871
+ "content-type": GRID_WIRE_CONTENT_TYPE,
872
+ "cache-control": "no-store",
873
+ connection: "keep-alive"
874
+ });
875
+ res.flushHeaders();
876
+ };
877
+ const clearStall = () => {
878
+ if (stallTimer === null) return;
879
+ clearTimeout(stallTimer);
880
+ stallTimer = null;
881
+ };
882
+ res.on("drain", clearStall);
883
+ try {
884
+ hold = await session.open({
885
+ onData: (chunk) => {
886
+ if (res.writableEnded || res.destroyed) return;
887
+ sendHead();
888
+ if (res.write(chunk) || stallTimer !== null) return;
889
+ stallTimer = setTimeout(() => {
890
+ stallTimer = null;
891
+ this.deps.logger.warn("camera grid: cutting a reader that never drained — it was holding the composition", {
892
+ tags: { deviceId: session.deviceId },
893
+ meta: {
894
+ instanceId,
895
+ profile,
896
+ stalledForMs: SLOW_READER_MS
897
+ }
898
+ });
899
+ res.destroy();
900
+ }, SLOW_READER_MS);
901
+ stallTimer.unref?.();
902
+ },
903
+ onClose: (reason) => {
904
+ this.deps.logger.info("camera grid: ending a reader", {
905
+ tags: { deviceId: session.deviceId },
906
+ meta: {
907
+ instanceId,
908
+ profile,
909
+ reason
910
+ }
911
+ });
912
+ clearStall();
913
+ res.end();
914
+ }
915
+ });
916
+ } catch (error) {
917
+ this.deps.logger.warn("camera grid: could not serve a grid stream", {
918
+ tags: { deviceId: session.deviceId },
919
+ meta: {
920
+ instanceId,
921
+ profile,
922
+ error: errMsg(error)
923
+ }
924
+ });
925
+ if (!res.headersSent) res.writeHead(503, { "content-type": "text/plain" });
926
+ res.end("camera grid unavailable");
927
+ this.open.delete(res);
928
+ return;
929
+ }
930
+ sendHead();
931
+ res.on("close", () => {
932
+ this.open.delete(res);
933
+ clearStall();
934
+ hold?.close();
935
+ });
936
+ }
937
+ async dispose() {
938
+ for (const res of this.open) res.destroy();
939
+ this.open.clear();
940
+ const server = this.server;
941
+ this.server = null;
942
+ this.baseUrl = "";
943
+ if (server) await new Promise((resolve) => server.close(() => resolve()));
944
+ }
945
+ };
946
+ //#endregion
947
+ //#region src/builtins/camera-grid/grid-stream-descriptor.ts
948
+ /**
949
+ * `profileHint` is load-bearing, not decoration.
950
+ *
951
+ * A device publishing a SINGLE stream with no hint is ranked by pixel count,
952
+ * and a single stream ranks into `mid` — the slot `recordings` does not select
953
+ * on its own (`high|mid` picks high), that `recordingsLow` ignores, and that
954
+ * scrub never reads. The Dreame robot's one 720p relay landed exactly there and
955
+ * its footage was written nowhere anything looked.
956
+ *
957
+ * With several compositions published the hint does a second job: it is the
958
+ * only thing that says which slot each one is FOR. Ranked by pixels a grid's
959
+ * `mid` would take the `high` slot on any grid whose `high` was not offered.
960
+ */
961
+ function gridStreamDescriptor(input) {
962
+ return {
963
+ camStreamId: gridCamStreamId(input.profile),
964
+ kind: GRID_WIRE_STREAM_KIND,
965
+ url: input.url,
966
+ codec: "h264",
967
+ resolution: {
968
+ width: input.width,
969
+ height: input.height
970
+ },
971
+ fps: input.fps,
972
+ label: `Grid composition (${input.profile})`,
973
+ profileHint: input.profile,
974
+ autoEligible: true
975
+ };
976
+ }
977
+ //#endregion
978
+ //#region src/builtins/camera-grid/flv-join.ts
979
+ /**
980
+ * Making one FLV stream joinable by many readers.
981
+ *
982
+ * MPEG-TS is self-describing: a reader attaches mid-stream, waits for the next
983
+ * PAT/PMT and decodes. FLV is not. Its 9-byte signature, its `onMetaData`
984
+ * script tag and its AVC sequence header appear ONCE, at the top, and a reader
985
+ * that arrives later is handed the middle of a tag — ffmpeg answers `Invalid
986
+ * data found when processing input`, which is exactly what the hub did the
987
+ * minute the grid started emitting FLV (2026-09-17). The grid emits FLV because
988
+ * the bundled ffmpeg segfaults remuxing MPEG-TS (D519 notes, same day), so the
989
+ * format is not negotiable and this is the price of it.
990
+ *
991
+ * The join point therefore does two things, and nothing else:
992
+ *
993
+ * - **remembers what describes the stream** — the header, the newest script
994
+ * tag, the newest AVC sequence header. Newest, not first: an encoder that
995
+ * respawns emits a new sequence header, and a reader handed the old one
996
+ * decodes noise;
997
+ * - **starts each reader on a tag boundary at a KEYFRAME**, never mid-tag and
998
+ * never on an inter frame that references pictures the reader never saw.
999
+ *
1000
+ * It is a parser, not a buffer: nothing is retained beyond the preamble and the
1001
+ * partial tag currently being assembled. A composite at 10 fps with a 2-second
1002
+ * GOP makes a reader wait at most two seconds to join, which is the same wait
1003
+ * MPEG-TS would have imposed for its next key frame.
1004
+ */
1005
+ /** FLV tag header: type(1) + dataSize(3) + timestamp(3) + extended(1) + streamId(3). */
1006
+ var TAG_HEADER_BYTES = 11;
1007
+ /** Every tag is followed by its own size as a 4-byte trailer. */
1008
+ var TAG_TRAILER_BYTES = 4;
1009
+ /** `FLV` + version(1) + flags(1) + dataOffset(4), then PreviousTagSize0(4). */
1010
+ var FLV_HEADER_BYTES = 13;
1011
+ var TAG_TYPE_SCRIPT = 18;
1012
+ var TAG_TYPE_VIDEO = 9;
1013
+ /** High nibble of a video tag's first byte: 1 = key frame. */
1014
+ var VIDEO_FRAME_TYPE_KEY = 1;
1015
+ /** Second byte of an AVC video tag: 0 = sequence header, 1 = NALU. */
1016
+ var AVC_PACKET_TYPE_SEQUENCE_HEADER = 0;
1017
+ /**
1018
+ * One reader's view of the stream.
1019
+ *
1020
+ * Every consumer holds one, the first included: a join point with a privileged
1021
+ * "primary" reader would have two code paths for the same question, and the
1022
+ * grid's first consumer is not special — it just happens to arrive when the
1023
+ * preamble is still being learnt.
1024
+ */
1025
+ var FlvReader = class {
1026
+ state;
1027
+ detach;
1028
+ constructor(state, detach) {
1029
+ this.state = state;
1030
+ this.detach = detach;
1031
+ }
1032
+ /** True until the preamble and a key frame have been handed over. */
1033
+ get pending() {
1034
+ return !this.state.started;
1035
+ }
1036
+ /** The bytes owed to this reader since the last call, or `null`. */
1037
+ take() {
1038
+ const queued = this.state.queued;
1039
+ if (queued.length === 0) return null;
1040
+ this.state.queued = [];
1041
+ return queued.length === 1 ? queued[0] ?? null : Buffer.concat(queued);
1042
+ }
1043
+ /** Stop receiving. Anything queued is dropped with the reader. */
1044
+ close() {
1045
+ this.state.queued = [];
1046
+ this.detach(this.state);
1047
+ }
1048
+ };
1049
+ function parseVideoFlags(body) {
1050
+ const first = body[0];
1051
+ const second = body[1];
1052
+ if (first === void 0) return {
1053
+ isKeyFrame: false,
1054
+ isSequenceHeader: false
1055
+ };
1056
+ const frameType = first >> 4;
1057
+ const sequenceHeader = second === AVC_PACKET_TYPE_SEQUENCE_HEADER;
1058
+ return {
1059
+ isKeyFrame: frameType === VIDEO_FRAME_TYPE_KEY && !sequenceHeader,
1060
+ isSequenceHeader: sequenceHeader
1061
+ };
1062
+ }
1063
+ var FlvJoinPoint = class {
1064
+ buffer = Buffer.alloc(0);
1065
+ header = null;
1066
+ script = null;
1067
+ sequenceHeader = null;
1068
+ readers = /* @__PURE__ */ new Set();
1069
+ /** Attach a reader. It receives the preamble at the next key frame. */
1070
+ join() {
1071
+ const state = {
1072
+ started: false,
1073
+ queued: []
1074
+ };
1075
+ this.readers.add(state);
1076
+ return new FlvReader(state, (gone) => this.readers.delete(gone));
1077
+ }
1078
+ /** Feed bytes from the encoder; each reader then takes what it is owed. */
1079
+ accept(chunk) {
1080
+ this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
1081
+ this.consumeHeader();
1082
+ for (const tag of this.consumeTags()) this.dispatch(tag);
1083
+ }
1084
+ consumeHeader() {
1085
+ if (this.header !== null) return;
1086
+ if (this.buffer.length < FLV_HEADER_BYTES) return;
1087
+ this.header = this.buffer.subarray(0, FLV_HEADER_BYTES);
1088
+ this.buffer = this.buffer.subarray(FLV_HEADER_BYTES);
1089
+ }
1090
+ *consumeTags() {
1091
+ if (this.header === null) return;
1092
+ for (;;) {
1093
+ if (this.buffer.length < TAG_HEADER_BYTES) return;
1094
+ const dataSize = this.buffer.readUIntBE(1, 3);
1095
+ const total = TAG_HEADER_BYTES + dataSize + TAG_TRAILER_BYTES;
1096
+ if (this.buffer.length < total) return;
1097
+ const bytes = this.buffer.subarray(0, total);
1098
+ const type = bytes.readUInt8(0);
1099
+ const body = bytes.subarray(TAG_HEADER_BYTES, TAG_HEADER_BYTES + dataSize);
1100
+ const flags = type === TAG_TYPE_VIDEO ? parseVideoFlags(body) : {
1101
+ isKeyFrame: false,
1102
+ isSequenceHeader: false
1103
+ };
1104
+ this.buffer = this.buffer.subarray(total);
1105
+ yield {
1106
+ bytes,
1107
+ type,
1108
+ ...flags
1109
+ };
1110
+ }
1111
+ }
1112
+ dispatch(tag) {
1113
+ if (tag.type === TAG_TYPE_SCRIPT) this.script = tag.bytes;
1114
+ if (tag.isSequenceHeader) this.sequenceHeader = tag.bytes;
1115
+ for (const reader of this.readers) {
1116
+ if (reader.started) {
1117
+ reader.queued.push(tag.bytes);
1118
+ continue;
1119
+ }
1120
+ if (!tag.isKeyFrame) continue;
1121
+ const preamble = this.preamble();
1122
+ if (preamble === null) continue;
1123
+ reader.started = true;
1124
+ reader.queued.push(preamble, tag.bytes);
1125
+ }
1126
+ }
1127
+ /** The bytes that describe the stream, or `null` while any is still unknown. */
1128
+ preamble() {
1129
+ if (this.header === null || this.sequenceHeader === null) return null;
1130
+ const parts = [this.header];
1131
+ if (this.script !== null) parts.push(this.script);
1132
+ parts.push(this.sequenceHeader);
1133
+ return Buffer.concat(parts);
1134
+ }
1135
+ };
1136
+ //#endregion
1137
+ //#region src/builtins/camera-grid/grid-stream-session.ts
1138
+ var DEFAULT_LINGER_MS = 3e3;
1139
+ var GridStreamSession = class {
1140
+ options;
1141
+ consumers = /* @__PURE__ */ new Map();
1142
+ /**
1143
+ * The FLV stream's describing bytes, and where each reader may start.
1144
+ *
1145
+ * Rebuilt with every cold start: a new child means a new header and a new
1146
+ * sequence header, and handing a reader the previous child's would decode
1147
+ * noise. See `flv-join.ts` for why FLV needs this and MPEG-TS did not.
1148
+ */
1149
+ join = new FlvJoinPoint();
1150
+ running = null;
1151
+ starting = null;
1152
+ lingerTimer = null;
1153
+ teardown = null;
1154
+ disposed = false;
1155
+ constructor(options) {
1156
+ this.options = options;
1157
+ }
1158
+ get logTags() {
1159
+ return { deviceId: this.options.deviceId };
1160
+ }
1161
+ /** The COMPOSITE camera's device id — what every log line about this grid is tagged with. */
1162
+ get deviceId() {
1163
+ return this.options.deviceId;
1164
+ }
1165
+ isRunning() {
1166
+ return this.running !== null;
1167
+ }
1168
+ consumerCount() {
1169
+ return this.consumers.size;
1170
+ }
1171
+ /** Resolves once any in-flight start or teardown has finished. For tests and shutdown. */
1172
+ async settled() {
1173
+ await this.starting?.catch(() => void 0);
1174
+ await this.teardown;
1175
+ }
1176
+ /**
1177
+ * Attach a consumer, starting the composition if this is the first one.
1178
+ *
1179
+ * @throws when the grid has nothing to compose, or a source could not be
1180
+ * acquired. Both are LOGGED before they are thrown: a branch that accepts a
1181
+ * consumer and then produces no media is exactly the branch that reads as
1182
+ * "nothing ever happened".
1183
+ */
1184
+ async open(consumer) {
1185
+ if (this.disposed) throw new Error("camera-grid: this stream session is disposed");
1186
+ this.cancelLinger();
1187
+ this.consumers.set(consumer, this.join.join());
1188
+ try {
1189
+ await this.ensureRunning();
1190
+ } catch (error) {
1191
+ this.dropConsumer(consumer);
1192
+ this.scheduleTeardownIfIdle();
1193
+ throw error;
1194
+ }
1195
+ let closed = false;
1196
+ return { close: () => {
1197
+ if (closed) return;
1198
+ closed = true;
1199
+ this.dropConsumer(consumer);
1200
+ this.options.deps.logger.debug("camera grid: consumer left", {
1201
+ tags: this.logTags,
1202
+ meta: { remaining: this.consumers.size }
1203
+ });
1204
+ this.scheduleTeardownIfIdle();
1205
+ } };
1206
+ }
1207
+ /**
1208
+ * Attach to a composition that is ALREADY running, or refuse.
1209
+ *
1210
+ * The whole difference from {@link open} is the refusal, and the refusal is
1211
+ * the feature. A snapshot of a grid is a frame of the composition — which
1212
+ * only exists while something is watching it — and the operator's rule is
1213
+ * that producing a thumbnail must never be the reason N cameras get dialled.
1214
+ * `open()` would start the composition, acquire every source, and on a
1215
+ * battery camera that is a wake nobody asked for.
1216
+ *
1217
+ * `null` is a real answer: "there is no composition to take a frame from",
1218
+ * which the caller turns into the stored last frame, or into nothing.
1219
+ */
1220
+ openIfRunning(consumer) {
1221
+ if (this.disposed || this.running === null) return null;
1222
+ this.cancelLinger();
1223
+ this.consumers.set(consumer, this.join.join());
1224
+ let closed = false;
1225
+ return { close: () => {
1226
+ if (closed) return;
1227
+ closed = true;
1228
+ this.dropConsumer(consumer);
1229
+ this.scheduleTeardownIfIdle();
1230
+ } };
1231
+ }
1232
+ /** Forget a consumer AND the reader holding bytes on its behalf. */
1233
+ dropConsumer(consumer) {
1234
+ this.consumers.get(consumer)?.close();
1235
+ this.consumers.delete(consumer);
1236
+ }
1237
+ /**
1238
+ * The child ended on its own. Every consumer is told WHY — a stream that
1239
+ * simply stops is indistinguishable from a stalled one at the far end.
1240
+ */
1241
+ onChildEnded(reason) {
1242
+ if (this.running === null) return;
1243
+ this.options.deps.logger.warn("camera grid: the composition ended", {
1244
+ tags: this.logTags,
1245
+ meta: {
1246
+ reason,
1247
+ consumers: this.consumers.size
1248
+ }
1249
+ });
1250
+ for (const [consumer, reader] of [...this.consumers]) {
1251
+ reader.close();
1252
+ consumer.onClose(reason);
1253
+ }
1254
+ this.consumers.clear();
1255
+ this.teardown = this.stopNow();
1256
+ }
1257
+ async dispose() {
1258
+ this.disposed = true;
1259
+ this.cancelLinger();
1260
+ for (const [consumer, reader] of [...this.consumers]) {
1261
+ reader.close();
1262
+ consumer.onClose("shutdown");
1263
+ }
1264
+ this.consumers.clear();
1265
+ await this.settled();
1266
+ await this.stopNow();
1267
+ }
1268
+ async ensureRunning() {
1269
+ if (this.running) return this.running;
1270
+ if (this.starting) return this.starting;
1271
+ const starting = this.startCold();
1272
+ this.starting = starting;
1273
+ try {
1274
+ const state = await starting;
1275
+ this.running = state;
1276
+ return state;
1277
+ } finally {
1278
+ if (this.starting === starting) this.starting = null;
1279
+ }
1280
+ }
1281
+ async startCold() {
1282
+ const plan = this.options.resolvePlan();
1283
+ if (plan === null || plan.layout.cells.length === 0 || plan.sourceDeviceIds.length === 0) {
1284
+ this.options.deps.logger.warn("camera grid: a consumer attached but the grid has no cells — nothing to compose", { tags: this.logTags });
1285
+ throw new Error("camera-grid: this grid has no cells to compose");
1286
+ }
1287
+ const acquired = [];
1288
+ try {
1289
+ for (const sourceDeviceId of plan.sourceDeviceIds) acquired.push(await this.options.deps.acquireSource(sourceDeviceId));
1290
+ } catch (error) {
1291
+ this.options.deps.logger.error("camera grid: could not acquire every source — releasing the ones already taken", {
1292
+ tags: this.logTags,
1293
+ meta: {
1294
+ acquired: acquired.length,
1295
+ error: errMsg(error)
1296
+ }
1297
+ });
1298
+ await this.releaseAll(acquired);
1299
+ throw error;
1300
+ }
1301
+ try {
1302
+ this.join = new FlvJoinPoint();
1303
+ for (const consumer of this.consumers.keys()) this.consumers.set(consumer, this.join.join());
1304
+ const child = await this.options.deps.startChild(plan, acquired);
1305
+ child.stdout.on("data", (chunk) => {
1306
+ this.join.accept(chunk);
1307
+ for (const [consumer, reader] of this.consumers) {
1308
+ const owed = reader.take();
1309
+ if (owed !== null) consumer.onData(owed);
1310
+ }
1311
+ });
1312
+ this.options.deps.logger.info("camera grid: composition started", {
1313
+ tags: this.logTags,
1314
+ meta: {
1315
+ cells: plan.layout.cells.length,
1316
+ sources: [...plan.sourceDeviceIds],
1317
+ canvas: `${String(plan.layout.width)}x${String(plan.layout.height)}`,
1318
+ fps: plan.fps
1319
+ }
1320
+ });
1321
+ return {
1322
+ child,
1323
+ sources: acquired
1324
+ };
1325
+ } catch (error) {
1326
+ this.options.deps.logger.error("camera grid: the composition failed to start", {
1327
+ tags: this.logTags,
1328
+ meta: { error: errMsg(error) }
1329
+ });
1330
+ await this.releaseAll(acquired);
1331
+ throw error;
1332
+ }
1333
+ }
1334
+ scheduleTeardownIfIdle() {
1335
+ if (this.consumers.size > 0) return;
1336
+ if (this.running === null && this.starting === null) return;
1337
+ const lingerMs = this.options.deps.lingerMs ?? DEFAULT_LINGER_MS;
1338
+ if (lingerMs <= 0) {
1339
+ this.teardown = this.stopNow();
1340
+ return;
1341
+ }
1342
+ this.cancelLinger();
1343
+ this.lingerTimer = setTimeout(() => {
1344
+ this.lingerTimer = null;
1345
+ if (this.consumers.size > 0) return;
1346
+ this.teardown = this.stopNow();
1347
+ }, lingerMs);
1348
+ this.lingerTimer.unref?.();
1349
+ }
1350
+ cancelLinger() {
1351
+ if (this.lingerTimer === null) return;
1352
+ clearTimeout(this.lingerTimer);
1353
+ this.lingerTimer = null;
1354
+ }
1355
+ async stopNow() {
1356
+ const state = this.running;
1357
+ this.running = null;
1358
+ if (state === null) return;
1359
+ try {
1360
+ await state.child.stop();
1361
+ } catch (error) {
1362
+ this.options.deps.logger.warn("camera grid: the composition did not stop cleanly", {
1363
+ tags: this.logTags,
1364
+ meta: { error: errMsg(error) }
1365
+ });
1366
+ }
1367
+ await this.releaseAll(state.sources);
1368
+ this.options.deps.logger.info("camera grid: composition stopped — no consumers left", {
1369
+ tags: this.logTags,
1370
+ meta: { released: state.sources.length }
1371
+ });
1372
+ }
1373
+ async releaseAll(sources) {
1374
+ for (const source of sources) try {
1375
+ await this.options.deps.releaseSource(source.pipelineKey);
1376
+ } catch (error) {
1377
+ this.options.deps.logger.error("camera grid: could NOT release a source handle", {
1378
+ tags: this.logTags,
1379
+ meta: {
1380
+ pipelineKey: source.pipelineKey,
1381
+ error: errMsg(error)
1382
+ }
1383
+ });
1384
+ }
1385
+ }
1386
+ };
1387
+ //#endregion
1388
+ //#region src/builtins/camera-grid/silence-analysis.ts
1389
+ /**
1390
+ * Every analyzer a composite camera is created with switched OFF.
1391
+ *
1392
+ * A grid is a picture we composed from cameras that are ALREADY analyzed. Running
1393
+ * detection on the composite would pay for the same subjects a second time, and
1394
+ * would attribute them to a camera that does not exist as a viewpoint — a person
1395
+ * detected in the bottom-right cell is a person on THAT camera, not on the grid.
1396
+ *
1397
+ * `motion-detection` sits first deliberately: it is the one that holds the decode
1398
+ * session open, so its absence is the difference between a grid costing a
1399
+ * composition and costing a full decode pipeline. Same ordering, and the same
1400
+ * reason, as the Terminal addon's list.
1401
+ *
1402
+ * Called ONLY on creation. An operator who deliberately turns detection back on
1403
+ * for a grid must win, and a reconcile that re-asserted every pass would silently
1404
+ * overrule them once a minute.
1405
+ */
1406
+ var GRID_SILENCED_CAP_NAMES = [
1407
+ "motion-detection",
1408
+ DETECTION_PIPELINE_CAP_NAME,
1409
+ AUDIO_ANALYSIS_CAP_NAME
1410
+ ];
1411
+ /**
1412
+ * Resolve the addon currently providing `capName` for this device, from the
1413
+ * device's OWN bindings. `listBindableCapsForDeviceType` answers for the device
1414
+ * TYPE and would happily name a wrapper that is not the one bound here.
1415
+ *
1416
+ * `null` when the cap is not bound at all — not an error: a deployment with no
1417
+ * audio analyzer has nothing to switch off, and demanding one would make every
1418
+ * grid creation fail on a perfectly valid hub.
1419
+ */
1420
+ async function resolveBoundWrapper(api, deviceId, capName) {
1421
+ const bindings = await api.deviceManager.getBindings.query({ deviceId });
1422
+ for (const entry of bindings.entries) {
1423
+ if (entry.capName !== capName) continue;
1424
+ if (entry.kind !== "wrapped") continue;
1425
+ if (entry.providerAddonId === "") return null;
1426
+ return entry.providerAddonId;
1427
+ }
1428
+ return null;
1429
+ }
1430
+ /**
1431
+ * @throws if a cap IS bound and the write to its authority failed. The composite
1432
+ * would then be running a full analyzer over a picture whose subjects are already
1433
+ * counted elsewhere — exactly the cost this removes — and a silent version of
1434
+ * that failure is unfindable.
1435
+ */
1436
+ async function silenceAnalysisFor(deps, deviceId) {
1437
+ const failures = [];
1438
+ for (const capName of GRID_SILENCED_CAP_NAMES) try {
1439
+ const wrapperAddonId = await resolveBoundWrapper(deps.api, deviceId, capName);
1440
+ if (wrapperAddonId === null) {
1441
+ deps.logger.debug("camera grid: no analyzer bound for this capability", {
1442
+ tags: { deviceId },
1443
+ meta: { capName }
1444
+ });
1445
+ continue;
1446
+ }
1447
+ await deps.api.deviceManager.setWrapperActive.mutate({
1448
+ deviceId,
1449
+ capName,
1450
+ wrapperAddonId,
1451
+ active: false
1452
+ });
1453
+ deps.logger.info("camera grid: analyzer switched off at its authority", {
1454
+ tags: { deviceId },
1455
+ meta: {
1456
+ capName,
1457
+ wrapperAddonId
1458
+ }
1459
+ });
1460
+ } catch (err) {
1461
+ failures.push(`${capName}: ${errMsg(err)}`);
1462
+ deps.logger.error("camera grid: could NOT switch an analyzer off", {
1463
+ tags: { deviceId },
1464
+ meta: {
1465
+ capName,
1466
+ error: errMsg(err)
1467
+ }
1468
+ });
1469
+ }
1470
+ 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("; ")})`);
1471
+ }
1472
+ //#endregion
1473
+ //#region src/builtins/camera-grid/grid-filter-graph.ts
1474
+ var CANVAS_LABEL = "canvas";
1475
+ var OUT_LABEL = "grid";
1476
+ /** Full-frame within a tolerance, i.e. nothing to crop. */
1477
+ var FULL_FRAME_EPSILON = 1e-6;
1478
+ function isFullFrame(rect) {
1479
+ return Math.abs(rect.x) < FULL_FRAME_EPSILON && Math.abs(rect.y) < FULL_FRAME_EPSILON && Math.abs(rect.width - 1) < FULL_FRAME_EPSILON && Math.abs(rect.height - 1) < FULL_FRAME_EPSILON;
1480
+ }
1481
+ /**
1482
+ * yuv420p subsamples chroma 2x2, so an odd width or height is rejected by every
1483
+ * encoder we use. Rounding here lets the reason be stated; rounding inside the
1484
+ * child is a start-up failure with no context.
1485
+ */
1486
+ function toEven(value) {
1487
+ const rounded = Math.round(value);
1488
+ const even = rounded % 2 === 0 ? rounded : rounded - 1;
1489
+ return Math.max(2, even);
1490
+ }
1491
+ function assertWithin(rect, what, cellIndex) {
1492
+ if (rect.x < 0 || rect.y < 0 || rect.width <= 0 || rect.height <= 0 || rect.x + rect.width > 1.000001 || rect.y + rect.height > 1.000001) throw new Error(`camera-grid: cell ${cellIndex} falls ${what} (x=${rect.x} y=${rect.y} w=${rect.width} h=${rect.height}) — ffmpeg would render this as a silently clipped picture`);
1493
+ }
1494
+ /**
1495
+ * `crop` resolved against the source's OWN size at runtime (`iw`/`ih`), never
1496
+ * against a resolution guessed here. That is what makes the stored rectangle
1497
+ * survive a source that changes resolution.
1498
+ */
1499
+ function cropExpression(source) {
1500
+ return `crop=iw*${source.width}:ih*${source.height}:iw*${source.x}:ih*${source.y}`;
1501
+ }
1502
+ function buildGridFilterGraph(layout) {
1503
+ if (layout.cells.length === 0) throw new Error("camera-grid: a composite needs at least one cell to emit anything");
1504
+ const steps = [`color=c=black:s=${layout.width}x${layout.height}:d=1[${CANVAS_LABEL}]`];
1505
+ const cellLabels = [];
1506
+ layout.cells.forEach((cell, index) => {
1507
+ assertWithin(cell.source, "outside its source", index);
1508
+ assertWithin(cell.cell, "outside the canvas", index);
1509
+ const targetWidth = toEven(cell.cell.width * layout.width);
1510
+ const targetHeight = toEven(cell.cell.height * layout.height);
1511
+ const label = `c${index}`;
1512
+ const filters = [...isFullFrame(cell.source) ? [] : [cropExpression(cell.source)], `scale=${targetWidth}:${targetHeight}`];
1513
+ steps.push(`[${cell.inputIndex}:v]${filters.join(",")}[${label}]`);
1514
+ cellLabels.push(label);
1515
+ });
1516
+ let base = CANVAS_LABEL;
1517
+ layout.cells.forEach((cell, index) => {
1518
+ const x = Math.round(cell.cell.x * layout.width);
1519
+ const y = Math.round(cell.cell.y * layout.height);
1520
+ const out = index === layout.cells.length - 1 ? OUT_LABEL : `s${index}`;
1521
+ steps.push(`[${base}][${cellLabels[index]}]overlay=${x}:${y}[${out}]`);
1522
+ base = out;
1523
+ });
1524
+ return {
1525
+ graph: steps.join(";"),
1526
+ videoOutLabel: OUT_LABEL
1527
+ };
1528
+ }
1529
+ //#endregion
1530
+ //#region src/builtins/camera-grid/grid-stream-invocation.ts
1531
+ /**
1532
+ * The encoder preset. `veryfast` because a composite is the one encode in the
1533
+ * cluster whose input cost already scales with the number of cameras in it —
1534
+ * the decode side of a 2x2 is four decodes — so the encode side is where a
1535
+ * cheap default matters most.
1536
+ */
1537
+ var PRESET = "veryfast";
1538
+ /**
1539
+ * `-tune zerolatency`, and this one is worth several SECONDS.
1540
+ *
1541
+ * x264's defaults hold frames before emitting any: a rate-control lookahead of
1542
+ * ~40 frames plus B-frames. At a composite's 10 fps that lookahead alone is
1543
+ * about four seconds of picture sitting inside the encoder — the operator saw
1544
+ * it as "ritardi di secondi" and it was not the network, the relay or the
1545
+ * broker. `zerolatency` sets `rc-lookahead=0`, `sync-lookahead=0` and
1546
+ * `bframes=0`, which is exactly the trade a live composite wants: a few percent
1547
+ * more bitrate for the same picture, in exchange for the encoder emitting each
1548
+ * frame as it arrives.
1549
+ *
1550
+ * It is not a preset. `veryfast` says how hard the encoder searches; this says
1551
+ * how long it is allowed to WAIT, and the two are independent.
1552
+ */
1553
+ var TUNE = "zerolatency";
1554
+ /** yuv420p: the only pixel format every consumer of a CamStack profile reads. */
1555
+ var PIXEL_FORMAT = "yuv420p";
1556
+ /**
1557
+ * Key-frame cadence, in seconds.
1558
+ *
1559
+ * Two things ride on this and they pull the same way. A recorder cuts its
1560
+ * segments on a key frame, so a long GOP produces long segments and a scrub
1561
+ * that lands far from where the operator clicked. And every consumer — the
1562
+ * broker's reader included — starts at a key frame, so the GOP is the WORST
1563
+ * CASE wait before a grid appears at all: at two seconds a viewer could stare
1564
+ * at nothing for two seconds after pressing play.
1565
+ *
1566
+ * One second halves that wait. It costs bitrate (an IDR is expensive and there
1567
+ * are now twice as many), which is why it is not lower: below a second the
1568
+ * bitrate paid buys a wait nobody can feel.
1569
+ */
1570
+ var GOP_SECONDS = 1;
1571
+ /**
1572
+ * `-analyzeduration` / `-probesize`, well under ffmpeg's 5 s / 5 MB defaults.
1573
+ *
1574
+ * Safe on RTSP specifically: the SDP already declares the codec, so the probe
1575
+ * has nothing to discover. It matters more here than anywhere else in the repo
1576
+ * because the probes are PARALLEL inputs of one child — the composite emits
1577
+ * nothing until the LAST of them has finished, so the default budget is paid
1578
+ * once per grid, at the slowest source's pace.
1579
+ */
1580
+ var ANALYZE_DURATION_US = 1e6;
1581
+ var PROBE_SIZE_BYTES = 1e6;
1582
+ function inputPlanFor(source) {
1583
+ return {
1584
+ url: source.url,
1585
+ rtspTransport: "tcp",
1586
+ fflags: ["+discardcorrupt+nobuffer"],
1587
+ lowDelay: true,
1588
+ analyzeDurationUs: ANALYZE_DURATION_US,
1589
+ probeSizeBytes: PROBE_SIZE_BYTES
1590
+ };
1591
+ }
1592
+ /**
1593
+ * @throws when the layout addresses an input the sources do not contain, or
1594
+ * when there is nothing to compose. Both are plan errors: ffmpeg would refuse
1595
+ * them at start-up with a message about a filter pad, which names the graph
1596
+ * rather than the configuration that produced it.
1597
+ */
1598
+ function gridStreamInvocation(input) {
1599
+ if (input.sources.length === 0 || input.layout.cells.length === 0) throw new Error("camera-grid: a composite needs at least one cell with a source before it can emit anything");
1600
+ for (const cell of input.layout.cells) if (cell.inputIndex < 0 || cell.inputIndex >= input.sources.length) throw new Error(`camera-grid: cell addresses input ordinal ${cell.inputIndex}, but only ${input.sources.length} input(s) were acquired`);
1601
+ const graph = buildGridFilterGraph(input.layout);
1602
+ const [first, ...rest] = input.sources;
1603
+ if (first === void 0) throw new Error("camera-grid: a composite needs at least one cell with a source");
1604
+ const video = {
1605
+ kind: "encode",
1606
+ encoder: input.encoder,
1607
+ preset: PRESET,
1608
+ tune: TUNE,
1609
+ pixelFormat: PIXEL_FORMAT,
1610
+ fps: input.fps,
1611
+ gopFrames: input.fps * GOP_SECONDS,
1612
+ bitrateKbps: input.bitrateKbps,
1613
+ scale: null
1614
+ };
1615
+ return {
1616
+ logLevel: "error",
1617
+ decodeHwAccel: input.decodeHwAccel,
1618
+ input: inputPlanFor(first),
1619
+ extraInputs: rest.map(inputPlanFor),
1620
+ filterGraph: graph,
1621
+ video,
1622
+ audio: { kind: "none" },
1623
+ threadCount: 0,
1624
+ outputArgs: [],
1625
+ sink: {
1626
+ kind: "stdout",
1627
+ container: "flv"
1628
+ }
1629
+ };
1630
+ }
1631
+ //#endregion
1632
+ //#region src/builtins/camera-grid/grid-child.ts
1633
+ /**
1634
+ * Spawning the composition, through the ONE primitive.
1635
+ *
1636
+ * `FfmpegProcess` (`@camstack/types`) owns spawn, the first-data deadline, the
1637
+ * hardware→software retry, exit classification and the bounded restart. This
1638
+ * file owns only the PLUMBING — which bytes go where — because that is the one
1639
+ * thing the primitive deliberately does not own.
1640
+ *
1641
+ * `maxRestarts: 0`. A composite is demand-driven: if the child dies, the
1642
+ * session tells every consumer why and tears down, and the consumer's next read
1643
+ * starts a cold one with freshly acquired sources. A restart inside the process
1644
+ * would reuse broker handles that may already have been released.
1645
+ */
1646
+ /** No first-data deadline retry loop: one attempt, and the session hears about it. */
1647
+ var FIRST_DATA_TIMEOUT_MS = 15e3;
1648
+ async function startGridChild(ctx, plan, sources, onEnded) {
1649
+ const invocation = (decodeHwAccel) => gridStreamInvocation({
1650
+ layout: plan.layout,
1651
+ sources: plan.sourceDeviceIds.map((deviceId, index) => ({
1652
+ deviceId,
1653
+ url: sources[index]?.url ?? ""
1654
+ })),
1655
+ decodeHwAccel,
1656
+ encoder: plan.encoder,
1657
+ fps: plan.fps,
1658
+ bitrateKbps: plan.bitrateKbps
1659
+ });
1660
+ let stdout = null;
1661
+ const process = new FfmpegProcess({
1662
+ binaryPath: ctx.binaryPath,
1663
+ buildArgs: (decodeHwAccel) => buildFfmpegArgs(invocation(decodeHwAccel)),
1664
+ decodeHwAccel: plan.decodeHwAccel,
1665
+ logger: ctx.logger,
1666
+ deviceId: ctx.deviceId,
1667
+ role: "camera-grid",
1668
+ tags: {
1669
+ cells: plan.layout.cells.length,
1670
+ inputs: plan.sourceDeviceIds.length
1671
+ },
1672
+ firstDataTimeoutMs: FIRST_DATA_TIMEOUT_MS,
1673
+ maxRestarts: 0,
1674
+ onChild: (child) => {
1675
+ if (child.stdout) stdout = child.stdout;
1676
+ child.stderr?.on("data", () => {});
1677
+ },
1678
+ onExit: (exit) => {
1679
+ onEnded(exit.classification);
1680
+ }
1681
+ });
1682
+ await process.start();
1683
+ if (stdout === null) {
1684
+ await process.stop();
1685
+ throw new Error("camera-grid: the composition child produced no stdout to read");
1686
+ }
1687
+ return {
1688
+ stdout,
1689
+ stop: async () => {
1690
+ await process.stop();
1691
+ }
1692
+ };
1693
+ }
1694
+ //#endregion
1695
+ //#region src/builtins/camera-grid/addon.ts
1696
+ var INTEGRATION_NAME = "Camera Grids";
1697
+ var RECONCILE_MS = 6e4;
1698
+ var DEFAULTS = {
1699
+ cameraGrids: [],
1700
+ cameraGridTombstones: []
1701
+ };
1702
+ /** One session per (grid, profile). A grid composes each profile separately. */
1703
+ function sessionKey(instanceId, profile) {
1704
+ return `${instanceId}::${profile}`;
1705
+ }
1706
+ /**
1707
+ * One grid's composition of one profile, and enough about it for the snapshot
1708
+ * to choose between them without reaching into the session.
1709
+ */
1710
+ var GridSessionEntry = class {
1711
+ instanceId;
1712
+ profile;
1713
+ session;
1714
+ constructor(instanceId, profile, session) {
1715
+ this.instanceId = instanceId;
1716
+ this.profile = profile;
1717
+ this.session = session;
1718
+ }
1719
+ isRunning() {
1720
+ return this.session.isRunning();
1721
+ }
1722
+ };
1723
+ var CameraGridAddon = class extends BaseAddon {
1724
+ relay = null;
1725
+ reconcileTimer = null;
1726
+ /** Keyed by {@link sessionKey} — one composition per grid per profile. */
1727
+ sessions = /* @__PURE__ */ new Map();
1728
+ deviceIdByInstanceId = /* @__PURE__ */ new Map();
1729
+ tombstones = /* @__PURE__ */ new Set();
1730
+ /**
1731
+ * What each grid can honestly publish, per profile.
1732
+ *
1733
+ * ABSENT means "not asked yet", and it is a REFUSAL to describe — the device
1734
+ * throws rather than answering an empty catalog, because an empty catalog
1735
+ * retracts every stream the broker holds for the grid. It is refreshed on
1736
+ * every reconcile and on every layout save; the window belongs to the READER
1737
+ * (D224), and 60 s is the window for a fact — "does 615 have a low assigned"
1738
+ * — that only changes when an operator changes it.
1739
+ */
1740
+ offersByInstanceId = /* @__PURE__ */ new Map();
1741
+ snapshots = null;
1742
+ frameSampler = null;
1743
+ ffmpegBinaryPath = "ffmpeg";
1744
+ integrationId;
1745
+ constructor() {
1746
+ super({ ...DEFAULTS });
1747
+ }
1748
+ async onInitialize() {
1749
+ this.replaceTombstones(this.config.cameraGridTombstones);
1750
+ await this.normalizeRows();
1751
+ if (declarationOwnerNodeId(this.ctx.kernel.localNodeId) === "hub") {
1752
+ this.ffmpegBinaryPath = await this.resolveFfmpegBinaryPath();
1753
+ const relay = new GridStreamRelay({
1754
+ logger: this.ctx.logger.child("relay"),
1755
+ sessionFor: (request) => this.sessionFor(request)
1756
+ });
1757
+ await relay.start();
1758
+ this.relay = relay;
1759
+ this.frameSampler = new GridFrameSampler({
1760
+ binaryPath: this.ffmpegBinaryPath,
1761
+ logger: this.ctx.logger.child("snapshot")
1762
+ });
1763
+ this.snapshots = new GridSnapshotSource({
1764
+ logger: this.ctx.logger.child("snapshot"),
1765
+ sessionsFor: (deviceId) => this.sessionsForDevice(deviceId),
1766
+ sampleFrame: async (session) => this.sampleFrame(session),
1767
+ store: new GridLastFrameFileStore({ dataDir: this.ctx.dataDir })
1768
+ });
1769
+ installGridCameraRuntime({
1770
+ descriptorsFor: (instanceId) => this.descriptorsFor(instanceId),
1771
+ getLayout: (deviceId) => this.gridViewFor(deviceId),
1772
+ saveLayout: async (patch) => this.saveGridLayout(patch),
1773
+ getSnapshot: async (deviceId) => this.getSnapshot(deviceId)
1774
+ });
1775
+ await this.reconcile().catch((error) => {
1776
+ this.ctx.logger.warn("camera grid: initial reconciliation failed — will retry", { meta: { error: errMsg(error) } });
1777
+ });
1778
+ this.reconcileTimer = setInterval(() => {
1779
+ this.reconcile().catch((error) => {
1780
+ this.ctx.logger.warn("camera grid: reconciliation failed", { meta: { error: errMsg(error) } });
1781
+ });
1782
+ }, RECONCILE_MS);
1783
+ this.reconcileTimer.unref?.();
1784
+ }
1785
+ return { providers: [{
1786
+ capability: cameraGridLayoutCapability,
1787
+ provider: buildGridLayoutNativeProvider({
1788
+ getLayout: (deviceId) => this.gridViewFor(deviceId),
1789
+ saveLayout: (patch) => this.saveGridLayout(patch)
1790
+ })
1791
+ }] };
1792
+ }
1793
+ async onConfigChanged() {
1794
+ this.replaceTombstones(this.config.cameraGridTombstones);
1795
+ await this.normalizeRows();
1796
+ await this.applyLayoutChanges();
1797
+ if (this.relay) this.reconcile().catch((error) => {
1798
+ this.ctx.logger.warn("camera grid: reconciliation after a config change failed", { meta: { error: errMsg(error) } });
1799
+ });
1800
+ }
1801
+ async onShutdown() {
1802
+ if (this.reconcileTimer) clearInterval(this.reconcileTimer);
1803
+ this.reconcileTimer = null;
1804
+ installGridCameraRuntime(null);
1805
+ await this.relay?.dispose();
1806
+ this.relay = null;
1807
+ for (const entry of this.sessions.values()) await entry.session.dispose();
1808
+ this.sessions.clear();
1809
+ this.snapshots = null;
1810
+ this.frameSampler = null;
1811
+ }
1812
+ instances() {
1813
+ return readGridInstances(this.config.cameraGrids, (message) => {
1814
+ this.ctx.logger.warn(message);
1815
+ });
1816
+ }
1817
+ instanceById(instanceId) {
1818
+ return this.instances().find((instance) => instance.id === instanceId);
1819
+ }
1820
+ /**
1821
+ * Fill in everything a name-only row is missing, and persist if it changed.
1822
+ *
1823
+ * Without this a freshly typed row is unparseable, and `readGridInstances`
1824
+ * IGNORES what it cannot parse — so creation would silently do nothing.
1825
+ */
1826
+ async normalizeRows() {
1827
+ const normalized = normalizeGridRows(this.config.cameraGrids, () => crypto.randomUUID());
1828
+ if (!normalized.changed) return;
1829
+ this.ctx.logger.info("camera grid: completing operator-created rows", { meta: {
1830
+ rows: normalized.rows.length,
1831
+ wasRows: this.config.cameraGrids.length
1832
+ } });
1833
+ await this.updateGlobalSettings({ cameraGrids: [...normalized.rows] });
1834
+ }
1835
+ replaceTombstones(stableIds) {
1836
+ this.tombstones.clear();
1837
+ for (const stableId of stableIds) if (typeof stableId === "string" && stableId.length > 0) this.tombstones.add(stableId);
1838
+ }
1839
+ async reconcile() {
1840
+ if (!this.relay) return;
1841
+ const instances = this.instances();
1842
+ const declarations = buildGridCameraDeclarations(instances);
1843
+ await this.refreshProfileOffers(instances);
1844
+ await this.retireWithdrawnSessions(instances);
1845
+ const result = await new DeclaredDevices({
1846
+ logger: this.ctx.logger.child("declaration"),
1847
+ addonId: this.ctx.id,
1848
+ devices: this.ctx.kernel.devices,
1849
+ localNodeId: this.ctx.kernel.localNodeId,
1850
+ getIntegration: async (addonId) => {
1851
+ const integration = await this.ctx.api.integrations.getByAddonId.query({ addonId });
1852
+ this.integrationId = integration?.id ?? null;
1853
+ return integration;
1854
+ },
1855
+ createIntegration: async (input) => {
1856
+ const integration = await this.ctx.api.integrations.create.mutate(input);
1857
+ this.integrationId = integration.id;
1858
+ return integration;
1859
+ },
1860
+ updateIntegration: async ({ id, info }) => {
1861
+ await this.ctx.api.integrations.update.mutate({
1862
+ id,
1863
+ info,
1864
+ skipRestart: true
1865
+ });
1866
+ },
1867
+ listOwnDevices: async () => {
1868
+ return gridCameraReconciliationIndex(await this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id }), this.integrationId, declarations, this.tombstones);
1869
+ }
1870
+ }).reconcile({
1871
+ integrationName: INTEGRATION_NAME,
1872
+ placement: "hub",
1873
+ devices: declarations.map((camera) => ({
1874
+ stableId: camera.stableId,
1875
+ name: camera.name,
1876
+ type: DeviceType.Camera,
1877
+ DeviceClass: GridCameraDevice,
1878
+ config: camera.config,
1879
+ role: "camera-grid"
1880
+ }))
1881
+ });
1882
+ const declaredByStableId = new Map(declarations.map((camera) => [camera.stableId, camera]));
1883
+ for (const outcome of result.devices) {
1884
+ if (!(outcome.device instanceof GridCameraDevice)) continue;
1885
+ const declaration = declaredByStableId.get(outcome.stableId);
1886
+ if (!declaration) continue;
1887
+ const config = outcome.device.config;
1888
+ if (config.get("instanceId") !== declaration.config.instanceId) await config.setAll(declaration.config);
1889
+ const instanceId = declaration.config.instanceId;
1890
+ this.deviceIdByInstanceId.set(instanceId, outcome.device.id);
1891
+ this.ensureSessions(instanceId, outcome.device.id);
1892
+ if (outcome.created) await this.silenceAnalysis(outcome.device.id);
1893
+ outcome.device.setAddonOnline(true);
1894
+ }
1895
+ }
1896
+ async silenceAnalysis(deviceId) {
1897
+ try {
1898
+ await silenceAnalysisFor({
1899
+ api: this.ctx.api,
1900
+ logger: this.ctx.logger
1901
+ }, deviceId);
1902
+ } catch (error) {
1903
+ this.ctx.logger.error("camera grid: a new grid could not be silenced", {
1904
+ tags: { deviceId },
1905
+ meta: { error: errMsg(error) }
1906
+ });
1907
+ }
1908
+ }
1909
+ /**
1910
+ * Which profiles each grid can honestly publish, asked of the BROKER.
1911
+ *
1912
+ * `listAllProfileSlots` is the one authority on "does 615 have a low
1913
+ * assigned", and `sourceCamStreamId === null` is its way of saying no. A read
1914
+ * that FAILS leaves the previous answer in place and says so - it is not an
1915
+ * empty catalog, and turning it into one would retract the streams of every
1916
+ * grid on the hub.
1917
+ */
1918
+ async refreshProfileOffers(instances) {
1919
+ let slots;
1920
+ try {
1921
+ slots = await this.ctx.api.streamBroker.listAllProfileSlots.query();
1922
+ } catch (error) {
1923
+ this.ctx.logger.warn("camera grid: could not read the broker profile slots - keeping the last answer", { meta: {
1924
+ error: errMsg(error),
1925
+ grids: instances.length
1926
+ } });
1927
+ return;
1928
+ }
1929
+ const servedBy = /* @__PURE__ */ new Map();
1930
+ for (const slot of slots) {
1931
+ if (slot.sourceCamStreamId === null) continue;
1932
+ const profiles = servedBy.get(slot.deviceId) ?? [];
1933
+ profiles.push(slot.profile);
1934
+ servedBy.set(slot.deviceId, profiles);
1935
+ }
1936
+ for (const instance of instances) {
1937
+ const offers = offeredGridProfiles({
1938
+ sourceDeviceIds: [...new Set(instance.cells.map((cell) => cell.deviceId))],
1939
+ profilesServedBy: (deviceId) => servedBy.get(deviceId) ?? null
1940
+ });
1941
+ const previous = this.offersByInstanceId.get(instance.id);
1942
+ this.offersByInstanceId.set(instance.id, offers);
1943
+ this.logRefusals(instance, offers, previous);
1944
+ }
1945
+ }
1946
+ /**
1947
+ * Say which profiles this grid will NOT publish, and why - once per change.
1948
+ *
1949
+ * A profile silently missing from a catalog is indistinguishable from a
1950
+ * broker that swept it. Logged on the EDGE rather than every minute: a grid
1951
+ * that can only serve `high` would otherwise write two lines a minute for
1952
+ * ever.
1953
+ */
1954
+ logRefusals(instance, offers, previous) {
1955
+ if (previous !== void 0 && JSON.stringify(previous) === JSON.stringify(offers)) return;
1956
+ const deviceId = this.deviceIdByInstanceId.get(instance.id) ?? 0;
1957
+ for (const offer of offers) {
1958
+ if (offer.offered) continue;
1959
+ if (offer.missingSources.length === 0) continue;
1960
+ this.ctx.logger.info("camera grid: a profile is NOT on offer", {
1961
+ tags: { deviceId },
1962
+ meta: {
1963
+ instanceId: instance.id,
1964
+ profile: offer.profile,
1965
+ missingSources: [...offer.missingSources]
1966
+ }
1967
+ });
1968
+ }
1969
+ this.ctx.logger.info("camera grid: published profiles", {
1970
+ tags: { deviceId },
1971
+ meta: {
1972
+ instanceId: instance.id,
1973
+ offered: offers.filter((offer) => offer.offered).map((offer) => offer.profile)
1974
+ }
1975
+ });
1976
+ }
1977
+ offeredProfiles(instanceId) {
1978
+ const offers = this.offersByInstanceId.get(instanceId);
1979
+ if (offers === void 0) return null;
1980
+ return offers.filter((offer) => offer.offered).map((offer) => offer.profile);
1981
+ }
1982
+ ensureSessions(instanceId, deviceId) {
1983
+ const offered = this.offeredProfiles(instanceId) ?? [];
1984
+ for (const profile of GRID_PROFILES) {
1985
+ const key = sessionKey(instanceId, profile);
1986
+ if (!offered.includes(profile)) {
1987
+ const stale = this.sessions.get(key);
1988
+ if (stale) {
1989
+ this.sessions.delete(key);
1990
+ stale.session.dispose();
1991
+ }
1992
+ continue;
1993
+ }
1994
+ if (this.sessions.has(key)) continue;
1995
+ const session = this.makeSession(instanceId, deviceId, profile);
1996
+ this.sessions.set(key, new GridSessionEntry(instanceId, profile, session));
1997
+ }
1998
+ }
1999
+ makeSession(instanceId, deviceId, profile) {
2000
+ const childContext = {
2001
+ binaryPath: this.ffmpegBinaryPath,
2002
+ logger: this.ctx.logger,
2003
+ deviceId
2004
+ };
2005
+ const session = new GridStreamSession({
2006
+ deviceId,
2007
+ deps: {
2008
+ logger: this.ctx.logger,
2009
+ acquireSource: async (sourceDeviceId) => {
2010
+ const dial = gridSourceDial(await this.ctx.api.streamBroker.getStreamWithCodec.mutate({
2011
+ deviceId: sourceDeviceId,
2012
+ video: "copy",
2013
+ audio: "none",
2014
+ profile,
2015
+ tag: `camera-grid:${String(deviceId)}:${profile}`
2016
+ }));
2017
+ this.ctx.logger.info("camera grid: source acquired", {
2018
+ tags: { deviceId },
2019
+ meta: {
2020
+ instanceId,
2021
+ profile,
2022
+ sourceDeviceId,
2023
+ mode: dial.mode,
2024
+ ...dial.mode === "sentry" ? { why: "battery source - the sentry dial never asks for the camera" } : {}
2025
+ }
2026
+ });
2027
+ return {
2028
+ url: dial.url,
2029
+ pipelineKey: dial.pipelineKey
2030
+ };
2031
+ },
2032
+ releaseSource: async (pipelineKey) => {
2033
+ await this.ctx.api.streamBroker.releaseStreamWithCodec.mutate({ pipelineKey });
2034
+ },
2035
+ startChild: async (plan, sources) => startGridChild(childContext, plan, sources, (reason) => {
2036
+ session.onChildEnded(reason);
2037
+ })
2038
+ },
2039
+ resolvePlan: () => {
2040
+ const instance = this.instanceById(instanceId);
2041
+ if (!instance) return null;
2042
+ return gridPlanFor(instance, {
2043
+ encoder: "libx264",
2044
+ decodeHwAccel: null,
2045
+ canvas: gridCanvasFor({
2046
+ width: instance.width,
2047
+ height: instance.height
2048
+ }, profile)
2049
+ });
2050
+ }
2051
+ });
2052
+ return session;
2053
+ }
2054
+ /** The session a dial is asking for, or `null` - which the relay answers 404 to. */
2055
+ sessionFor(request) {
2056
+ return this.sessions.get(sessionKey(request.instanceId, request.profile))?.session ?? null;
2057
+ }
2058
+ /** This grid's sessions, in DESCENDING quality - the snapshot takes the first running one. */
2059
+ sessionsForDevice(deviceId) {
2060
+ const instanceId = this.instanceIdForDevice(deviceId);
2061
+ if (instanceId === null) return [];
2062
+ const entries = [];
2063
+ for (const profile of GRID_PROFILES) {
2064
+ const entry = this.sessions.get(sessionKey(instanceId, profile));
2065
+ if (entry) entries.push(entry);
2066
+ }
2067
+ return entries;
2068
+ }
2069
+ /** A grid that is gone stops immediately - it must not keep N sources dialled. */
2070
+ async retireWithdrawnSessions(instances) {
2071
+ const live = new Set(instances.filter((instance) => instance.enabled).map((i) => i.id));
2072
+ for (const [key, entry] of [...this.sessions]) {
2073
+ if (live.has(entry.instanceId)) continue;
2074
+ this.ctx.logger.info("camera grid: retiring a withdrawn grid", {
2075
+ tags: { deviceId: this.deviceIdByInstanceId.get(entry.instanceId) ?? 0 },
2076
+ meta: {
2077
+ instanceId: entry.instanceId,
2078
+ profile: entry.profile
2079
+ }
2080
+ });
2081
+ this.sessions.delete(key);
2082
+ await entry.session.dispose();
2083
+ }
2084
+ for (const instanceId of [...this.deviceIdByInstanceId.keys()]) {
2085
+ if (live.has(instanceId)) continue;
2086
+ this.deviceIdByInstanceId.delete(instanceId);
2087
+ this.offersByInstanceId.delete(instanceId);
2088
+ }
2089
+ }
2090
+ /**
2091
+ * A geometry change is a NEW composition. The running child is composing the
2092
+ * old one, and ffmpeg has no way to be told otherwise, so it is stopped: the
2093
+ * consumer's next read re-dials and gets the new picture.
2094
+ */
2095
+ async applyLayoutChanges() {
2096
+ for (const entry of this.sessions.values()) {
2097
+ if (!entry.session.isRunning()) continue;
2098
+ this.ctx.logger.info("camera grid: the layout changed - restarting the composition", {
2099
+ tags: { deviceId: this.deviceIdByInstanceId.get(entry.instanceId) ?? 0 },
2100
+ meta: {
2101
+ instanceId: entry.instanceId,
2102
+ profile: entry.profile
2103
+ }
2104
+ });
2105
+ entry.session.onChildEnded("layout-changed");
2106
+ }
2107
+ }
2108
+ async getSnapshot(deviceId) {
2109
+ const snapshots = this.snapshots;
2110
+ if (snapshots === null) return null;
2111
+ return snapshots.getSnapshot(deviceId);
2112
+ }
2113
+ /**
2114
+ * One frame out of a composition that is ALREADY running.
2115
+ *
2116
+ * `openIfRunning` is the whole guarantee: it refuses a cold session, so a
2117
+ * thumbnail can never be the reason N sources get acquired. A grid that is
2118
+ * not being watched has no fresh frame, and that is the honest answer.
2119
+ */
2120
+ async sampleFrame(entry) {
2121
+ const sampler = this.frameSampler;
2122
+ if (sampler === null) return null;
2123
+ return sampler.sample(entry.session.deviceId, { attach: (onChunk) => entry.session.openIfRunning({
2124
+ onData: onChunk,
2125
+ onClose: () => {}
2126
+ }) });
2127
+ }
2128
+ /**
2129
+ * Every stream this grid offers, or `null` when the addon cannot say.
2130
+ *
2131
+ * `null` is a refusal to describe and the device turns it into a THROW. An
2132
+ * empty array would be the broker reading "this camera offers nothing" and
2133
+ * sweeping every stream it holds for the grid.
2134
+ */
2135
+ descriptorsFor(instanceId) {
2136
+ const instance = this.instanceById(instanceId);
2137
+ const relay = this.relay;
2138
+ const offered = this.offeredProfiles(instanceId);
2139
+ if (!instance || !relay || offered === null) return null;
2140
+ return offered.map((profile) => {
2141
+ const canvas = gridCanvasFor({
2142
+ width: instance.width,
2143
+ height: instance.height
2144
+ }, profile);
2145
+ return gridStreamDescriptor({
2146
+ profile,
2147
+ width: canvas.width,
2148
+ height: canvas.height,
2149
+ fps: instance.fps,
2150
+ url: relay.streamUrl({
2151
+ instanceId,
2152
+ profile
2153
+ })
2154
+ });
2155
+ });
2156
+ }
2157
+ instanceIdForDevice(deviceId) {
2158
+ for (const [instanceId, id] of this.deviceIdByInstanceId) if (id === deviceId) return instanceId;
2159
+ return null;
2160
+ }
2161
+ instanceForDevice(deviceId) {
2162
+ const instanceId = this.instanceIdForDevice(deviceId);
2163
+ return instanceId === null ? null : this.instanceById(instanceId) ?? null;
2164
+ }
2165
+ gridViewFor(deviceId) {
2166
+ const instance = this.instanceForDevice(deviceId);
2167
+ if (!instance) return null;
2168
+ return {
2169
+ instanceId: instance.id,
2170
+ deviceId,
2171
+ name: instance.name,
2172
+ width: instance.width,
2173
+ height: instance.height,
2174
+ fps: instance.fps,
2175
+ cells: instance.cells.map((cell) => ({
2176
+ deviceId: cell.deviceId,
2177
+ source: cell.source,
2178
+ cell: cell.cell
2179
+ })),
2180
+ profiles: (this.offersByInstanceId.get(instance.id) ?? []).map((offer) => ({
2181
+ profile: offer.profile,
2182
+ offered: offer.offered,
2183
+ missingSources: offer.offered ? [] : [...offer.missingSources]
2184
+ }))
2185
+ };
2186
+ }
2187
+ async saveGridLayout(input) {
2188
+ const instance = this.instanceForDevice(input.deviceId);
2189
+ if (!instance) throw new Error(`camera-grid: device ${String(input.deviceId)} is not a grid camera`);
2190
+ const updated = {
2191
+ ...instance,
2192
+ ...input.name !== void 0 ? { name: input.name.trim() } : {},
2193
+ ...input.width !== void 0 ? { width: input.width } : {},
2194
+ ...input.height !== void 0 ? { height: input.height } : {},
2195
+ ...input.fps !== void 0 ? { fps: input.fps } : {},
2196
+ cells: input.cells.map((cell) => ({ ...cell }))
2197
+ };
2198
+ this.ctx.logger.info("camera grid: layout saved", {
2199
+ tags: { deviceId: input.deviceId },
2200
+ meta: {
2201
+ cells: updated.cells.length,
2202
+ sources: [...new Set(updated.cells.map((cell) => cell.deviceId))],
2203
+ canvas: `${String(updated.width)}x${String(updated.height)}`
2204
+ }
2205
+ });
2206
+ await this.updateGlobalSettings({ cameraGrids: this.config.cameraGrids.map((row) => row["id"] === instance.id ? {
2207
+ ...row,
2208
+ ...updated
2209
+ } : row) });
2210
+ await this.refreshProfileOffers(this.instances());
2211
+ this.ensureSessions(instance.id, input.deviceId);
2212
+ const view = this.gridViewFor(input.deviceId);
2213
+ if (!view) throw new Error("camera-grid: the grid vanished while it was being saved");
2214
+ return view;
2215
+ }
2216
+ async resolveFfmpegBinaryPath() {
2217
+ try {
2218
+ return await this.ctx.deps.ensureFfmpeg();
2219
+ } catch (error) {
2220
+ this.ctx.logger.error("camera grid: ensureFfmpeg() failed to provision ffmpeg — falling back to PATH \"ffmpeg\"", { meta: { error: errMsg(error) } });
2221
+ return "ffmpeg";
2222
+ }
2223
+ }
2224
+ /**
2225
+ * The integration page: a list of grids, with a NAME.
2226
+ *
2227
+ * Deliberately nothing else. A generated form for the geometry would render
2228
+ * "cell 1 source = 592, cell 1 crop x = 0.25" and be unusable, so the
2229
+ * geometry lives in a WIDGET on the camera's own device details, declared by
2230
+ * the `camera-grid-layout` CAPABILITY (`camera-grid-layout.cap.ts`). It used
2231
+ * to be a `type:'widget'` field in this addon's own `deviceSettingsSchema()`
2232
+ * and it rendered NOWHERE: that section is only fetched for the four addons
2233
+ * hand-listed in `PIPELINE_CLUSTER_DEVICE_ADDONS`, and an addon not on the
2234
+ * list falls off silently. A cap-declared widget is placed by the framework,
2235
+ * on the camera, beside PTZ and motion zones.
2236
+ */
2237
+ globalSettingsSchema() {
2238
+ return this.schema({ sections: [{
2239
+ id: "camera-grids",
2240
+ title: "Camera grids",
2241
+ description: "A grid is a composite camera: several cameras composed into one picture, recorded and played back like any other camera. Create one here with a name; open its camera to lay it out. Object detection is switched OFF on a new grid — the cameras it is composed from are already analysed.",
2242
+ columns: 1,
2243
+ fields: [this.field({
2244
+ type: "editable-array",
2245
+ key: "cameraGrids",
2246
+ label: "Grids",
2247
+ default: [],
2248
+ maxRows: 16,
2249
+ addLabel: "Add grid",
2250
+ emptyMessage: "No camera grids yet.",
2251
+ rowTitleTemplate: "{name}",
2252
+ defaultItem: {
2253
+ name: "",
2254
+ enabled: true
2255
+ },
2256
+ itemFields: [{
2257
+ type: "text",
2258
+ key: "name",
2259
+ label: "Name",
2260
+ required: true
2261
+ }, {
2262
+ type: "boolean",
2263
+ key: "enabled",
2264
+ label: "Enabled",
2265
+ description: "A disabled grid withdraws its camera.",
2266
+ default: true
2267
+ }]
2268
+ })]
2269
+ }] });
2270
+ }
2271
+ integrationSettingSections() {
2272
+ return ["camera-grids"];
2273
+ }
2274
+ };
2275
+ //#endregion
2276
+ export { CameraGridAddon, CameraGridAddon as default, GRID_CAM_STREAM_ID, GRID_PROFILES, GRID_SILENCED_CAP_NAMES, GridCameraDevice, GridFrameSampler, GridLastFrameFileStore, GridSnapshotSource, GridStreamRelay, GridStreamSession, buildGridCameraDeclarations, buildGridFilterGraph, gridCamStreamId, gridCameraReconciliationIndex, gridCanvasFor, gridPlanFor, gridSnapshotAnswerFor, gridSourceDial, gridStreamDescriptor, gridStreamInvocation, installGridCameraRuntime, newGridCameraStableId, normalizeGridRows, offeredGridProfiles, parseGridStreamPath, readGridInstances, silenceAnalysisFor, startGridChild };