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