@camstack/ui-library 1.2.172 → 1.2.174

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/index.cjs CHANGED
@@ -2045,6 +2045,30 @@ var Gauge = createLucideIcon("gauge", [["path", {
2045
2045
  * This source code is licensed under the ISC license.
2046
2046
  * See the LICENSE file in the root directory of this source tree.
2047
2047
  */
2048
+ var Grid2x2 = createLucideIcon("grid-2x2", [
2049
+ ["path", {
2050
+ d: "M12 3v18",
2051
+ key: "108xh3"
2052
+ }],
2053
+ ["path", {
2054
+ d: "M3 12h18",
2055
+ key: "1i2n21"
2056
+ }],
2057
+ ["rect", {
2058
+ x: "3",
2059
+ y: "3",
2060
+ width: "18",
2061
+ height: "18",
2062
+ rx: "2",
2063
+ key: "h1oib"
2064
+ }]
2065
+ ]);
2066
+ /**
2067
+ * @license lucide-react v0.576.0 - ISC
2068
+ *
2069
+ * This source code is licensed under the ISC license.
2070
+ * See the LICENSE file in the root directory of this source tree.
2071
+ */
2048
2072
  var GripHorizontal = createLucideIcon("grip-horizontal", [
2049
2073
  ["circle", {
2050
2074
  cx: "12",
@@ -6595,6 +6619,16 @@ var usePtzDeletePreset = trpc.ptz.deletePreset.useMutation;
6595
6619
  var usePtzGetOptions = trpc.ptz.getOptions.useQuery;
6596
6620
  /** Generated alias around `trpc.ptz.goHome.useMutation`. */
6597
6621
  var usePtzGoHome = trpc.ptz.goHome.useMutation;
6622
+ /** Generated alias around `trpc.ptz.getHomePreset.useQuery`. */
6623
+ var usePtzGetHomePreset = trpc.ptz.getHomePreset.useQuery;
6624
+ /** Generated alias around `trpc.ptz.setHomePreset.useMutation`. */
6625
+ var usePtzSetHomePreset = trpc.ptz.setHomePreset.useMutation;
6626
+ /** Generated alias around `trpc.ptz.captureHomeHere.useMutation`. */
6627
+ var usePtzCaptureHomeHere = trpc.ptz.captureHomeHere.useMutation;
6628
+ /** Generated alias around `trpc.ptz.getHomeReturnSeconds.useQuery`. */
6629
+ var usePtzGetHomeReturnSeconds = trpc.ptz.getHomeReturnSeconds.useQuery;
6630
+ /** Generated alias around `trpc.ptz.setHomeReturnSeconds.useMutation`. */
6631
+ var usePtzSetHomeReturnSeconds = trpc.ptz.setHomeReturnSeconds.useMutation;
6598
6632
  /** Generated alias around `trpc.ptz.getPosition.useQuery`. */
6599
6633
  var usePtzGetPosition = trpc.ptz.getPosition.useQuery;
6600
6634
  /** Generated alias around `trpc.ptz.setAutofocus.useMutation`. */
@@ -16227,6 +16261,611 @@ function AutotrackSection({ deviceId }) {
16227
16261
  });
16228
16262
  }
16229
16263
  //#endregion
16264
+ //#region src/hooks/use-devices.ts
16265
+ /**
16266
+ * useDevices / useDevice — reactive sync-mirror reads.
16267
+ *
16268
+ * Both hooks hand out `DeviceProxy` references straight out of the
16269
+ * `System` mirror. The mirror keeps device + binding caches warm via
16270
+ * the `device.registered/unregistered/bindings-changed` event stream,
16271
+ * so re-renders are driven by lifecycle pushes — no React Query
16272
+ * round-trip and no `refetch` plumbing needed at the caller side.
16273
+ *
16274
+ * The hooks defensively read once on mount and assume the System has
16275
+ * been `init()`ed by the host app — calls before the warm-boot
16276
+ * completes simply return an empty array / null and re-render once
16277
+ * the registered listeners fire.
16278
+ */
16279
+ /**
16280
+ * Reactive list of devices matching the optional filter set. Returns a
16281
+ * stable array reference between updates so consumers passing it as a
16282
+ * prop don't tear down memoized children.
16283
+ */
16284
+ function useDevices(filters) {
16285
+ const system = useSystem();
16286
+ const filtersKey = (0, react$1.useMemo)(() => filters ? JSON.stringify(filters) : "", [filters]);
16287
+ const [devices, setDevices] = (0, react$1.useState)(() => system.listDevices(filters));
16288
+ (0, react$1.useEffect)(() => {
16289
+ const refresh = () => {
16290
+ setDevices(system.listDevices(filters));
16291
+ };
16292
+ refresh();
16293
+ let unsubAdded = null;
16294
+ let unsubRemoved = null;
16295
+ try {
16296
+ unsubAdded = system.onDeviceAdded(refresh);
16297
+ unsubRemoved = system.onDeviceRemoved(refresh);
16298
+ } catch {}
16299
+ const unsubConn = system.subscribeConnectionEvents((state) => {
16300
+ if (state === "connected") refresh();
16301
+ });
16302
+ return () => {
16303
+ unsubAdded?.();
16304
+ unsubRemoved?.();
16305
+ unsubConn();
16306
+ };
16307
+ }, [
16308
+ system,
16309
+ filters,
16310
+ filtersKey
16311
+ ]);
16312
+ return devices;
16313
+ }
16314
+ /**
16315
+ * Single-device variant. Returns `null` until the device is in the
16316
+ * mirror; re-renders when the device is removed (back to `null`) or
16317
+ * added.
16318
+ */
16319
+ function useDevice(deviceId) {
16320
+ const system = useSystem();
16321
+ const [device, setDevice] = (0, react$1.useState)(() => deviceId === null ? null : system.getDevice(deviceId));
16322
+ (0, react$1.useEffect)(() => {
16323
+ if (deviceId === null) {
16324
+ setDevice(null);
16325
+ return;
16326
+ }
16327
+ const refresh = () => {
16328
+ setDevice(system.getDevice(deviceId));
16329
+ };
16330
+ refresh();
16331
+ let unsubAdded = null;
16332
+ let unsubRemoved = null;
16333
+ try {
16334
+ unsubAdded = system.onDeviceAdded(refresh);
16335
+ unsubRemoved = system.onDeviceRemoved(refresh);
16336
+ } catch {}
16337
+ const unsubConn = system.subscribeConnectionEvents((state) => {
16338
+ if (state === "connected") refresh();
16339
+ });
16340
+ return () => {
16341
+ unsubAdded?.();
16342
+ unsubRemoved?.();
16343
+ unsubConn();
16344
+ };
16345
+ }, [system, deviceId]);
16346
+ return device;
16347
+ }
16348
+ //#endregion
16349
+ //#region src/hooks/use-device-snapshot.ts
16350
+ /**
16351
+ * useDeviceSnapshotImage — a device's snapshot as a plain image URL.
16352
+ *
16353
+ * ## What this used to do, and why it stopped
16354
+ *
16355
+ * It fetched `dev.snapshot.getSnapshot()` over tRPC and returned
16356
+ * `data:image/jpeg;base64,…`. That put a full camera capture — up to 4 K,
16357
+ * measured at 758 KB average per camera on the live hub — through the msgpack
16358
+ * WebSocket plane, base64-inflated by a third, and then held it in the
16359
+ * react-query heap for 24 h per device. The device list rendered it into a
16360
+ * **28 × 28 px `<img>`**.
16361
+ *
16362
+ * It also did not do what it appeared to do. `snapshot.getSnapshot` scoped to a
16363
+ * device routes to the NATIVE provider, not the snapshot wrapper, so it bypassed
16364
+ * the wrapper's cache, coalescing and stale-while-revalidate entirely: probed
16365
+ * live, a `force:true` call returned a fresh 1.05 MB JPEG in 0.55 s while the
16366
+ * wrapper's `lastCapturedAt` for that camera did not move. Two snapshot paths,
16367
+ * different behaviour, and only one of them maintained the cache the other one
16368
+ * reported on.
16369
+ *
16370
+ * ## What it does now
16371
+ *
16372
+ * Returns the same URL the viewer uses: `/addon/snapshot/media/<id>.jpg?w=<n>`,
16373
+ * served by the wrapper over the HTTP data plane. Relative and same-origin, so
16374
+ * the admin UI's `camstack_session` cookie authenticates it — the endpoint is
16375
+ * `access: 'authenticated'`, which accepts either a Bearer header or that
16376
+ * cookie, and a bare `<img>` can only send the cookie. This is the same
16377
+ * mechanism the native `<video>` fallback in `hls-video.tsx` already relies on.
16378
+ *
16379
+ * Caching is now the browser's, which is the point: `ETag` +
16380
+ * `Cache-Control: private, max-age=<device maxAgeS>` means a repeat render is a
16381
+ * cache hit and a revalidation is a 0-byte 304. No JS heap, no msgpack, no
16382
+ * base64. Nothing to configure — unlike `expo-image` on native, browsers
16383
+ * revalidate a stale URL rather than treating it as immutable, so the URL stays
16384
+ * stable and does NOT need a version parameter.
16385
+ */
16386
+ /** The hub-proxied path prefix for the snapshot image data plane, composed
16387
+ * below on `baseUrl` or, for the same-origin case, on the page's mount. */
16388
+ var SNAPSHOT_MEDIA_PATH = "/addon/snapshot/media";
16389
+ function useDeviceSnapshotImage(deviceId, options = {}) {
16390
+ const { width, baseUrl } = options;
16391
+ const [refreshNonce, setRefreshNonce] = (0, react$1.useState)(0);
16392
+ return {
16393
+ src: (0, react$1.useMemo)(() => {
16394
+ if (deviceId === null || deviceId <= 0) return null;
16395
+ const base = baseUrl !== void 0 ? baseUrl.replace(/\/+$/, "") : routerBasename();
16396
+ const params = new URLSearchParams();
16397
+ if (width !== void 0 && Number.isInteger(width) && width > 0) params.set("w", String(width));
16398
+ if (refreshNonce > 0) {
16399
+ params.set("force", "1");
16400
+ params.set("r", String(refreshNonce));
16401
+ }
16402
+ const query = params.toString();
16403
+ return `${base}${SNAPSHOT_MEDIA_PATH}/${String(deviceId)}.jpg${query.length > 0 ? `?${query}` : ""}`;
16404
+ }, [
16405
+ deviceId,
16406
+ width,
16407
+ baseUrl,
16408
+ refreshNonce
16409
+ ]),
16410
+ loading: false,
16411
+ refresh: (0, react$1.useCallback)(() => {
16412
+ setRefreshNonce((n) => n + 1);
16413
+ }, [])
16414
+ };
16415
+ }
16416
+ //#endregion
16417
+ //#region src/composites/cap-settings/camera-grid-layout.ts
16418
+ /** Smallest rectangle the editor will produce — ffmpeg refuses a zero-area crop. */
16419
+ var MIN_EXTENT = .02;
16420
+ function isRecord(value) {
16421
+ return value !== null && typeof value === "object" && !Array.isArray(value);
16422
+ }
16423
+ function readRect(value) {
16424
+ if (!isRecord(value)) return null;
16425
+ const { x, y, width, height } = value;
16426
+ if (typeof x !== "number" || typeof y !== "number" || typeof width !== "number" || typeof height !== "number") return null;
16427
+ if (x < 0 || y < 0 || width <= 0 || height <= 0) return null;
16428
+ if (x + width > 1 || y + height > 1) return null;
16429
+ return {
16430
+ x,
16431
+ y,
16432
+ width,
16433
+ height
16434
+ };
16435
+ }
16436
+ function readCell(value) {
16437
+ if (!isRecord(value)) return null;
16438
+ const deviceId = value["deviceId"];
16439
+ if (typeof deviceId !== "number" || !Number.isInteger(deviceId)) return null;
16440
+ const source = readRect(value["source"]);
16441
+ const cell = readRect(value["cell"]);
16442
+ if (source === null || cell === null) return null;
16443
+ return {
16444
+ deviceId,
16445
+ source,
16446
+ cell
16447
+ };
16448
+ }
16449
+ /** Parse the `getGridForDevice` reply. See {@link GridLayoutReadOutcome}. */
16450
+ function parseGridView(raw) {
16451
+ if (!isRecord(raw) || !("grid" in raw)) return "unreadable";
16452
+ const grid = raw["grid"];
16453
+ if (grid === null) return null;
16454
+ if (!isRecord(grid)) return "unreadable";
16455
+ const { instanceId, deviceId, name, width, height, fps, cells } = grid;
16456
+ if (typeof instanceId !== "string" || typeof deviceId !== "number" || typeof name !== "string" || typeof width !== "number" || typeof height !== "number" || typeof fps !== "number" || !Array.isArray(cells)) return "unreadable";
16457
+ const parsed = [];
16458
+ for (const entry of cells) {
16459
+ const cell = readCell(entry);
16460
+ if (cell === null) return "unreadable";
16461
+ parsed.push(cell);
16462
+ }
16463
+ return {
16464
+ instanceId,
16465
+ deviceId,
16466
+ name,
16467
+ width,
16468
+ height,
16469
+ fps,
16470
+ cells: parsed
16471
+ };
16472
+ }
16473
+ /**
16474
+ * Keep a rectangle inside the unit square.
16475
+ *
16476
+ * The addon THROWS on a rectangle that falls outside — a silently clipped
16477
+ * picture is worse than a refusal — so the editor must never hand it one.
16478
+ */
16479
+ function clampNormalizedRect(rect) {
16480
+ const width = Math.min(1, Math.max(MIN_EXTENT, rect.width));
16481
+ const height = Math.min(1, Math.max(MIN_EXTENT, rect.height));
16482
+ return {
16483
+ x: Math.min(1 - width, Math.max(0, rect.x)),
16484
+ y: Math.min(1 - height, Math.max(0, rect.y)),
16485
+ width,
16486
+ height
16487
+ };
16488
+ }
16489
+ /** Drag: translate by a normalized delta, clamped. */
16490
+ function moveNormalizedRect(rect, dx, dy) {
16491
+ return clampNormalizedRect({
16492
+ ...rect,
16493
+ x: rect.x + dx,
16494
+ y: rect.y + dy
16495
+ });
16496
+ }
16497
+ /** Resize from the bottom-right corner: the origin stays put. */
16498
+ function resizeNormalizedRect(rect, dw, dh) {
16499
+ return clampNormalizedRect({
16500
+ ...rect,
16501
+ width: rect.width + dw,
16502
+ height: rect.height + dh
16503
+ });
16504
+ }
16505
+ //#endregion
16506
+ //#region src/composites/cap-settings/CameraGridLayoutEditor.tsx
16507
+ /**
16508
+ * The camera-grid layout editor.
16509
+ *
16510
+ * A grid's geometry is the one thing a DERIVED settings form cannot express: a
16511
+ * generated form renders "cell 1 source = 592, cell 1 crop x = 0.25, cell 1 crop
16512
+ * width = 0.5…" and nobody can hold a picture in their head from that. So the
16513
+ * geometry gets a widget — the canvas, drawn at the grid's own aspect ratio,
16514
+ * with the cells on it — while the knobs that ARE ordinary fields (the name,
16515
+ * the canvas size, the frame rate) sit beside it in the same panel.
16516
+ *
16517
+ * Every rectangle in here is NORMALIZED [0,1], on both sides: the crop taken
16518
+ * out of a source, and the cell it lands in. That is how the addon stores them
16519
+ * (a source camera can change resolution, and a stored pixel rectangle would
16520
+ * quietly start cutting the wrong region), so the editor speaks the same
16521
+ * language rather than converting at the boundary.
16522
+ *
16523
+ * The preview is the source cameras' own snapshots, positioned by CSS from the
16524
+ * SAME normalized numbers that will become the ffmpeg `crop`/`overlay`. It costs
16525
+ * the server nothing and it is wrong in exactly the ways the real composition
16526
+ * would be wrong.
16527
+ */
16528
+ var ADDON_ID = "camera-grid";
16529
+ var FULL_FRAME = {
16530
+ x: 0,
16531
+ y: 0,
16532
+ width: 1,
16533
+ height: 1
16534
+ };
16535
+ var MAX_CELLS = 16;
16536
+ /** Hoisted so `useDevices` does not re-subscribe on every render. */
16537
+ var CAMERA_FILTER = { type: _camstack_types.DeviceType.Camera };
16538
+ function CameraGridLayoutEditor({ deviceId }) {
16539
+ const system = useSystem();
16540
+ const cameras = (0, react$1.useMemo)(() => system.listDeviceInfos(CAMERA_FILTER), [system, useDevices(CAMERA_FILTER)]);
16541
+ const [loaded, setLoaded] = (0, react$1.useState)(void 0);
16542
+ const [draft, setDraft] = (0, react$1.useState)(null);
16543
+ const [saving, setSaving] = (0, react$1.useState)(false);
16544
+ const [saveError, setSaveError] = (0, react$1.useState)(null);
16545
+ const [selected, setSelected] = (0, react$1.useState)(0);
16546
+ const [drag, setDrag] = (0, react$1.useState)(null);
16547
+ const reload = (0, react$1.useCallback)(async () => {
16548
+ const outcome = parseGridView(await system.trpcClient.addons.custom.mutate({
16549
+ addonId: ADDON_ID,
16550
+ action: "getGridForDevice",
16551
+ input: { deviceId }
16552
+ }));
16553
+ setLoaded(outcome);
16554
+ setDraft(outcome === null || outcome === "unreadable" ? null : outcome);
16555
+ }, [system, deviceId]);
16556
+ (0, react$1.useEffect)(() => {
16557
+ reload().catch(() => {
16558
+ setLoaded("unreadable");
16559
+ });
16560
+ }, [reload]);
16561
+ const save = (0, react$1.useCallback)(async () => {
16562
+ if (draft === null) return;
16563
+ setSaving(true);
16564
+ setSaveError(null);
16565
+ try {
16566
+ const outcome = parseGridView(await system.trpcClient.addons.custom.mutate({
16567
+ addonId: ADDON_ID,
16568
+ action: "saveGridLayout",
16569
+ input: {
16570
+ deviceId,
16571
+ name: draft.name,
16572
+ width: draft.width,
16573
+ height: draft.height,
16574
+ fps: draft.fps,
16575
+ cells: draft.cells.map((cell) => ({ ...cell }))
16576
+ }
16577
+ }));
16578
+ if (outcome !== null && outcome !== "unreadable") {
16579
+ setLoaded(outcome);
16580
+ setDraft(outcome);
16581
+ }
16582
+ } catch (error) {
16583
+ setSaveError(error instanceof Error ? error.message : String(error));
16584
+ } finally {
16585
+ setSaving(false);
16586
+ }
16587
+ }, [
16588
+ system,
16589
+ deviceId,
16590
+ draft
16591
+ ]);
16592
+ const patchCell = (0, react$1.useCallback)((index, patch) => {
16593
+ setDraft((prev) => prev === null ? prev : {
16594
+ ...prev,
16595
+ cells: prev.cells.map((cell, i) => i === index ? {
16596
+ ...cell,
16597
+ ...patch
16598
+ } : cell)
16599
+ });
16600
+ }, []);
16601
+ const canvasAspect = (0, react$1.useMemo)(() => draft === null ? 16 / 9 : draft.width / draft.height, [draft]);
16602
+ if (loaded === void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidgetPanel, {
16603
+ title: "Grid layout",
16604
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Grid2x2, { className: "h-3.5 w-3.5" }),
16605
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16606
+ className: "flex items-center gap-2 text-[10.5px] text-foreground-subtle",
16607
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3 w-3 animate-spin" }), " Loading the layout…"]
16608
+ })
16609
+ });
16610
+ if (loaded === "unreadable") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidgetPanel, {
16611
+ title: "Grid layout",
16612
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Grid2x2, { className: "h-3.5 w-3.5" }),
16613
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16614
+ className: "flex items-center gap-2 text-[10.5px] text-danger",
16615
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(CircleAlert, { className: "h-3 w-3" }), "The Camera Grids addon did not answer, so this layout is unknown — not empty."]
16616
+ })
16617
+ });
16618
+ if (loaded === null || draft === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidgetPanel, {
16619
+ title: "Grid layout",
16620
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Grid2x2, { className: "h-3.5 w-3.5" }),
16621
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
16622
+ className: "text-[10.5px] text-foreground-subtle",
16623
+ children: "This camera is not a grid."
16624
+ })
16625
+ });
16626
+ const onPointerMove = (event) => {
16627
+ if (drag === null) return;
16628
+ const bounds = event.currentTarget.getBoundingClientRect();
16629
+ if (bounds.width === 0 || bounds.height === 0) return;
16630
+ const dx = (event.clientX - drag.startX) / bounds.width;
16631
+ const dy = (event.clientY - drag.startY) / bounds.height;
16632
+ patchCell(drag.index, { cell: drag.mode === "move" ? moveNormalizedRect(drag.origin, dx, dy) : resizeNormalizedRect(drag.origin, dx, dy) });
16633
+ };
16634
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidgetPanel, {
16635
+ title: "Grid layout",
16636
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Grid2x2, { className: "h-3.5 w-3.5" }),
16637
+ headerRight: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
16638
+ type: "button",
16639
+ disabled: saving,
16640
+ onClick: () => void save(),
16641
+ className: "flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[10px] uppercase",
16642
+ children: [saving ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3 w-3 animate-spin" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Save, { className: "h-3 w-3" }), "Save"]
16643
+ }),
16644
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16645
+ className: "flex flex-col gap-3",
16646
+ children: [
16647
+ saveError !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16648
+ className: "flex items-center gap-2 text-[10.5px] text-danger",
16649
+ children: [
16650
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CircleAlert, { className: "h-3 w-3" }),
16651
+ " ",
16652
+ saveError
16653
+ ]
16654
+ }),
16655
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16656
+ className: "relative w-full select-none overflow-hidden rounded border border-border bg-black",
16657
+ style: { aspectRatio: String(canvasAspect) },
16658
+ onPointerMove,
16659
+ onPointerUp: () => {
16660
+ setDrag(null);
16661
+ },
16662
+ onPointerLeave: () => {
16663
+ setDrag(null);
16664
+ },
16665
+ children: [draft.cells.map((cell, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GridCellBox, {
16666
+ cell,
16667
+ selected: index === selected,
16668
+ onSelect: () => {
16669
+ setSelected(index);
16670
+ },
16671
+ onDragStart: (mode, event) => {
16672
+ setSelected(index);
16673
+ setDrag({
16674
+ index,
16675
+ mode,
16676
+ startX: event.clientX,
16677
+ startY: event.clientY,
16678
+ origin: cell.cell
16679
+ });
16680
+ }
16681
+ }, `${String(cell.deviceId)}-${String(index)}`)), draft.cells.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
16682
+ className: "absolute inset-0 flex items-center justify-center text-[10.5px] text-foreground-subtle",
16683
+ children: "No cells yet — add one below."
16684
+ })]
16685
+ }),
16686
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16687
+ className: "grid grid-cols-4 gap-2 text-[10.5px]",
16688
+ children: [
16689
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LabelledNumber, {
16690
+ label: "Width",
16691
+ value: draft.width,
16692
+ onChange: (width) => {
16693
+ setDraft({
16694
+ ...draft,
16695
+ width
16696
+ });
16697
+ }
16698
+ }),
16699
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LabelledNumber, {
16700
+ label: "Height",
16701
+ value: draft.height,
16702
+ onChange: (height) => {
16703
+ setDraft({
16704
+ ...draft,
16705
+ height
16706
+ });
16707
+ }
16708
+ }),
16709
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LabelledNumber, {
16710
+ label: "FPS",
16711
+ value: draft.fps,
16712
+ onChange: (fps) => {
16713
+ setDraft({
16714
+ ...draft,
16715
+ fps
16716
+ });
16717
+ }
16718
+ }),
16719
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
16720
+ type: "button",
16721
+ disabled: draft.cells.length >= MAX_CELLS,
16722
+ onClick: () => {
16723
+ const first = cameras.find((camera) => camera.id !== deviceId);
16724
+ if (!first) return;
16725
+ setDraft({
16726
+ ...draft,
16727
+ cells: [...draft.cells, {
16728
+ deviceId: first.id,
16729
+ source: FULL_FRAME,
16730
+ cell: clampNormalizedRect({
16731
+ x: .1,
16732
+ y: .1,
16733
+ width: .4,
16734
+ height: .4
16735
+ })
16736
+ }]
16737
+ });
16738
+ setSelected(draft.cells.length);
16739
+ },
16740
+ className: "flex items-center justify-center gap-1 self-end rounded border border-border px-2 py-1 uppercase",
16741
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Plus, { className: "h-3 w-3" }), " Cell"]
16742
+ })
16743
+ ]
16744
+ }),
16745
+ draft.cells[selected] !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CellInspector, {
16746
+ cell: draft.cells[selected],
16747
+ index: selected,
16748
+ cameras: cameras.filter((camera) => camera.id !== deviceId),
16749
+ onChange: (patch) => {
16750
+ patchCell(selected, patch);
16751
+ },
16752
+ onRemove: () => {
16753
+ setDraft({
16754
+ ...draft,
16755
+ cells: draft.cells.filter((_, i) => i !== selected)
16756
+ });
16757
+ setSelected(0);
16758
+ }
16759
+ })
16760
+ ]
16761
+ })
16762
+ });
16763
+ }
16764
+ function GridCellBox({ cell, selected, onSelect, onDragStart }) {
16765
+ const { src } = useDeviceSnapshotImage(cell.deviceId, { width: 640 });
16766
+ const backgroundSize = `${String(100 / cell.source.width)}% ${String(100 / cell.source.height)}%`;
16767
+ const backgroundPosition = cell.source.width >= 1 && cell.source.height >= 1 ? "center" : `${String(cell.source.x / Math.max(1e-6, 1 - cell.source.width) * 100)}% ${String(cell.source.y / Math.max(1e-6, 1 - cell.source.height) * 100)}%`;
16768
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
16769
+ role: "presentation",
16770
+ onPointerDown: (event) => {
16771
+ onSelect();
16772
+ onDragStart("move", event);
16773
+ },
16774
+ className: `absolute cursor-move border ${selected ? "border-accent" : "border-border/60"}`,
16775
+ style: {
16776
+ left: `${String(cell.cell.x * 100)}%`,
16777
+ top: `${String(cell.cell.y * 100)}%`,
16778
+ width: `${String(cell.cell.width * 100)}%`,
16779
+ height: `${String(cell.cell.height * 100)}%`,
16780
+ backgroundImage: src === null ? void 0 : `url(${src})`,
16781
+ backgroundSize,
16782
+ backgroundPosition,
16783
+ backgroundRepeat: "no-repeat"
16784
+ },
16785
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
16786
+ role: "presentation",
16787
+ onPointerDown: (event) => {
16788
+ event.stopPropagation();
16789
+ onDragStart("resize", event);
16790
+ },
16791
+ className: "absolute -bottom-1 -right-1 h-2.5 w-2.5 cursor-se-resize bg-accent"
16792
+ })
16793
+ });
16794
+ }
16795
+ function CellInspector({ cell, index, cameras, onChange, onRemove }) {
16796
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16797
+ className: "flex flex-col gap-2 rounded border border-border p-2 text-[10.5px]",
16798
+ children: [
16799
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16800
+ className: "flex items-center justify-between",
16801
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
16802
+ className: "uppercase text-foreground-subtle",
16803
+ children: ["Cell ", index + 1]
16804
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
16805
+ type: "button",
16806
+ onClick: onRemove,
16807
+ className: "text-danger",
16808
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Trash2, { className: "h-3 w-3" })
16809
+ })]
16810
+ }),
16811
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
16812
+ className: "flex items-center gap-2",
16813
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16814
+ className: "w-16 text-foreground-subtle",
16815
+ children: "Source"
16816
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
16817
+ value: String(cell.deviceId),
16818
+ onChange: (event) => {
16819
+ onChange({ deviceId: Number(event.target.value) });
16820
+ },
16821
+ className: "flex-1 rounded border border-border bg-transparent px-1 py-0.5",
16822
+ children: cameras.map((camera) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
16823
+ value: String(camera.id),
16824
+ children: camera.name
16825
+ }, camera.id))
16826
+ })]
16827
+ }),
16828
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
16829
+ className: "grid grid-cols-4 gap-2",
16830
+ children: [
16831
+ "x",
16832
+ "y",
16833
+ "width",
16834
+ "height"
16835
+ ].map((key) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LabelledNumber, {
16836
+ label: `Crop ${key}`,
16837
+ value: Math.round(cell.source[key] * 100) / 100,
16838
+ step: .01,
16839
+ onChange: (next) => {
16840
+ onChange({ source: clampNormalizedRect({
16841
+ ...cell.source,
16842
+ [key]: next
16843
+ }) });
16844
+ }
16845
+ }, key))
16846
+ })
16847
+ ]
16848
+ });
16849
+ }
16850
+ function LabelledNumber({ label, value, step = 1, onChange }) {
16851
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
16852
+ className: "flex flex-col gap-0.5",
16853
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16854
+ className: "text-foreground-subtle",
16855
+ children: label
16856
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
16857
+ type: "number",
16858
+ step,
16859
+ value,
16860
+ onChange: (event) => {
16861
+ const next = Number(event.target.value);
16862
+ if (Number.isFinite(next)) onChange(next);
16863
+ },
16864
+ className: "rounded border border-border bg-transparent px-1 py-0.5"
16865
+ })]
16866
+ });
16867
+ }
16868
+ //#endregion
16230
16869
  //#region src/hooks/use-device-proxy.ts
16231
16870
  /**
16232
16871
  * useDeviceProxy — canonical React entry point for a device.
@@ -16945,7 +17584,13 @@ function isAbsentProvider(err) {
16945
17584
  * continuous motion (`cap.continuousMove` + `cap.stop`). Use for
16946
17585
  * long-press handlers on touch / mouse-down handlers on desktop.
16947
17586
  * - `zoom('in' | 'out')`: discrete zoom step.
16948
- * - `goHome()`: jump to preset 0.
17587
+ * - `goHome()`: jump to the camera's configured home. It used to be
17588
+ * "jump to preset 0" — three providers hardcoded a vendor convention that
17589
+ * none of this fleet's cameras honours — so it now REFUSES when nothing is
17590
+ * configured rather than moving the head to an arbitrary slot.
17591
+ * - `homePreset` + `setHomePreset(presetId)`: which preset home points at,
17592
+ * and who decided. `source: 'native'` means the camera homes without a
17593
+ * preset (ONVIF) and there is nothing to pick.
16949
17594
  * - `presets` + `goToPreset(presetId)`: list and jump to named presets.
16950
17595
  *
16951
17596
  * Parametrised by the same `UseDeviceProxyTrpc` shape every other
@@ -16992,6 +17637,8 @@ function usePTZ(trpc, deviceId, hookOptions) {
16992
17637
  const enabled = hookOptions?.enabled ?? true;
16993
17638
  const ptz = useDeviceProxy(trpc, enabled ? deviceId : null)?.ptz;
16994
17639
  const [presets, setPresets] = (0, react$1.useState)([]);
17640
+ const [homePreset, setHomePresetState] = (0, react$1.useState)(null);
17641
+ const [homeReturnSeconds, setHomeReturnState] = (0, react$1.useState)(null);
16995
17642
  const [options, setOptions] = (0, react$1.useState)(null);
16996
17643
  const [busy, setBusy] = (0, react$1.useState)(false);
16997
17644
  const [error, setError] = (0, react$1.useState)(null);
@@ -17004,6 +17651,24 @@ function usePTZ(trpc, deviceId, hookOptions) {
17004
17651
  setError(err instanceof Error ? err.message : String(err));
17005
17652
  }
17006
17653
  }, [ptz, enabled]);
17654
+ const refreshHomePreset = (0, react$1.useCallback)(async () => {
17655
+ if (!enabled || !ptz) return;
17656
+ try {
17657
+ setHomePresetState(await ptz.getHomePreset({}));
17658
+ } catch (err) {
17659
+ if (isAbsentProvider(err)) return;
17660
+ setError(err instanceof Error ? err.message : String(err));
17661
+ }
17662
+ }, [ptz, enabled]);
17663
+ const refreshHomeReturn = (0, react$1.useCallback)(async () => {
17664
+ if (!enabled || !ptz) return;
17665
+ try {
17666
+ setHomeReturnState(await ptz.getHomeReturnSeconds({}));
17667
+ } catch (err) {
17668
+ if (isAbsentProvider(err)) return;
17669
+ setHomeReturnState(null);
17670
+ }
17671
+ }, [ptz, enabled]);
17007
17672
  const refreshOptions = (0, react$1.useCallback)(async () => {
17008
17673
  if (!enabled || !ptz) return;
17009
17674
  try {
@@ -17016,7 +17681,14 @@ function usePTZ(trpc, deviceId, hookOptions) {
17016
17681
  (0, react$1.useEffect)(() => {
17017
17682
  refreshPresets();
17018
17683
  refreshOptions();
17019
- }, [refreshPresets, refreshOptions]);
17684
+ refreshHomePreset();
17685
+ refreshHomeReturn();
17686
+ }, [
17687
+ refreshPresets,
17688
+ refreshOptions,
17689
+ refreshHomePreset,
17690
+ refreshHomeReturn
17691
+ ]);
17020
17692
  const wrap = (0, react$1.useCallback)(async (fn) => {
17021
17693
  if (!enabled || !ptz) return void 0;
17022
17694
  setError(null);
@@ -17090,6 +17762,35 @@ function usePTZ(trpc, deviceId, hookOptions) {
17090
17762
  if (!ptz) return;
17091
17763
  await wrap(() => ptz.goHome({}));
17092
17764
  }, [ptz, wrap]),
17765
+ homePreset,
17766
+ setHomePreset: (0, react$1.useCallback)(async (presetId) => {
17767
+ if (!ptz) return;
17768
+ await wrap(() => ptz.setHomePreset({ presetId }));
17769
+ await refreshHomePreset();
17770
+ }, [
17771
+ ptz,
17772
+ wrap,
17773
+ refreshHomePreset
17774
+ ]),
17775
+ captureHomeHere: (0, react$1.useCallback)(async () => {
17776
+ if (!ptz) return;
17777
+ await wrap(() => ptz.captureHomeHere({}));
17778
+ await refreshHomePreset();
17779
+ }, [
17780
+ ptz,
17781
+ wrap,
17782
+ refreshHomePreset
17783
+ ]),
17784
+ homeReturnSeconds,
17785
+ setHomeReturnSeconds: (0, react$1.useCallback)(async (seconds) => {
17786
+ if (!ptz) return;
17787
+ await wrap(() => ptz.setHomeReturnSeconds({ seconds }));
17788
+ await refreshHomeReturn();
17789
+ }, [
17790
+ ptz,
17791
+ wrap,
17792
+ refreshHomeReturn
17793
+ ]),
17093
17794
  goToPreset: (0, react$1.useCallback)(async (presetId) => {
17094
17795
  if (!ptz) return;
17095
17796
  await wrap(() => ptz.goToPreset({ presetId }));
@@ -17149,6 +17850,109 @@ function usePTZ(trpc, deviceId, hookOptions) {
17149
17850
  };
17150
17851
  }
17151
17852
  //#endregion
17853
+ //#region src/composites/cap-settings/HomePresetPicker.tsx
17854
+ /**
17855
+ * Where "Home" sends this camera, and how it is set.
17856
+ *
17857
+ * ## Why this exists at all
17858
+ *
17859
+ * The vendor "convention" was wrong on every camera of this fleet. Three
17860
+ * providers each hardcoded a slot — reolink 0, hikvision '1', amcrest 1 — and
17861
+ * measured on 2026-09-17 those slots held "stanza", "Credenza" and "Preset1":
17862
+ * the operator's own presets, not a home. Two of the three swallowed the
17863
+ * failure, so a head that never moved looked exactly like one that went home.
17864
+ *
17865
+ * ## The mistake this component made first
17866
+ *
17867
+ * Its first version rendered NOTHING for `source: 'native'`, with a comment
17868
+ * saying "the camera homes natively; there is no preset to choose". That is
17869
+ * true for ONVIF, whose home is the fixed PTZ origin. It was written hours
17870
+ * before we learned that Reolink's native home is a GUARD POINT — a stored
17871
+ * position, settable, with its own idle-return timer — so the control was
17872
+ * hidden from the one vendor that most needed it. A comment that is right about
17873
+ * one case and read as covering all of them is exactly the leftover that reads
17874
+ * as verification.
17875
+ *
17876
+ * ## What each state offers now
17877
+ *
17878
+ * `native` — no preset to pick, but "Set home here" and, where the camera
17879
+ * has one, the idle-return delay.
17880
+ * `firmware` — the camera named its own home (Hikvision's "Back to origin").
17881
+ * Preselected; the operator may override.
17882
+ * `operator` — pinned here.
17883
+ * `none` — Home REFUSES, and the UI says so. A disabled feature must
17884
+ * never look like a broken one (D62).
17885
+ */
17886
+ var SELECT_CLASS$2 = "rounded-md border border-border bg-surface px-2 py-1 text-[11px] text-foreground focus:border-primary focus:outline-none disabled:opacity-50";
17887
+ function HomePresetPicker({ controls }) {
17888
+ const { homePreset, presets, setHomePreset, captureHomeHere, homeReturnSeconds, setHomeReturnSeconds, busy } = controls;
17889
+ const [saving, setSaving] = (0, react$1.useState)(false);
17890
+ if (homePreset === null) return null;
17891
+ const disabled = busy || saving;
17892
+ const run = (action) => {
17893
+ setSaving(true);
17894
+ action().finally(() => setSaving(false));
17895
+ };
17896
+ const explanation = homePreset.source === "native" ? "This camera stores its own home position. “Set home here” pins it to where the camera is pointing now." : homePreset.source === "firmware" ? "Taken from the camera, which names this preset as its home." : homePreset.source === "none" ? "Nothing is set, so Home refuses rather than moving to an arbitrary preset." : "Pinned here. Clear it to fall back to the camera’s own home.";
17897
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17898
+ className: "flex flex-col gap-2 px-2 pt-2",
17899
+ children: [
17900
+ homePreset.source !== "native" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
17901
+ className: "flex flex-col gap-1",
17902
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17903
+ className: "text-[10px] font-medium text-foreground-subtle",
17904
+ children: "Home preset"
17905
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
17906
+ value: homePreset.presetId ?? "",
17907
+ disabled,
17908
+ onChange: (e) => {
17909
+ const next = e.target.value === "" ? null : e.target.value;
17910
+ run(() => setHomePreset(next));
17911
+ },
17912
+ className: SELECT_CLASS$2,
17913
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
17914
+ value: "",
17915
+ children: "Not set — Home is refused"
17916
+ }), presets.map((preset) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
17917
+ value: preset.id,
17918
+ children: [preset.name || preset.id, preset.fn === "home" ? " (camera’s own home)" : ""]
17919
+ }, preset.id))]
17920
+ })]
17921
+ }),
17922
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
17923
+ type: "button",
17924
+ disabled,
17925
+ onClick: () => run(() => captureHomeHere()),
17926
+ className: "self-start rounded-md border border-border px-2 py-1 text-[11px] text-foreground hover:bg-primary/10 disabled:opacity-50",
17927
+ children: "Set home here"
17928
+ }),
17929
+ homeReturnSeconds !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
17930
+ className: "flex flex-col gap-1",
17931
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17932
+ className: "text-[10px] font-medium text-foreground-subtle",
17933
+ children: "Return home after (s)"
17934
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
17935
+ type: "number",
17936
+ min: 0,
17937
+ max: 3600,
17938
+ defaultValue: homeReturnSeconds,
17939
+ disabled,
17940
+ onBlur: (e) => {
17941
+ const next = Number(e.target.value);
17942
+ if (!Number.isFinite(next) || next === homeReturnSeconds) return;
17943
+ run(() => setHomeReturnSeconds(Math.round(next)));
17944
+ },
17945
+ className: SELECT_CLASS$2
17946
+ })]
17947
+ }),
17948
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17949
+ className: "text-[10px] text-foreground-subtle",
17950
+ children: explanation
17951
+ })
17952
+ ]
17953
+ });
17954
+ }
17955
+ //#endregion
17152
17956
  //#region src/composites/ptz-overlay.tsx
17153
17957
  /**
17154
17958
  * PTZOverlay — pan / tilt / zoom controls.
@@ -17224,7 +18028,7 @@ function DPadButton({ direction, icon: Icon, disabled, className, variant, onMov
17224
18028
  });
17225
18029
  }
17226
18030
  function PTZOverlay({ controls, mode = "overlay", showPresets, showZoom = true, showHome = true, className }) {
17227
- const { move, startContinuous, stopContinuous, zoom, goHome, goToPreset, savePreset, updatePreset, deletePreset, presets, options, busy, error } = controls;
18031
+ const { move, startContinuous, stopContinuous, zoom, goHome, homePreset, goToPreset, savePreset, updatePreset, deletePreset, presets, options, busy, error } = controls;
17228
18032
  const confirm = useConfirm();
17229
18033
  const [presetsOpen, setPresetsOpen] = (0, react$1.useState)(false);
17230
18034
  const [presetName, setPresetName] = (0, react$1.useState)("");
@@ -17392,8 +18196,8 @@ function PTZOverlay({ controls, mode = "overlay", showPresets, showZoom = true,
17392
18196
  })] }), showHome && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
17393
18197
  type: "button",
17394
18198
  onClick: () => goHome(),
17395
- disabled: busy,
17396
- title: "Go home",
18199
+ disabled: busy || homePreset?.source === "none",
18200
+ title: homePreset?.source === "none" ? "No home preset set for this camera — pick one in PTZ settings" : "Go home",
17397
18201
  className: cn("flex items-center justify-center rounded disabled:opacity-40", sideButtonSize, sideButtonHover),
17398
18202
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(House, { className: sideIconSize })
17399
18203
  })]
@@ -17433,7 +18237,7 @@ function PTZOverlay({ controls, mode = "overlay", showPresets, showZoom = true,
17433
18237
  disabled: busy,
17434
18238
  className: cn("flex-1 px-3 py-1.5 text-left text-[10px] disabled:opacity-40", isPanel ? "text-foreground" : "text-white"),
17435
18239
  children: p.name || p.id
17436
- }), presetManagementVisible && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18240
+ }), presetManagementVisible && p.writable && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
17437
18241
  type: "button",
17438
18242
  onClick: () => void handleUpdatePreset(p.id, p.name || p.id),
17439
18243
  disabled: busy,
@@ -17569,10 +18373,14 @@ function PtzPanel({ deviceId }) {
17569
18373
  title: "PTZ",
17570
18374
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Move, { className: "h-3.5 w-3.5 text-foreground-subtle" }),
17571
18375
  bodyClassName: "p-3 flex flex-col overflow-y-auto",
17572
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(PTZOverlay, {
17573
- controls,
17574
- mode: "panel"
17575
- }), hasAutofocus && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AutofocusToggle, { deviceId })]
18376
+ children: [
18377
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PTZOverlay, {
18378
+ controls,
18379
+ mode: "panel"
18380
+ }),
18381
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HomePresetPicker, { controls }),
18382
+ hasAutofocus && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AutofocusToggle, { deviceId })
18383
+ ]
17576
18384
  });
17577
18385
  }
17578
18386
  //#endregion
@@ -19708,13 +20516,14 @@ function RecordingPanel({ deviceId }) {
19708
20516
  * `type:'widget'` ConfigField. `WidgetSlot` resolves these BEFORE the
19709
20517
  * Module-Federation `WidgetRegistry`. ids are namespaced `host/<name>`. */
19710
20518
  var HOST_WIDGETS = {
19711
- "host/motion-zones-grid": (0, react$1.lazy)(() => Promise.resolve().then(() => require("./MotionZonesSettings-BP9p0pFB.cjs")).then((m) => ({ default: m.MotionZonesSettings }))),
20519
+ "host/motion-zones-grid": (0, react$1.lazy)(() => Promise.resolve().then(() => require("./MotionZonesSettings-BK9R9NIr.cjs")).then((m) => ({ default: m.MotionZonesSettings }))),
19712
20520
  "host/privacy-mask-grid": (0, react$1.lazy)(() => Promise.resolve().then(() => require("./PrivacyMaskSettings-DfZAFg32.cjs")).then((m) => ({ default: m.PrivacyMaskSettings }))),
19713
20521
  "host/ptz-panel": PtzPanel,
19714
20522
  "host/navigation-panel": NavigationPanel,
19715
20523
  "host/consumables-panel": ConsumablesPanel,
19716
20524
  "host/ptz-autotrack": AutotrackSection,
19717
- "host/recording-panel": RecordingPanel
20525
+ "host/recording-panel": RecordingPanel,
20526
+ "host/camera-grid-layout": CameraGridLayoutEditor
19718
20527
  };
19719
20528
  //#endregion
19720
20529
  //#region src/composites/widget-slot.tsx
@@ -43164,74 +43973,6 @@ function useDeviceNetworkLink(trpc, deviceId) {
43164
43973
  return useDeviceState(trpc, deviceId, (s) => s.networkLink) ?? null;
43165
43974
  }
43166
43975
  //#endregion
43167
- //#region src/hooks/use-device-snapshot.ts
43168
- /**
43169
- * useDeviceSnapshotImage — a device's snapshot as a plain image URL.
43170
- *
43171
- * ## What this used to do, and why it stopped
43172
- *
43173
- * It fetched `dev.snapshot.getSnapshot()` over tRPC and returned
43174
- * `data:image/jpeg;base64,…`. That put a full camera capture — up to 4 K,
43175
- * measured at 758 KB average per camera on the live hub — through the msgpack
43176
- * WebSocket plane, base64-inflated by a third, and then held it in the
43177
- * react-query heap for 24 h per device. The device list rendered it into a
43178
- * **28 × 28 px `<img>`**.
43179
- *
43180
- * It also did not do what it appeared to do. `snapshot.getSnapshot` scoped to a
43181
- * device routes to the NATIVE provider, not the snapshot wrapper, so it bypassed
43182
- * the wrapper's cache, coalescing and stale-while-revalidate entirely: probed
43183
- * live, a `force:true` call returned a fresh 1.05 MB JPEG in 0.55 s while the
43184
- * wrapper's `lastCapturedAt` for that camera did not move. Two snapshot paths,
43185
- * different behaviour, and only one of them maintained the cache the other one
43186
- * reported on.
43187
- *
43188
- * ## What it does now
43189
- *
43190
- * Returns the same URL the viewer uses: `/addon/snapshot/media/<id>.jpg?w=<n>`,
43191
- * served by the wrapper over the HTTP data plane. Relative and same-origin, so
43192
- * the admin UI's `camstack_session` cookie authenticates it — the endpoint is
43193
- * `access: 'authenticated'`, which accepts either a Bearer header or that
43194
- * cookie, and a bare `<img>` can only send the cookie. This is the same
43195
- * mechanism the native `<video>` fallback in `hls-video.tsx` already relies on.
43196
- *
43197
- * Caching is now the browser's, which is the point: `ETag` +
43198
- * `Cache-Control: private, max-age=<device maxAgeS>` means a repeat render is a
43199
- * cache hit and a revalidation is a 0-byte 304. No JS heap, no msgpack, no
43200
- * base64. Nothing to configure — unlike `expo-image` on native, browsers
43201
- * revalidate a stale URL rather than treating it as immutable, so the URL stays
43202
- * stable and does NOT need a version parameter.
43203
- */
43204
- /** The hub-proxied path prefix for the snapshot image data plane, composed
43205
- * below on `baseUrl` or, for the same-origin case, on the page's mount. */
43206
- var SNAPSHOT_MEDIA_PATH = "/addon/snapshot/media";
43207
- function useDeviceSnapshotImage(deviceId, options = {}) {
43208
- const { width, baseUrl } = options;
43209
- const [refreshNonce, setRefreshNonce] = (0, react$1.useState)(0);
43210
- return {
43211
- src: (0, react$1.useMemo)(() => {
43212
- if (deviceId === null || deviceId <= 0) return null;
43213
- const base = baseUrl !== void 0 ? baseUrl.replace(/\/+$/, "") : routerBasename();
43214
- const params = new URLSearchParams();
43215
- if (width !== void 0 && Number.isInteger(width) && width > 0) params.set("w", String(width));
43216
- if (refreshNonce > 0) {
43217
- params.set("force", "1");
43218
- params.set("r", String(refreshNonce));
43219
- }
43220
- const query = params.toString();
43221
- return `${base}${SNAPSHOT_MEDIA_PATH}/${String(deviceId)}.jpg${query.length > 0 ? `?${query}` : ""}`;
43222
- }, [
43223
- deviceId,
43224
- width,
43225
- baseUrl,
43226
- refreshNonce
43227
- ]),
43228
- loading: false,
43229
- refresh: (0, react$1.useCallback)(() => {
43230
- setRefreshNonce((n) => n + 1);
43231
- }, [])
43232
- };
43233
- }
43234
- //#endregion
43235
43976
  //#region src/composites/hover-zoom-image.tsx
43236
43977
  /**
43237
43978
  * <HoverZoomImage> — a small thumbnail that expands into a floating
@@ -50368,91 +51109,6 @@ function useDeviceWebrtc(trpc, deviceId, pollIntervalMs = 5e3) {
50368
51109
  };
50369
51110
  }
50370
51111
  //#endregion
50371
- //#region src/hooks/use-devices.ts
50372
- /**
50373
- * useDevices / useDevice — reactive sync-mirror reads.
50374
- *
50375
- * Both hooks hand out `DeviceProxy` references straight out of the
50376
- * `System` mirror. The mirror keeps device + binding caches warm via
50377
- * the `device.registered/unregistered/bindings-changed` event stream,
50378
- * so re-renders are driven by lifecycle pushes — no React Query
50379
- * round-trip and no `refetch` plumbing needed at the caller side.
50380
- *
50381
- * The hooks defensively read once on mount and assume the System has
50382
- * been `init()`ed by the host app — calls before the warm-boot
50383
- * completes simply return an empty array / null and re-render once
50384
- * the registered listeners fire.
50385
- */
50386
- /**
50387
- * Reactive list of devices matching the optional filter set. Returns a
50388
- * stable array reference between updates so consumers passing it as a
50389
- * prop don't tear down memoized children.
50390
- */
50391
- function useDevices(filters) {
50392
- const system = useSystem();
50393
- const filtersKey = (0, react$1.useMemo)(() => filters ? JSON.stringify(filters) : "", [filters]);
50394
- const [devices, setDevices] = (0, react$1.useState)(() => system.listDevices(filters));
50395
- (0, react$1.useEffect)(() => {
50396
- const refresh = () => {
50397
- setDevices(system.listDevices(filters));
50398
- };
50399
- refresh();
50400
- let unsubAdded = null;
50401
- let unsubRemoved = null;
50402
- try {
50403
- unsubAdded = system.onDeviceAdded(refresh);
50404
- unsubRemoved = system.onDeviceRemoved(refresh);
50405
- } catch {}
50406
- const unsubConn = system.subscribeConnectionEvents((state) => {
50407
- if (state === "connected") refresh();
50408
- });
50409
- return () => {
50410
- unsubAdded?.();
50411
- unsubRemoved?.();
50412
- unsubConn();
50413
- };
50414
- }, [
50415
- system,
50416
- filters,
50417
- filtersKey
50418
- ]);
50419
- return devices;
50420
- }
50421
- /**
50422
- * Single-device variant. Returns `null` until the device is in the
50423
- * mirror; re-renders when the device is removed (back to `null`) or
50424
- * added.
50425
- */
50426
- function useDevice(deviceId) {
50427
- const system = useSystem();
50428
- const [device, setDevice] = (0, react$1.useState)(() => deviceId === null ? null : system.getDevice(deviceId));
50429
- (0, react$1.useEffect)(() => {
50430
- if (deviceId === null) {
50431
- setDevice(null);
50432
- return;
50433
- }
50434
- const refresh = () => {
50435
- setDevice(system.getDevice(deviceId));
50436
- };
50437
- refresh();
50438
- let unsubAdded = null;
50439
- let unsubRemoved = null;
50440
- try {
50441
- unsubAdded = system.onDeviceAdded(refresh);
50442
- unsubRemoved = system.onDeviceRemoved(refresh);
50443
- } catch {}
50444
- const unsubConn = system.subscribeConnectionEvents((state) => {
50445
- if (state === "connected") refresh();
50446
- });
50447
- return () => {
50448
- unsubAdded?.();
50449
- unsubRemoved?.();
50450
- unsubConn();
50451
- };
50452
- }, [system, deviceId]);
50453
- return device;
50454
- }
50455
- //#endregion
50456
51112
  //#region src/hooks/use-doorbell-events.ts
50457
51113
  /**
50458
51114
  * useDoorbellEvents — subscribe to doorbell.onPressed across the cluster.
@@ -50717,7 +51373,7 @@ function mirror(input) {
50717
51373
  * Render inside `<Suspense>` at all call sites. */
50718
51374
  var MaskShapeCanvas = (0, react$1.lazy)(() => Promise.resolve().then(() => require("./MaskShapeCanvas-BByN3jvt.cjs")).then((m) => ({ default: m.MaskShapeCanvas })));
50719
51375
  /** Lazy-wrapped `MotionZonesSettings` — code-split off the main bundle. */
50720
- var MotionZonesSettings = (0, react$1.lazy)(() => Promise.resolve().then(() => require("./MotionZonesSettings-BP9p0pFB.cjs")).then((m) => ({ default: m.MotionZonesSettings })));
51376
+ var MotionZonesSettings = (0, react$1.lazy)(() => Promise.resolve().then(() => require("./MotionZonesSettings-BK9R9NIr.cjs")).then((m) => ({ default: m.MotionZonesSettings })));
50721
51377
  /** Lazy-wrapped `SceneMonitorEditor` — code-split off the main bundle for the
50722
51378
  * same reason: it mounts `MaskShapeCanvas`, which chains react-konva. Render
50723
51379
  * inside `<Suspense>`; the admin-ui `/scenes` dialog is its only caller. */
@@ -50848,6 +51504,7 @@ exports.FormField = FormField$1;
50848
51504
  exports.GRID_GAP = GRID_GAP;
50849
51505
  exports.GRID_PAIRED = GRID_PAIRED;
50850
51506
  exports.GRID_QUICK_STATS = GRID_QUICK_STATS;
51507
+ exports.Grid2x2 = Grid2x2;
50851
51508
  exports.GripTrack = GripTrack;
50852
51509
  exports.GroupedModelSelector = GroupedModelSelector;
50853
51510
  exports.HOST_WIDGETS = HOST_WIDGETS;
@@ -51927,8 +52584,11 @@ exports.usePtzAutotrackGetSettings = usePtzAutotrackGetSettings;
51927
52584
  exports.usePtzAutotrackGetStatus = usePtzAutotrackGetStatus;
51928
52585
  exports.usePtzAutotrackSetEnabled = usePtzAutotrackSetEnabled;
51929
52586
  exports.usePtzAutotrackSetSettings = usePtzAutotrackSetSettings;
52587
+ exports.usePtzCaptureHomeHere = usePtzCaptureHomeHere;
51930
52588
  exports.usePtzContinuousMove = usePtzContinuousMove;
51931
52589
  exports.usePtzDeletePreset = usePtzDeletePreset;
52590
+ exports.usePtzGetHomePreset = usePtzGetHomePreset;
52591
+ exports.usePtzGetHomeReturnSeconds = usePtzGetHomeReturnSeconds;
51932
52592
  exports.usePtzGetOptions = usePtzGetOptions;
51933
52593
  exports.usePtzGetPosition = usePtzGetPosition;
51934
52594
  exports.usePtzGetPresets = usePtzGetPresets;
@@ -51938,6 +52598,8 @@ exports.usePtzGoToPreset = usePtzGoToPreset;
51938
52598
  exports.usePtzMove = usePtzMove;
51939
52599
  exports.usePtzSavePreset = usePtzSavePreset;
51940
52600
  exports.usePtzSetAutofocus = usePtzSetAutofocus;
52601
+ exports.usePtzSetHomePreset = usePtzSetHomePreset;
52602
+ exports.usePtzSetHomeReturnSeconds = usePtzSetHomeReturnSeconds;
51941
52603
  exports.usePtzStop = usePtzStop;
51942
52604
  exports.useRebootReboot = useRebootReboot;
51943
52605
  exports.useRecordedPlayback = useRecordedPlayback;