@camstack/ui-library 1.2.49 → 1.2.51
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/{MotionZonesSettings-Bvavn6s9.js → MotionZonesSettings-DRrdkR4U.js} +1 -1
- package/dist/{PrivacyMaskSettings-CSxDpLOw.js → PrivacyMaskSettings-DsyAfD21.js} +1 -1
- package/dist/SceneMonitorEditor-CfSrNccC.cjs +281 -0
- package/dist/SceneMonitorEditor-D5BNWAM_.js +281 -0
- package/dist/composites/cap-settings/SceneMonitorEditor.d.ts +43 -0
- package/dist/composites/cap-settings/index.d.ts +6 -5
- package/dist/composites/cap-settings/scene-monitor-copy.d.ts +37 -0
- package/dist/composites/index.d.ts +135 -133
- package/dist/generated/system-hooks.d.ts +10 -0
- package/dist/index.cjs +43652 -43523
- package/dist/index.d.ts +14 -10
- package/dist/index.js +43621 -43516
- package/dist/primitives/badge.d.ts +1 -1
- package/dist/primitives/button.d.ts +1 -1
- package/dist/primitives/icon-button.d.ts +1 -1
- package/dist/primitives/input.d.ts +1 -1
- package/dist/theme/index.cjs +71 -71
- package/dist/theme/index.js +71 -71
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { TEXT_HINT, WidgetPanel,
|
|
1
|
+
import { TEXT_HINT, WidgetPanel, isAbsentProvider, s as createLucideIcon, useDeviceProxy, usePlayerOverlayLayer, useSystem } from "./index.js";
|
|
2
2
|
import { MaskShapeCanvas } from "./MaskShapeCanvas-DI4BY7W2.js";
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { TEXT_HINT, WidgetPanel, a as
|
|
1
|
+
import { TEXT_HINT, WidgetPanel, a as EyeOff, i as Mic, isAbsentProvider, n as Square, s as createLucideIcon, t as Trash2, useDeviceProxy, usePlayerOverlayLayer, useSystem } from "./index.js";
|
|
2
2
|
import { MaskShapeCanvas } from "./MaskShapeCanvas-DI4BY7W2.js";
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
const require_index = require("./index.cjs");
|
|
2
|
+
const require_MaskShapeCanvas = require("./MaskShapeCanvas-BByN3jvt.cjs");
|
|
3
|
+
let react = require("react");
|
|
4
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
5
|
+
//#region src/composites/cap-settings/SceneMonitorEditor.tsx
|
|
6
|
+
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";
|
|
7
|
+
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";
|
|
8
|
+
var BTN_DANGER = "rounded-md border border-danger/50 bg-danger/10 px-2 py-1 text-[11px] font-medium text-danger hover:bg-danger/20 disabled:opacity-40 transition-colors";
|
|
9
|
+
var TONE_CLASS = {
|
|
10
|
+
alarm: "border-danger/50 bg-danger/15 text-danger",
|
|
11
|
+
ok: "border-success/50 bg-success/15 text-success",
|
|
12
|
+
unknown: "border-border bg-surface text-foreground-subtle"
|
|
13
|
+
};
|
|
14
|
+
/** A scene without at least one reference cannot judge anything, so the
|
|
15
|
+
* similarity threshold only becomes meaningful after the first capture. */
|
|
16
|
+
var DEFAULT_THRESHOLD = .8;
|
|
17
|
+
var DEFAULT_HYSTERESIS = 3;
|
|
18
|
+
function SceneMonitorEditor({ deviceId }) {
|
|
19
|
+
const dev = require_index.useDeviceProxy$1(require_index.useSystem$1().trpcClient, deviceId);
|
|
20
|
+
const pushed = require_index.useDeviceStateSlice$1(dev?.state.sceneMonitor);
|
|
21
|
+
const snapshot = require_index.useDeviceSnapshotImage$1(deviceId, { width: 640 });
|
|
22
|
+
const [fetched, setFetched] = (0, react.useState)(null);
|
|
23
|
+
const [unsupported, setUnsupported] = (0, react.useState)(false);
|
|
24
|
+
const [busy, setBusy] = (0, react.useState)(null);
|
|
25
|
+
const [error, setError] = (0, react.useState)(null);
|
|
26
|
+
const [selectedId, setSelectedId] = (0, react.useState)(null);
|
|
27
|
+
const [drafting, setDrafting] = (0, react.useState)(false);
|
|
28
|
+
const [draftRoi, setDraftRoi] = (0, react.useState)(null);
|
|
29
|
+
const [draftLabel, setDraftLabel] = (0, react.useState)("");
|
|
30
|
+
const mounted = (0, react.useRef)(true);
|
|
31
|
+
(0, react.useEffect)(() => {
|
|
32
|
+
mounted.current = true;
|
|
33
|
+
return () => {
|
|
34
|
+
mounted.current = false;
|
|
35
|
+
};
|
|
36
|
+
}, []);
|
|
37
|
+
const reload = (0, react.useCallback)(async () => {
|
|
38
|
+
if (!dev) return;
|
|
39
|
+
try {
|
|
40
|
+
const status = await dev.sceneMonitor?.listScenes({});
|
|
41
|
+
if (!mounted.current) return;
|
|
42
|
+
if (status) setFetched(status);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (!mounted.current) return;
|
|
45
|
+
if (require_index.isAbsentProvider$1(err)) setUnsupported(true);
|
|
46
|
+
else setError(err instanceof Error ? err.message : String(err));
|
|
47
|
+
}
|
|
48
|
+
}, [dev]);
|
|
49
|
+
(0, react.useEffect)(() => {
|
|
50
|
+
setFetched(null);
|
|
51
|
+
setUnsupported(false);
|
|
52
|
+
setError(null);
|
|
53
|
+
reload();
|
|
54
|
+
}, [reload]);
|
|
55
|
+
const monitors = pushed?.monitors ?? fetched?.monitors ?? [];
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const run = (0, react.useCallback)(async (key, fn) => {
|
|
58
|
+
setBusy(key);
|
|
59
|
+
setError(null);
|
|
60
|
+
try {
|
|
61
|
+
await fn();
|
|
62
|
+
await reload();
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (mounted.current) setError(err instanceof Error ? err.message : String(err));
|
|
65
|
+
} finally {
|
|
66
|
+
if (mounted.current) setBusy(null);
|
|
67
|
+
}
|
|
68
|
+
}, [reload]);
|
|
69
|
+
const items = (0, react.useMemo)(() => {
|
|
70
|
+
const existing = monitors.map((m) => ({
|
|
71
|
+
id: m.id,
|
|
72
|
+
shape: m.roi,
|
|
73
|
+
label: m.label,
|
|
74
|
+
enabled: m.enabled
|
|
75
|
+
}));
|
|
76
|
+
return draftRoi !== null ? [...existing, {
|
|
77
|
+
id: "__draft__",
|
|
78
|
+
shape: draftRoi,
|
|
79
|
+
label: "New scene"
|
|
80
|
+
}] : existing;
|
|
81
|
+
}, [monitors, draftRoi]);
|
|
82
|
+
const onShapeChange = (0, react.useCallback)((id, shape) => {
|
|
83
|
+
if (shape.kind !== "rect") return;
|
|
84
|
+
if (id === "__draft__") {
|
|
85
|
+
setDraftRoi(shape);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
run(`roi-${String(id)}`, async () => dev?.sceneMonitor?.updateScene({
|
|
89
|
+
monitorId: String(id),
|
|
90
|
+
patch: { roi: shape }
|
|
91
|
+
}));
|
|
92
|
+
}, [dev, run]);
|
|
93
|
+
const onDrawComplete = (0, react.useCallback)((shape) => {
|
|
94
|
+
if (shape.kind !== "rect") return;
|
|
95
|
+
setDraftRoi(shape);
|
|
96
|
+
}, []);
|
|
97
|
+
const createScene = (0, react.useCallback)(async () => {
|
|
98
|
+
if (draftRoi === null || draftLabel.trim().length === 0) return;
|
|
99
|
+
await run("create", async () => {
|
|
100
|
+
await dev?.sceneMonitor?.createScene({
|
|
101
|
+
label: draftLabel.trim(),
|
|
102
|
+
roi: draftRoi,
|
|
103
|
+
check: {
|
|
104
|
+
mode: "similarity",
|
|
105
|
+
threshold: DEFAULT_THRESHOLD,
|
|
106
|
+
hysteresisCount: DEFAULT_HYSTERESIS
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
setDraftRoi(null);
|
|
111
|
+
setDraftLabel("");
|
|
112
|
+
setDrafting(false);
|
|
113
|
+
}, [
|
|
114
|
+
dev,
|
|
115
|
+
draftRoi,
|
|
116
|
+
draftLabel,
|
|
117
|
+
run
|
|
118
|
+
]);
|
|
119
|
+
if (!dev) return null;
|
|
120
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
121
|
+
className: "flex flex-col gap-3",
|
|
122
|
+
children: unsupported ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
123
|
+
className: `${require_index.TEXT_HINT} leading-relaxed`,
|
|
124
|
+
children: "Scene monitoring is not available for this camera."
|
|
125
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
126
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
127
|
+
className: `${require_index.TEXT_HINT} leading-relaxed`,
|
|
128
|
+
children: "A scene watches one region of this camera and tells you when it stops looking like the picture you captured. Draw a box around the thing you care about — a wheelie bin, a gate, a parking space — then capture what “normal” looks like, in daylight and again at night."
|
|
129
|
+
}),
|
|
130
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
131
|
+
className: "relative overflow-hidden rounded-md border border-border",
|
|
132
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_MaskShapeCanvas.MaskShapeCanvas, {
|
|
133
|
+
items,
|
|
134
|
+
supportedShapes: ["rect"],
|
|
135
|
+
selectedId: selectedId ?? (draftRoi !== null ? "__draft__" : null),
|
|
136
|
+
onSelect: (id) => setSelectedId(id === null ? null : String(id)),
|
|
137
|
+
onShapeChange,
|
|
138
|
+
onDrawComplete,
|
|
139
|
+
drawingKind: drafting && draftRoi === null ? "rect" : null,
|
|
140
|
+
backdrop: snapshot.src !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
|
|
141
|
+
src: snapshot.src,
|
|
142
|
+
alt: "",
|
|
143
|
+
className: "h-full w-full object-contain",
|
|
144
|
+
draggable: false
|
|
145
|
+
}) : null
|
|
146
|
+
})
|
|
147
|
+
}),
|
|
148
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
149
|
+
className: "flex flex-wrap items-center gap-2",
|
|
150
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
151
|
+
type: "button",
|
|
152
|
+
className: drafting ? BTN_PRIMARY : BTN_NEUTRAL,
|
|
153
|
+
"aria-pressed": drafting,
|
|
154
|
+
onClick: () => {
|
|
155
|
+
setDrafting((v) => !v);
|
|
156
|
+
setDraftRoi(null);
|
|
157
|
+
},
|
|
158
|
+
disabled: busy !== null,
|
|
159
|
+
children: drafting ? "Cancel" : "+ New scene"
|
|
160
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
161
|
+
type: "button",
|
|
162
|
+
className: BTN_NEUTRAL,
|
|
163
|
+
onClick: snapshot.refresh,
|
|
164
|
+
disabled: busy !== null,
|
|
165
|
+
children: "Refresh frame"
|
|
166
|
+
})]
|
|
167
|
+
}),
|
|
168
|
+
drafting && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
169
|
+
className: "flex flex-col gap-2 rounded-md border border-border bg-surface p-2",
|
|
170
|
+
children: [
|
|
171
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
172
|
+
className: "text-[10px] text-foreground-subtle",
|
|
173
|
+
children: draftRoi === null ? "Click on the frame to drop a box, then drag its corner to fit it around the thing you want watched." : "Name it, then create. You capture what “normal” looks like next."
|
|
174
|
+
}),
|
|
175
|
+
draftRoi !== null && require_index.roiTooLarge$1(draftRoi) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
176
|
+
className: "text-[11px] leading-relaxed text-warning",
|
|
177
|
+
children: "That box covers most of the frame. A scene that big is dominated by the weather, the sky and passing cars — it will quietly stop noticing the thing you actually care about. Draw it tighter."
|
|
178
|
+
}),
|
|
179
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
180
|
+
className: "flex items-center gap-2",
|
|
181
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
182
|
+
type: "text",
|
|
183
|
+
value: draftLabel,
|
|
184
|
+
onChange: (e) => setDraftLabel(e.target.value),
|
|
185
|
+
placeholder: "e.g. Immondizia prelevata",
|
|
186
|
+
className: "flex-1 rounded-md border border-border bg-surface px-2 py-1 text-[12px] text-foreground"
|
|
187
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
188
|
+
type: "button",
|
|
189
|
+
className: BTN_PRIMARY,
|
|
190
|
+
onClick: () => void createScene(),
|
|
191
|
+
disabled: draftRoi === null || draftLabel.trim().length === 0 || busy !== null,
|
|
192
|
+
children: "Create"
|
|
193
|
+
})]
|
|
194
|
+
})
|
|
195
|
+
]
|
|
196
|
+
}),
|
|
197
|
+
error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
198
|
+
className: "text-[11px] leading-relaxed text-danger",
|
|
199
|
+
children: error
|
|
200
|
+
}),
|
|
201
|
+
monitors.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
202
|
+
className: `${require_index.TEXT_HINT} leading-relaxed`,
|
|
203
|
+
children: "No scenes on this camera yet."
|
|
204
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
|
|
205
|
+
className: "flex flex-col gap-2",
|
|
206
|
+
children: monitors.map((m) => {
|
|
207
|
+
const badge = require_index.badgeFor$1(m);
|
|
208
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
|
|
209
|
+
className: `flex flex-col gap-1.5 rounded-md border p-2 ${selectedId === m.id ? "border-primary/50" : "border-border"}`,
|
|
210
|
+
children: [
|
|
211
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
212
|
+
className: "flex items-center gap-2",
|
|
213
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
214
|
+
type: "button",
|
|
215
|
+
className: "flex-1 text-left text-[12px] font-medium text-foreground",
|
|
216
|
+
onClick: () => setSelectedId(m.id),
|
|
217
|
+
children: m.label
|
|
218
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
219
|
+
className: `rounded border px-1.5 py-0.5 text-[10px] font-medium ${TONE_CLASS[badge.tone]}`,
|
|
220
|
+
children: badge.text
|
|
221
|
+
})]
|
|
222
|
+
}),
|
|
223
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
224
|
+
className: `${require_index.TEXT_HINT} leading-relaxed`,
|
|
225
|
+
children: require_index.statusLine$1(m, now)
|
|
226
|
+
}),
|
|
227
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
228
|
+
className: `${require_index.TEXT_HINT} tabular-nums`,
|
|
229
|
+
children: require_index.coverageLine$1(m)
|
|
230
|
+
}),
|
|
231
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
232
|
+
className: "flex flex-wrap items-center gap-1.5",
|
|
233
|
+
children: [
|
|
234
|
+
require_index.SCENE_CAPTURE_VARIANTS$1.map((variant) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
235
|
+
type: "button",
|
|
236
|
+
className: BTN_NEUTRAL,
|
|
237
|
+
disabled: busy !== null,
|
|
238
|
+
title: `Take a fresh photo now and store it as the ${require_index.variantLabel$1(variant).toLowerCase()} reference`,
|
|
239
|
+
onClick: () => void run(`cap-${m.id}-${variant}`, async () => dev.sceneMonitor?.captureReference({
|
|
240
|
+
monitorId: m.id,
|
|
241
|
+
condition: variant
|
|
242
|
+
})),
|
|
243
|
+
children: [
|
|
244
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_index.Camera, { className: "mr-1 inline h-3 w-3" }),
|
|
245
|
+
"Capture ",
|
|
246
|
+
require_index.variantLabel$1(variant).toLowerCase()
|
|
247
|
+
]
|
|
248
|
+
}, variant)),
|
|
249
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
250
|
+
type: "button",
|
|
251
|
+
className: BTN_PRIMARY,
|
|
252
|
+
disabled: busy !== null || m.states.length === 0,
|
|
253
|
+
title: "Clear the flag and re-photograph what normal looks like right now",
|
|
254
|
+
onClick: () => void run(`reset-${m.id}`, async () => dev.sceneMonitor?.resetScene({ monitorId: m.id })),
|
|
255
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_index.RotateCcw, { className: "mr-1 inline h-3 w-3" }), "Reset"]
|
|
256
|
+
}),
|
|
257
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
258
|
+
type: "button",
|
|
259
|
+
className: BTN_NEUTRAL,
|
|
260
|
+
disabled: busy !== null,
|
|
261
|
+
onClick: () => void run(`check-${m.id}`, async () => dev.sceneMonitor?.recheckNow({ monitorId: m.id })),
|
|
262
|
+
children: "Check now"
|
|
263
|
+
}),
|
|
264
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
265
|
+
type: "button",
|
|
266
|
+
className: BTN_DANGER,
|
|
267
|
+
disabled: busy !== null,
|
|
268
|
+
onClick: () => void run(`del-${m.id}`, async () => dev.sceneMonitor?.deleteScene({ monitorId: m.id })),
|
|
269
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_index.Trash2, { className: "h-3 w-3" })
|
|
270
|
+
})
|
|
271
|
+
]
|
|
272
|
+
})
|
|
273
|
+
]
|
|
274
|
+
}, m.id);
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
] })
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
//#endregion
|
|
281
|
+
exports.SceneMonitorEditor = SceneMonitorEditor;
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { SCENE_CAPTURE_VARIANTS, TEXT_HINT, badgeFor, coverageLine, isAbsentProvider, o as Camera, r as RotateCcw, roiTooLarge, statusLine, t as Trash2, useDeviceProxy, useDeviceSnapshotImage, useDeviceStateSlice, useSystem, variantLabel } 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
|
+
//#region src/composites/cap-settings/SceneMonitorEditor.tsx
|
|
6
|
+
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";
|
|
7
|
+
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";
|
|
8
|
+
var BTN_DANGER = "rounded-md border border-danger/50 bg-danger/10 px-2 py-1 text-[11px] font-medium text-danger hover:bg-danger/20 disabled:opacity-40 transition-colors";
|
|
9
|
+
var TONE_CLASS = {
|
|
10
|
+
alarm: "border-danger/50 bg-danger/15 text-danger",
|
|
11
|
+
ok: "border-success/50 bg-success/15 text-success",
|
|
12
|
+
unknown: "border-border bg-surface text-foreground-subtle"
|
|
13
|
+
};
|
|
14
|
+
/** A scene without at least one reference cannot judge anything, so the
|
|
15
|
+
* similarity threshold only becomes meaningful after the first capture. */
|
|
16
|
+
var DEFAULT_THRESHOLD = .8;
|
|
17
|
+
var DEFAULT_HYSTERESIS = 3;
|
|
18
|
+
function SceneMonitorEditor({ deviceId }) {
|
|
19
|
+
const dev = useDeviceProxy(useSystem().trpcClient, deviceId);
|
|
20
|
+
const pushed = useDeviceStateSlice(dev?.state.sceneMonitor);
|
|
21
|
+
const snapshot = useDeviceSnapshotImage(deviceId, { width: 640 });
|
|
22
|
+
const [fetched, setFetched] = useState(null);
|
|
23
|
+
const [unsupported, setUnsupported] = useState(false);
|
|
24
|
+
const [busy, setBusy] = useState(null);
|
|
25
|
+
const [error, setError] = useState(null);
|
|
26
|
+
const [selectedId, setSelectedId] = useState(null);
|
|
27
|
+
const [drafting, setDrafting] = useState(false);
|
|
28
|
+
const [draftRoi, setDraftRoi] = useState(null);
|
|
29
|
+
const [draftLabel, setDraftLabel] = useState("");
|
|
30
|
+
const mounted = useRef(true);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
mounted.current = true;
|
|
33
|
+
return () => {
|
|
34
|
+
mounted.current = false;
|
|
35
|
+
};
|
|
36
|
+
}, []);
|
|
37
|
+
const reload = useCallback(async () => {
|
|
38
|
+
if (!dev) return;
|
|
39
|
+
try {
|
|
40
|
+
const status = await dev.sceneMonitor?.listScenes({});
|
|
41
|
+
if (!mounted.current) return;
|
|
42
|
+
if (status) setFetched(status);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (!mounted.current) return;
|
|
45
|
+
if (isAbsentProvider(err)) setUnsupported(true);
|
|
46
|
+
else setError(err instanceof Error ? err.message : String(err));
|
|
47
|
+
}
|
|
48
|
+
}, [dev]);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
setFetched(null);
|
|
51
|
+
setUnsupported(false);
|
|
52
|
+
setError(null);
|
|
53
|
+
reload();
|
|
54
|
+
}, [reload]);
|
|
55
|
+
const monitors = pushed?.monitors ?? fetched?.monitors ?? [];
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const run = useCallback(async (key, fn) => {
|
|
58
|
+
setBusy(key);
|
|
59
|
+
setError(null);
|
|
60
|
+
try {
|
|
61
|
+
await fn();
|
|
62
|
+
await reload();
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (mounted.current) setError(err instanceof Error ? err.message : String(err));
|
|
65
|
+
} finally {
|
|
66
|
+
if (mounted.current) setBusy(null);
|
|
67
|
+
}
|
|
68
|
+
}, [reload]);
|
|
69
|
+
const items = useMemo(() => {
|
|
70
|
+
const existing = monitors.map((m) => ({
|
|
71
|
+
id: m.id,
|
|
72
|
+
shape: m.roi,
|
|
73
|
+
label: m.label,
|
|
74
|
+
enabled: m.enabled
|
|
75
|
+
}));
|
|
76
|
+
return draftRoi !== null ? [...existing, {
|
|
77
|
+
id: "__draft__",
|
|
78
|
+
shape: draftRoi,
|
|
79
|
+
label: "New scene"
|
|
80
|
+
}] : existing;
|
|
81
|
+
}, [monitors, draftRoi]);
|
|
82
|
+
const onShapeChange = useCallback((id, shape) => {
|
|
83
|
+
if (shape.kind !== "rect") return;
|
|
84
|
+
if (id === "__draft__") {
|
|
85
|
+
setDraftRoi(shape);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
run(`roi-${String(id)}`, async () => dev?.sceneMonitor?.updateScene({
|
|
89
|
+
monitorId: String(id),
|
|
90
|
+
patch: { roi: shape }
|
|
91
|
+
}));
|
|
92
|
+
}, [dev, run]);
|
|
93
|
+
const onDrawComplete = useCallback((shape) => {
|
|
94
|
+
if (shape.kind !== "rect") return;
|
|
95
|
+
setDraftRoi(shape);
|
|
96
|
+
}, []);
|
|
97
|
+
const createScene = useCallback(async () => {
|
|
98
|
+
if (draftRoi === null || draftLabel.trim().length === 0) return;
|
|
99
|
+
await run("create", async () => {
|
|
100
|
+
await dev?.sceneMonitor?.createScene({
|
|
101
|
+
label: draftLabel.trim(),
|
|
102
|
+
roi: draftRoi,
|
|
103
|
+
check: {
|
|
104
|
+
mode: "similarity",
|
|
105
|
+
threshold: DEFAULT_THRESHOLD,
|
|
106
|
+
hysteresisCount: DEFAULT_HYSTERESIS
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
setDraftRoi(null);
|
|
111
|
+
setDraftLabel("");
|
|
112
|
+
setDrafting(false);
|
|
113
|
+
}, [
|
|
114
|
+
dev,
|
|
115
|
+
draftRoi,
|
|
116
|
+
draftLabel,
|
|
117
|
+
run
|
|
118
|
+
]);
|
|
119
|
+
if (!dev) return null;
|
|
120
|
+
return /* @__PURE__ */ jsx("div", {
|
|
121
|
+
className: "flex flex-col gap-3",
|
|
122
|
+
children: unsupported ? /* @__PURE__ */ jsx("p", {
|
|
123
|
+
className: `${TEXT_HINT} leading-relaxed`,
|
|
124
|
+
children: "Scene monitoring is not available for this camera."
|
|
125
|
+
}) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
126
|
+
/* @__PURE__ */ jsx("p", {
|
|
127
|
+
className: `${TEXT_HINT} leading-relaxed`,
|
|
128
|
+
children: "A scene watches one region of this camera and tells you when it stops looking like the picture you captured. Draw a box around the thing you care about — a wheelie bin, a gate, a parking space — then capture what “normal” looks like, in daylight and again at night."
|
|
129
|
+
}),
|
|
130
|
+
/* @__PURE__ */ jsx("div", {
|
|
131
|
+
className: "relative overflow-hidden rounded-md border border-border",
|
|
132
|
+
children: /* @__PURE__ */ jsx(MaskShapeCanvas, {
|
|
133
|
+
items,
|
|
134
|
+
supportedShapes: ["rect"],
|
|
135
|
+
selectedId: selectedId ?? (draftRoi !== null ? "__draft__" : null),
|
|
136
|
+
onSelect: (id) => setSelectedId(id === null ? null : String(id)),
|
|
137
|
+
onShapeChange,
|
|
138
|
+
onDrawComplete,
|
|
139
|
+
drawingKind: drafting && draftRoi === null ? "rect" : null,
|
|
140
|
+
backdrop: snapshot.src !== null ? /* @__PURE__ */ jsx("img", {
|
|
141
|
+
src: snapshot.src,
|
|
142
|
+
alt: "",
|
|
143
|
+
className: "h-full w-full object-contain",
|
|
144
|
+
draggable: false
|
|
145
|
+
}) : null
|
|
146
|
+
})
|
|
147
|
+
}),
|
|
148
|
+
/* @__PURE__ */ jsxs("div", {
|
|
149
|
+
className: "flex flex-wrap items-center gap-2",
|
|
150
|
+
children: [/* @__PURE__ */ jsx("button", {
|
|
151
|
+
type: "button",
|
|
152
|
+
className: drafting ? BTN_PRIMARY : BTN_NEUTRAL,
|
|
153
|
+
"aria-pressed": drafting,
|
|
154
|
+
onClick: () => {
|
|
155
|
+
setDrafting((v) => !v);
|
|
156
|
+
setDraftRoi(null);
|
|
157
|
+
},
|
|
158
|
+
disabled: busy !== null,
|
|
159
|
+
children: drafting ? "Cancel" : "+ New scene"
|
|
160
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
161
|
+
type: "button",
|
|
162
|
+
className: BTN_NEUTRAL,
|
|
163
|
+
onClick: snapshot.refresh,
|
|
164
|
+
disabled: busy !== null,
|
|
165
|
+
children: "Refresh frame"
|
|
166
|
+
})]
|
|
167
|
+
}),
|
|
168
|
+
drafting && /* @__PURE__ */ jsxs("div", {
|
|
169
|
+
className: "flex flex-col gap-2 rounded-md border border-border bg-surface p-2",
|
|
170
|
+
children: [
|
|
171
|
+
/* @__PURE__ */ jsx("p", {
|
|
172
|
+
className: "text-[10px] text-foreground-subtle",
|
|
173
|
+
children: draftRoi === null ? "Click on the frame to drop a box, then drag its corner to fit it around the thing you want watched." : "Name it, then create. You capture what “normal” looks like next."
|
|
174
|
+
}),
|
|
175
|
+
draftRoi !== null && roiTooLarge(draftRoi) && /* @__PURE__ */ jsx("p", {
|
|
176
|
+
className: "text-[11px] leading-relaxed text-warning",
|
|
177
|
+
children: "That box covers most of the frame. A scene that big is dominated by the weather, the sky and passing cars — it will quietly stop noticing the thing you actually care about. Draw it tighter."
|
|
178
|
+
}),
|
|
179
|
+
/* @__PURE__ */ jsxs("div", {
|
|
180
|
+
className: "flex items-center gap-2",
|
|
181
|
+
children: [/* @__PURE__ */ jsx("input", {
|
|
182
|
+
type: "text",
|
|
183
|
+
value: draftLabel,
|
|
184
|
+
onChange: (e) => setDraftLabel(e.target.value),
|
|
185
|
+
placeholder: "e.g. Immondizia prelevata",
|
|
186
|
+
className: "flex-1 rounded-md border border-border bg-surface px-2 py-1 text-[12px] text-foreground"
|
|
187
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
188
|
+
type: "button",
|
|
189
|
+
className: BTN_PRIMARY,
|
|
190
|
+
onClick: () => void createScene(),
|
|
191
|
+
disabled: draftRoi === null || draftLabel.trim().length === 0 || busy !== null,
|
|
192
|
+
children: "Create"
|
|
193
|
+
})]
|
|
194
|
+
})
|
|
195
|
+
]
|
|
196
|
+
}),
|
|
197
|
+
error !== null && /* @__PURE__ */ jsx("p", {
|
|
198
|
+
className: "text-[11px] leading-relaxed text-danger",
|
|
199
|
+
children: error
|
|
200
|
+
}),
|
|
201
|
+
monitors.length === 0 ? /* @__PURE__ */ jsx("p", {
|
|
202
|
+
className: `${TEXT_HINT} leading-relaxed`,
|
|
203
|
+
children: "No scenes on this camera yet."
|
|
204
|
+
}) : /* @__PURE__ */ jsx("ul", {
|
|
205
|
+
className: "flex flex-col gap-2",
|
|
206
|
+
children: monitors.map((m) => {
|
|
207
|
+
const badge = badgeFor(m);
|
|
208
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
209
|
+
className: `flex flex-col gap-1.5 rounded-md border p-2 ${selectedId === m.id ? "border-primary/50" : "border-border"}`,
|
|
210
|
+
children: [
|
|
211
|
+
/* @__PURE__ */ jsxs("div", {
|
|
212
|
+
className: "flex items-center gap-2",
|
|
213
|
+
children: [/* @__PURE__ */ jsx("button", {
|
|
214
|
+
type: "button",
|
|
215
|
+
className: "flex-1 text-left text-[12px] font-medium text-foreground",
|
|
216
|
+
onClick: () => setSelectedId(m.id),
|
|
217
|
+
children: m.label
|
|
218
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
219
|
+
className: `rounded border px-1.5 py-0.5 text-[10px] font-medium ${TONE_CLASS[badge.tone]}`,
|
|
220
|
+
children: badge.text
|
|
221
|
+
})]
|
|
222
|
+
}),
|
|
223
|
+
/* @__PURE__ */ jsx("p", {
|
|
224
|
+
className: `${TEXT_HINT} leading-relaxed`,
|
|
225
|
+
children: statusLine(m, now)
|
|
226
|
+
}),
|
|
227
|
+
/* @__PURE__ */ jsx("p", {
|
|
228
|
+
className: `${TEXT_HINT} tabular-nums`,
|
|
229
|
+
children: coverageLine(m)
|
|
230
|
+
}),
|
|
231
|
+
/* @__PURE__ */ jsxs("div", {
|
|
232
|
+
className: "flex flex-wrap items-center gap-1.5",
|
|
233
|
+
children: [
|
|
234
|
+
SCENE_CAPTURE_VARIANTS.map((variant) => /* @__PURE__ */ jsxs("button", {
|
|
235
|
+
type: "button",
|
|
236
|
+
className: BTN_NEUTRAL,
|
|
237
|
+
disabled: busy !== null,
|
|
238
|
+
title: `Take a fresh photo now and store it as the ${variantLabel(variant).toLowerCase()} reference`,
|
|
239
|
+
onClick: () => void run(`cap-${m.id}-${variant}`, async () => dev.sceneMonitor?.captureReference({
|
|
240
|
+
monitorId: m.id,
|
|
241
|
+
condition: variant
|
|
242
|
+
})),
|
|
243
|
+
children: [
|
|
244
|
+
/* @__PURE__ */ jsx(Camera, { className: "mr-1 inline h-3 w-3" }),
|
|
245
|
+
"Capture ",
|
|
246
|
+
variantLabel(variant).toLowerCase()
|
|
247
|
+
]
|
|
248
|
+
}, variant)),
|
|
249
|
+
/* @__PURE__ */ jsxs("button", {
|
|
250
|
+
type: "button",
|
|
251
|
+
className: BTN_PRIMARY,
|
|
252
|
+
disabled: busy !== null || m.states.length === 0,
|
|
253
|
+
title: "Clear the flag and re-photograph what normal looks like right now",
|
|
254
|
+
onClick: () => void run(`reset-${m.id}`, async () => dev.sceneMonitor?.resetScene({ monitorId: m.id })),
|
|
255
|
+
children: [/* @__PURE__ */ jsx(RotateCcw, { className: "mr-1 inline h-3 w-3" }), "Reset"]
|
|
256
|
+
}),
|
|
257
|
+
/* @__PURE__ */ jsx("button", {
|
|
258
|
+
type: "button",
|
|
259
|
+
className: BTN_NEUTRAL,
|
|
260
|
+
disabled: busy !== null,
|
|
261
|
+
onClick: () => void run(`check-${m.id}`, async () => dev.sceneMonitor?.recheckNow({ monitorId: m.id })),
|
|
262
|
+
children: "Check now"
|
|
263
|
+
}),
|
|
264
|
+
/* @__PURE__ */ jsx("button", {
|
|
265
|
+
type: "button",
|
|
266
|
+
className: BTN_DANGER,
|
|
267
|
+
disabled: busy !== null,
|
|
268
|
+
onClick: () => void run(`del-${m.id}`, async () => dev.sceneMonitor?.deleteScene({ monitorId: m.id })),
|
|
269
|
+
children: /* @__PURE__ */ jsx(Trash2, { className: "h-3 w-3" })
|
|
270
|
+
})
|
|
271
|
+
]
|
|
272
|
+
})
|
|
273
|
+
]
|
|
274
|
+
}, m.id);
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
] })
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
//#endregion
|
|
281
|
+
export { SceneMonitorEditor };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SceneMonitorEditor — the per-camera half of the Scenes surface.
|
|
3
|
+
*
|
|
4
|
+
* **Imported directly, not resolved as a widget.** It briefly lived behind the
|
|
5
|
+
* host widget `host/scene-monitor-editor`, mounted as a "Scenes" tab on one
|
|
6
|
+
* camera's detail page. That put a standing question about the property ("is
|
|
7
|
+
* the bin still out") one camera deep, where the operator's actual question —
|
|
8
|
+
* "which of my scenes have tripped" — could not be asked at all. The overview
|
|
9
|
+
* is now the top-level `/scenes` page; this component is what that page opens
|
|
10
|
+
* once a camera is chosen, in a dialog. The widget id and its `deviceConfig`
|
|
11
|
+
* declaration are both gone, in the same commit, because
|
|
12
|
+
* `scripts/check-host-widget-resolves.ts` checks both directions.
|
|
13
|
+
*
|
|
14
|
+
* It renders BARE — no `WidgetPanel` — because its only caller supplies a
|
|
15
|
+
* dialog header, and two stacked titles read as a layout bug.
|
|
16
|
+
*
|
|
17
|
+
* ## The flow, in the order the operator meets it
|
|
18
|
+
*
|
|
19
|
+
* 1. **Draw a region on the still frame.** A rect over a SNAPSHOT, not the live
|
|
20
|
+
* player: a scene is defined against a photograph, and drawing on a moving
|
|
21
|
+
* picture makes it impossible to see whether the box actually contains the
|
|
22
|
+
* bin. The editor starts in draw mode and warns above ~40 % of the frame,
|
|
23
|
+
* because a full-frame scene is dominated by weather, sky and parked cars —
|
|
24
|
+
* it will not work, and it will look broken rather than misconfigured.
|
|
25
|
+
* 2. **Capture the initial state, per lighting variant.** Two buttons, Daylight
|
|
26
|
+
* and Night (IR), each forcing a fresh snapshot. The variant is DETECTED
|
|
27
|
+
* from the frame and shown rather than asked; the buttons exist so the
|
|
28
|
+
* operator can say "capture this as the night reference" while standing
|
|
29
|
+
* outside at 22:00.
|
|
30
|
+
* 3. **Read the coverage line.** "Daylight ✓ · Night (IR) ✗ — reports 'can't
|
|
31
|
+
* tell' in night (ir)". This feature's failure mode is silence that reads as
|
|
32
|
+
* "nothing has happened", and that one line is the difference between a
|
|
33
|
+
* five-minute fix and a session of guessing.
|
|
34
|
+
* 4. **Reset.** Clears the flag AND re-captures the baseline for the current
|
|
35
|
+
* light, because the bin never goes back in exactly the same spot.
|
|
36
|
+
*
|
|
37
|
+
* Live state arrives through the cap's `status.kind: 'push'` runtime slice, so
|
|
38
|
+
* the card updates itself without polling.
|
|
39
|
+
*/
|
|
40
|
+
export interface SceneMonitorEditorProps {
|
|
41
|
+
readonly deviceId: number;
|
|
42
|
+
}
|
|
43
|
+
export declare function SceneMonitorEditor({ deviceId }: SceneMonitorEditorProps): import("react").JSX.Element | null;
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
export { PtzPanel } from './PtzPanel';
|
|
2
|
-
export { ConsumablesPanel } from './ConsumablesPanel';
|
|
3
1
|
export { AutotrackSection } from './AutotrackSection';
|
|
2
|
+
export { ConsumablesPanel } from './ConsumablesPanel';
|
|
3
|
+
export type { MaskShapeCanvasProps, MaskShapeItem } from './MaskShapeCanvas';
|
|
4
|
+
export { MaskShapeCanvas } from './MaskShapeCanvas';
|
|
4
5
|
export { MotionZonesSettings } from './MotionZonesSettings';
|
|
5
6
|
export { PrivacyMaskSettings } from './PrivacyMaskSettings';
|
|
7
|
+
export { PtzPanel } from './PtzPanel';
|
|
8
|
+
export { RecordingBandsEditor } from './RecordingBandsEditor';
|
|
6
9
|
export { RecordingPanel } from './RecordingPanel';
|
|
7
10
|
export { RecordingSettings } from './RecordingSettings';
|
|
8
|
-
export {
|
|
9
|
-
export { MaskShapeCanvas } from './MaskShapeCanvas';
|
|
10
|
-
export type { MaskShapeItem, MaskShapeCanvasProps } from './MaskShapeCanvas';
|
|
11
|
+
export { SceneMonitorEditor } from './SceneMonitorEditor';
|
|
11
12
|
/**
|
|
12
13
|
* Props every cap-settings component receives.
|
|
13
14
|
*
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { SceneMonitor } from '@camstack/types';
|
|
2
|
+
/** The lighting variants an operator is asked to capture. The resolver can also
|
|
3
|
+
* report `night`, `dawn` and `dusk`; those are shown when present but never
|
|
4
|
+
* demanded, because most installs only ever see day and IR. */
|
|
5
|
+
export declare const SCENE_CAPTURE_VARIANTS: readonly ["day", "ir"];
|
|
6
|
+
export type SceneCaptureVariant = (typeof SCENE_CAPTURE_VARIANTS)[number];
|
|
7
|
+
export declare const VARIANT_LABEL: Readonly<Record<string, string>>;
|
|
8
|
+
export declare function variantLabel(condition: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* "day ✓ · ir ✗ — this scene reports UNKNOWN at night."
|
|
11
|
+
*
|
|
12
|
+
* Converts the silent cross-condition fallback this engine used to have into an
|
|
13
|
+
* operator-visible fact. Without it, a scene with only a daylight reference
|
|
14
|
+
* simply stops answering after sunset and nothing anywhere says why.
|
|
15
|
+
*/
|
|
16
|
+
export declare function coverageLine(monitor: SceneMonitor): string;
|
|
17
|
+
/**
|
|
18
|
+
* The one line under a scene's name. It answers, in order: can it judge, what
|
|
19
|
+
* does it say, and how long has it said it.
|
|
20
|
+
*/
|
|
21
|
+
export declare function statusLine(monitor: SceneMonitor, now: number): string;
|
|
22
|
+
/** The badge word. `latched` wins over `verdict`, because the latch is the
|
|
23
|
+
* thing the operator asked to be told and it outlives the live state. */
|
|
24
|
+
export declare function badgeFor(monitor: SceneMonitor): {
|
|
25
|
+
text: string;
|
|
26
|
+
tone: 'alarm' | 'ok' | 'unknown';
|
|
27
|
+
};
|
|
28
|
+
/** A rect covering most of the frame is dominated by weather, sky and parked
|
|
29
|
+
* cars: the bin is 2 % of the pixels and CLIP will not notice it leaving. The
|
|
30
|
+
* failure is silent INSENSITIVITY, which is the worst kind — hence a warning
|
|
31
|
+
* rather than a refusal. */
|
|
32
|
+
export declare const ROI_AREA_WARN_FRACTION = 0.4;
|
|
33
|
+
export declare function roiTooLarge(roi: {
|
|
34
|
+
width: number;
|
|
35
|
+
height: number;
|
|
36
|
+
}): boolean;
|
|
37
|
+
export declare function humanDuration(ms: number): string;
|