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