@camstack/ui-library 1.0.1 → 1.0.3

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.
@@ -0,0 +1,438 @@
1
+ import { TEXT_HINT, WidgetPanel, i as createLucideIcon, isAbsentProvider, useDeviceProxy, usePlayerOverlayLayer, useSystem } from "./index.js";
2
+ import { MaskShapeCanvas } from "./MaskShapeCanvas-DI4BY7W2.js";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
5
+ /**
6
+ * @license lucide-react v0.576.0 - ISC
7
+ *
8
+ * This source code is licensed under the ISC license.
9
+ * See the LICENSE file in the root directory of this source tree.
10
+ */
11
+ var Grid2x2 = createLucideIcon("grid-2x2", [
12
+ ["path", {
13
+ d: "M12 3v18",
14
+ key: "108xh3"
15
+ }],
16
+ ["path", {
17
+ d: "M3 12h18",
18
+ key: "1i2n21"
19
+ }],
20
+ ["rect", {
21
+ x: "3",
22
+ y: "3",
23
+ width: "18",
24
+ height: "18",
25
+ rx: "2",
26
+ key: "h1oib"
27
+ }]
28
+ ]);
29
+ //#endregion
30
+ //#region src/composites/cap-settings/MotionZonesSettings.tsx
31
+ /**
32
+ * MotionZonesSettings — management surface for the on-camera
33
+ * `motion-zones` capability. A cap-settings component (ui-library),
34
+ * mounted into the device-detail Config tab via the cap-UI
35
+ * contribution mechanism (the `motion-zones` cap declares a `ui` block).
36
+ *
37
+ * The on-camera motion mask is a SINGLE `grid` region in the shared
38
+ * MaskShape vocabulary (a row-major boolean cell lattice). This component
39
+ * owns its own control panel (Edit toggle, All on / All off / Invert,
40
+ * count, Revert, Save) and paints the editable lattice over the live frame
41
+ * with the shared `MaskShapeCanvas` drawing-plane — registered as a
42
+ * player-overlay layer ONLY while "Edit grid" is toggled on (OFF by
43
+ * default — nothing draws on mount).
44
+ *
45
+ * Detection enable + sensitivity are NOT edited here — they are the
46
+ * camera's on-board motion master switch, owned by the driver's
47
+ * "On-camera motion" settings section. Saving sends a `cells`-only patch
48
+ * (one grid region); enable / sensitivity are left untouched camera-side.
49
+ *
50
+ * ── Resizable input grid (resampled to the camera grid on save) ──────────────
51
+ * The camera's native motion grid (`getOptions().grid`, e.g. 22×18) is the
52
+ * FINEST resolution that round-trips — painting finer than it can't be stored
53
+ * (not reconstructible). So the camera grid is the FLOOR (×1) and the operator
54
+ * picks BIGGER cells: an integer cell-size multiplier (×1/×2/×3) where a UI cell
55
+ * spans an m×m block of camera cells (×2 → quarter as many, ×3 → ninth). The
56
+ * CAMERA grid stays authoritative:
57
+ * - LOAD: the camera grid is COARSENED into the chosen UI resolution (a UI
58
+ * cell is `true` if ANY camera cell in its block is painted) so existing
59
+ * masks display on the bigger-celled surface.
60
+ * - SAVE: the UI grid is EXPANDED back to camera gridWidth×gridHeight — every
61
+ * camera cell takes the value of the UI cell whose block covers it.
62
+ * At ×1 both transforms are the identity, so the camera-grid round-trip is
63
+ * exact; at higher multipliers `coarsen(expand(uiCells)) === uiCells`, so an
64
+ * edit made at the chosen resolution survives the round-trip exactly. The
65
+ * component's draft (`uiCells`) lives at the UI resolution; the last-saved truth
66
+ * (`committedCamera`) and every comparison stay anchored to the camera-native
67
+ * grid so `dirty`/Save/Revert math is resolution-independent.
68
+ */
69
+ var MOTION_ZONES_OVERLAY_ORDER = 110;
70
+ var OVERLAY_ID = "motion-zones";
71
+ var GRID_REGION_ID = 0;
72
+ var GRID_MULTIPLIERS = [
73
+ 1,
74
+ 2,
75
+ 3
76
+ ];
77
+ var DEFAULT_MULTIPLIER = 1;
78
+ var BTN_NEUTRAL = "rounded-md border border-border bg-surface px-2 py-1 text-[11px] font-medium text-foreground-subtle hover:bg-surface-hover disabled:opacity-40 transition-colors";
79
+ var BTN_PRIMARY = "rounded-md border border-primary/50 bg-primary/15 px-2.5 py-1 text-[11px] font-medium text-primary hover:bg-primary/25 disabled:opacity-40 transition-colors";
80
+ /** Coerce an arbitrary `cells` array to exactly `width*height` booleans. */
81
+ function normaliseCells(cells, width, height) {
82
+ const total = width * height;
83
+ const next = new Array(total);
84
+ for (let i = 0; i < total; i += 1) next[i] = cells[i] === true;
85
+ return next;
86
+ }
87
+ function cellsEqual(a, b) {
88
+ if (a.length !== b.length) return false;
89
+ for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
90
+ return true;
91
+ }
92
+ /** UI-grid dimension for a camera dimension at cell-size multiplier m. */
93
+ function coarseDim(cameraDim, m) {
94
+ return Math.ceil(cameraDim / m);
95
+ }
96
+ /**
97
+ * Map a camera-grid index to the UI-grid cell that SPATIALLY covers it, by the
98
+ * camera cell's centre (`(c + 0.5)/cameraDim`). Index-based `floor(c/m)` drifts
99
+ * when the camera dim isn't a multiple of m (e.g. 22 ÷ 3) — the painted UI cell
100
+ * no longer lines up with the camera region it masks. The centre map keeps them
101
+ * aligned and is a non-decreasing surjection onto [0, uiDim), so every UI cell
102
+ * owns ≥1 camera cell — which is what makes `coarsen(expand(x)) === x` hold.
103
+ */
104
+ function cameraToUi(c, cameraDim, uiDim) {
105
+ return Math.min(uiDim - 1, Math.floor((c + .5) / cameraDim * uiDim));
106
+ }
107
+ /**
108
+ * COARSEN a camera-native grid (cw×ch, row-major) into a coarser UI grid at
109
+ * ceil(cw/m)×ceil(ch/m): a UI cell is `true` if ANY camera cell mapped into it
110
+ * is painted (the inclusive "any" rule — motion masks are inclusive, so a
111
+ * painted sub-region arms the whole bigger cell). At m === 1 this is the
112
+ * identity. Used on LOAD/Revert so an existing camera mask shows up on the
113
+ * chosen (bigger-celled) painting surface.
114
+ */
115
+ function coarsenCells(cameraCells, cw, ch, m) {
116
+ const uw = coarseDim(cw, m);
117
+ const uh = coarseDim(ch, m);
118
+ const out = new Array(uw * uh).fill(false);
119
+ for (let cy = 0; cy < ch; cy += 1) {
120
+ const uy = cameraToUi(cy, ch, uh);
121
+ for (let cx = 0; cx < cw; cx += 1) {
122
+ if (cameraCells[cy * cw + cx] !== true) continue;
123
+ out[uy * uw + cameraToUi(cx, cw, uw)] = true;
124
+ }
125
+ }
126
+ return out;
127
+ }
128
+ /**
129
+ * EXPAND a coarser UI grid at ceil(cw/m)×ceil(ch/m) back to the camera-native
130
+ * grid (cw×ch): every camera cell takes the value of the UI cell that spatially
131
+ * covers it. At m === 1 this is the identity, so the camera-grid round-trip is
132
+ * exact. Used on SAVE, on multiplier changes (to preserve painted area), and to
133
+ * compute `dirty`. Note `coarsen(expand(uiCells)) === uiCells`, so an edit made
134
+ * at the chosen resolution survives the round-trip exactly.
135
+ */
136
+ function expandCells(uiCells, cw, ch, m) {
137
+ const uw = coarseDim(cw, m);
138
+ const uh = coarseDim(ch, m);
139
+ const out = new Array(cw * ch).fill(false);
140
+ for (let cy = 0; cy < ch; cy += 1) {
141
+ const uy = cameraToUi(cy, ch, uh);
142
+ for (let cx = 0; cx < cw; cx += 1) out[cy * cw + cx] = uiCells[uy * uw + cameraToUi(cx, cw, uw)] === true;
143
+ }
144
+ return out;
145
+ }
146
+ function MotionZonesSettings({ deviceId }) {
147
+ const dev = useDeviceProxy(useSystem().trpcClient, deviceId);
148
+ const [options, setOptions] = useState(null);
149
+ const [unsupported, setUnsupported] = useState(false);
150
+ const [uiCells, setUiCells] = useState(null);
151
+ const [committedCamera, setCommittedCamera] = useState(null);
152
+ const [multiplier, setMultiplier] = useState(DEFAULT_MULTIPLIER);
153
+ const [saving, setSaving] = useState(false);
154
+ const [editing, setEditing] = useState(false);
155
+ const seededRef = useRef(false);
156
+ useEffect(() => {
157
+ if (!dev) return void 0;
158
+ let cancelled = false;
159
+ seededRef.current = false;
160
+ setOptions(null);
161
+ setUnsupported(false);
162
+ setUiCells(null);
163
+ setCommittedCamera(null);
164
+ setMultiplier(DEFAULT_MULTIPLIER);
165
+ setEditing(false);
166
+ (async () => {
167
+ try {
168
+ const opts = await dev.motionZones?.getOptions({});
169
+ if (cancelled) return;
170
+ if (!opts) throw new Error("device proxy not ready");
171
+ setOptions(opts);
172
+ if (seededRef.current) return;
173
+ const status = await dev.motionZones?.getStatus({});
174
+ if (cancelled) return;
175
+ if (!status) throw new Error("device proxy not ready");
176
+ seededRef.current = true;
177
+ const region = status.regions.find((r) => r.shape.kind === "grid");
178
+ const norm = normaliseCells(region ? region.shape.cells : [], opts.grid.width, opts.grid.height);
179
+ setCommittedCamera(norm);
180
+ setUiCells(coarsenCells(norm, opts.grid.width, opts.grid.height, DEFAULT_MULTIPLIER));
181
+ } catch (err) {
182
+ if (cancelled) return;
183
+ if (isAbsentProvider(err)) setUnsupported(true);
184
+ else console.error("Motion Zones load failed", err);
185
+ }
186
+ })();
187
+ return () => {
188
+ cancelled = true;
189
+ };
190
+ }, [dev]);
191
+ const cw = options ? options.grid.width : 0;
192
+ const ch = options ? options.grid.height : 0;
193
+ const uiWidth = coarseDim(cw, multiplier);
194
+ const uiHeight = coarseDim(ch, multiplier);
195
+ const draftCamera = useMemo(() => uiCells && options ? expandCells(uiCells, cw, ch, multiplier) : null, [
196
+ uiCells,
197
+ options,
198
+ cw,
199
+ ch,
200
+ multiplier
201
+ ]);
202
+ const dirty = useMemo(() => draftCamera !== null && committedCamera !== null && !cellsEqual(draftCamera, committedCamera), [draftCamera, committedCamera]);
203
+ const activeCount = useMemo(() => draftCamera ? draftCamera.reduce((n, c) => c ? n + 1 : n, 0) : 0, [draftCamera]);
204
+ const cameraTotal = cw * ch;
205
+ const uiTotal = uiWidth * uiHeight;
206
+ const setAll = useCallback((value) => {
207
+ if (uiTotal > 0) setUiCells(new Array(uiTotal).fill(value));
208
+ }, [uiTotal]);
209
+ const invert = useCallback(() => {
210
+ setUiCells((prev) => prev ? prev.map((c) => !c) : prev);
211
+ }, []);
212
+ const revert = useCallback(() => {
213
+ if (committedCamera && options) setUiCells(coarsenCells(committedCamera, cw, ch, multiplier));
214
+ }, [
215
+ committedCamera,
216
+ options,
217
+ cw,
218
+ ch,
219
+ multiplier
220
+ ]);
221
+ const changeMultiplier = useCallback((next) => {
222
+ if (next === multiplier || !options) return;
223
+ setUiCells((prev) => {
224
+ if (!prev) {
225
+ setMultiplier(next);
226
+ return prev;
227
+ }
228
+ const recoarsened = coarsenCells(expandCells(prev, cw, ch, multiplier), cw, ch, next);
229
+ setMultiplier(next);
230
+ return recoarsened;
231
+ });
232
+ }, [
233
+ multiplier,
234
+ options,
235
+ cw,
236
+ ch
237
+ ]);
238
+ const items = useMemo(() => options && uiCells ? [{
239
+ id: GRID_REGION_ID,
240
+ shape: {
241
+ kind: "grid",
242
+ gridWidth: uiWidth,
243
+ gridHeight: uiHeight,
244
+ cells: [...uiCells]
245
+ }
246
+ }] : [], [
247
+ options,
248
+ uiCells,
249
+ uiWidth,
250
+ uiHeight
251
+ ]);
252
+ const onShapeChange = useCallback((_id, shape) => {
253
+ if (shape.kind !== "grid") return;
254
+ setUiCells(shape.cells);
255
+ }, []);
256
+ const save = useCallback(async () => {
257
+ if (!dev || !uiCells || !options) return;
258
+ setSaving(true);
259
+ try {
260
+ const cameraCells = expandCells(uiCells, options.grid.width, options.grid.height, multiplier);
261
+ const shape = {
262
+ kind: "grid",
263
+ gridWidth: options.grid.width,
264
+ gridHeight: options.grid.height,
265
+ cells: cameraCells
266
+ };
267
+ await dev.motionZones?.setZone({ patch: { regions: [{
268
+ id: GRID_REGION_ID,
269
+ enabled: true,
270
+ shape
271
+ }] } });
272
+ const fresh = await dev.motionZones?.getStatus({});
273
+ if (fresh) {
274
+ const region = fresh.regions.find((r) => r.shape.kind === "grid");
275
+ const norm = normaliseCells(region ? region.shape.cells : [], options.grid.width, options.grid.height);
276
+ setCommittedCamera(norm);
277
+ setUiCells(coarsenCells(norm, options.grid.width, options.grid.height, multiplier));
278
+ }
279
+ } catch (err) {
280
+ console.error("Motion Zones save failed", err);
281
+ } finally {
282
+ setSaving(false);
283
+ }
284
+ }, [
285
+ dev,
286
+ uiCells,
287
+ options,
288
+ multiplier
289
+ ]);
290
+ usePlayerOverlayLayer(useMemo(() => editing && !unsupported && options && uiCells ? {
291
+ id: OVERLAY_ID,
292
+ order: MOTION_ZONES_OVERLAY_ORDER,
293
+ node: /* @__PURE__ */ jsx(MaskShapeCanvas, {
294
+ transparent: true,
295
+ items,
296
+ supportedShapes: ["grid"],
297
+ grid: {
298
+ width: uiWidth,
299
+ height: uiHeight
300
+ },
301
+ selectedId: GRID_REGION_ID,
302
+ onSelect: () => {},
303
+ onShapeChange,
304
+ onDrawComplete: () => {},
305
+ drawingKind: null
306
+ })
307
+ } : null, [
308
+ editing,
309
+ unsupported,
310
+ options,
311
+ uiCells,
312
+ items,
313
+ onShapeChange,
314
+ uiWidth,
315
+ uiHeight
316
+ ]));
317
+ const ready = !unsupported && options !== null && uiCells !== null;
318
+ if (!dev) return null;
319
+ return /* @__PURE__ */ jsx(WidgetPanel, {
320
+ title: "Motion Zones",
321
+ icon: /* @__PURE__ */ jsx(Grid2x2, { className: "h-3.5 w-3.5 text-foreground-subtle" }),
322
+ children: /* @__PURE__ */ jsx("div", {
323
+ className: "flex flex-col gap-3",
324
+ children: unsupported ? /* @__PURE__ */ jsx("p", {
325
+ className: `${TEXT_HINT} leading-relaxed`,
326
+ children: "This camera doesn't expose an on-board motion zones grid."
327
+ }) : !ready ? /* @__PURE__ */ jsx("p", {
328
+ className: `${TEXT_HINT} leading-relaxed`,
329
+ children: "Loading the camera's grid…"
330
+ }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("p", {
331
+ className: `${TEXT_HINT} leading-relaxed`,
332
+ children: [
333
+ "Toggle ",
334
+ /* @__PURE__ */ jsx("strong", {
335
+ className: "text-foreground",
336
+ children: "Edit grid"
337
+ }),
338
+ " to paint the region directly on the live frame, then ",
339
+ /* @__PURE__ */ jsx("strong", {
340
+ className: "text-foreground",
341
+ children: "Save"
342
+ }),
343
+ " to push the mask to the camera. Pick a bigger",
344
+ " ",
345
+ /* @__PURE__ */ jsx("strong", {
346
+ className: "text-foreground",
347
+ children: "Cell size"
348
+ }),
349
+ " for quicker, broad-stroke painting — it's resampled to the camera's native ",
350
+ options.grid.width,
351
+ "×",
352
+ options.grid.height,
353
+ " grid on save (×1 is the finest)."
354
+ ]
355
+ }), /* @__PURE__ */ jsxs("div", {
356
+ className: "flex items-center gap-2 flex-wrap",
357
+ children: [
358
+ /* @__PURE__ */ jsx("button", {
359
+ type: "button",
360
+ onClick: () => setEditing((v) => !v),
361
+ disabled: saving,
362
+ "aria-pressed": editing,
363
+ className: editing ? BTN_PRIMARY : BTN_NEUTRAL,
364
+ children: editing ? "Done editing" : "Edit grid"
365
+ }),
366
+ /* @__PURE__ */ jsx("button", {
367
+ type: "button",
368
+ onClick: () => setAll(true),
369
+ disabled: saving,
370
+ className: BTN_NEUTRAL,
371
+ children: "All on"
372
+ }),
373
+ /* @__PURE__ */ jsx("button", {
374
+ type: "button",
375
+ onClick: () => setAll(false),
376
+ disabled: saving,
377
+ className: BTN_NEUTRAL,
378
+ children: "All off"
379
+ }),
380
+ /* @__PURE__ */ jsx("button", {
381
+ type: "button",
382
+ onClick: invert,
383
+ disabled: saving,
384
+ className: BTN_NEUTRAL,
385
+ children: "Invert"
386
+ }),
387
+ /* @__PURE__ */ jsxs("div", {
388
+ className: "flex items-center gap-1 ml-1",
389
+ role: "group",
390
+ "aria-label": "Cell size",
391
+ children: [/* @__PURE__ */ jsx("span", {
392
+ className: `${TEXT_HINT} mr-0.5`,
393
+ children: "Cell size"
394
+ }), GRID_MULTIPLIERS.map((m) => /* @__PURE__ */ jsxs("button", {
395
+ type: "button",
396
+ onClick: () => changeMultiplier(m),
397
+ disabled: saving,
398
+ "aria-pressed": multiplier === m,
399
+ title: m === 1 ? `Camera grid ${cw}×${ch} (finest)` : `${coarseDim(cw, m)}×${coarseDim(ch, m)} painting grid · cells ×${m} bigger`,
400
+ className: multiplier === m ? BTN_PRIMARY : BTN_NEUTRAL,
401
+ children: ["×", m]
402
+ }, m))]
403
+ }),
404
+ /* @__PURE__ */ jsxs("span", {
405
+ className: `${TEXT_HINT} ml-1 tabular-nums`,
406
+ children: [
407
+ activeCount,
408
+ " / ",
409
+ cameraTotal,
410
+ " cells · ",
411
+ options.grid.width,
412
+ "×",
413
+ options.grid.height,
414
+ multiplier !== 1 ? ` · paint ${uiWidth}×${uiHeight}` : ""
415
+ ]
416
+ }),
417
+ /* @__PURE__ */ jsx("span", { className: "flex-1" }),
418
+ /* @__PURE__ */ jsx("button", {
419
+ type: "button",
420
+ onClick: revert,
421
+ disabled: saving || !dirty,
422
+ className: "rounded-md border border-border bg-surface px-2 py-1 text-[11px] text-foreground-subtle hover:bg-surface-hover disabled:opacity-40 transition-colors",
423
+ children: "Revert"
424
+ }),
425
+ /* @__PURE__ */ jsx("button", {
426
+ type: "button",
427
+ onClick: () => void save(),
428
+ disabled: saving || !dirty,
429
+ className: BTN_PRIMARY,
430
+ children: saving ? "Saving…" : "Save"
431
+ })
432
+ ]
433
+ })] })
434
+ })
435
+ });
436
+ }
437
+ //#endregion
438
+ export { MotionZonesSettings };