mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-01 08:32:18 +03:00
ONVIF refactor (#22629)
* add profile support and decouple relative move from autotracking * add drag to zoom * docs * add profile selection to UI * dynamically update onvif config * ui tweak * docs * docs tweak
This commit is contained in:
@@ -3,20 +3,12 @@ import type { SectionConfigOverrides } from "./types";
|
||||
const onvif: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/cameras#setting-up-camera-ptz-controls",
|
||||
restartRequired: [
|
||||
"host",
|
||||
"port",
|
||||
"user",
|
||||
"password",
|
||||
"tls_insecure",
|
||||
"ignore_time_mismatch",
|
||||
"autotracking.calibrate_on_startup",
|
||||
],
|
||||
fieldOrder: [
|
||||
"host",
|
||||
"port",
|
||||
"user",
|
||||
"password",
|
||||
"profile",
|
||||
"tls_insecure",
|
||||
"ignore_time_mismatch",
|
||||
"autotracking",
|
||||
@@ -27,10 +19,14 @@ const onvif: SectionConfigOverrides = {
|
||||
],
|
||||
advancedFields: ["tls_insecure", "ignore_time_mismatch"],
|
||||
overrideFields: [],
|
||||
restartRequired: ["autotracking.calibrate_on_startup"],
|
||||
uiSchema: {
|
||||
host: {
|
||||
"ui:options": { size: "sm" },
|
||||
},
|
||||
profile: {
|
||||
"ui:widget": "onvifProfile",
|
||||
},
|
||||
autotracking: {
|
||||
required_zones: {
|
||||
"ui:widget": "zoneNames",
|
||||
|
||||
@@ -29,6 +29,7 @@ import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
|
||||
import { CameraPathWidget } from "./widgets/CameraPathWidget";
|
||||
import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
|
||||
import { SemanticSearchModelWidget } from "./widgets/SemanticSearchModelWidget";
|
||||
import { OnvifProfileWidget } from "./widgets/OnvifProfileWidget";
|
||||
|
||||
import { FieldTemplate } from "./templates/FieldTemplate";
|
||||
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
|
||||
@@ -79,6 +80,7 @@ export const frigateTheme: FrigateTheme = {
|
||||
timezoneSelect: TimezoneSelectWidget,
|
||||
optionalField: OptionalFieldWidget,
|
||||
semanticSearchModel: SemanticSearchModelWidget,
|
||||
onvifProfile: OnvifProfileWidget,
|
||||
},
|
||||
templates: {
|
||||
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import useSWR from "swr";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import type { CameraPtzInfo } from "@/types/ptz";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const AUTO_VALUE = "__auto__";
|
||||
|
||||
export function OnvifProfileWidget(props: WidgetProps) {
|
||||
const { id, value, disabled, readonly, onChange, schema, options } = props;
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
const cameraName = formContext?.cameraName;
|
||||
const isCameraLevel = formContext?.level === "camera";
|
||||
const hasOnvifHost = !!formContext?.fullCameraConfig?.onvif?.host;
|
||||
|
||||
const { data: ptzInfo } = useSWR<CameraPtzInfo>(
|
||||
isCameraLevel && cameraName && hasOnvifHost
|
||||
? `${cameraName}/ptz/info`
|
||||
: null,
|
||||
{
|
||||
// ONVIF may not be initialized yet when the settings page loads,
|
||||
// so retry until profiles become available
|
||||
refreshInterval: (data) =>
|
||||
data?.profiles && data.profiles.length > 0 ? 0 : 5000,
|
||||
},
|
||||
);
|
||||
|
||||
const profiles = ptzInfo?.profiles ?? [];
|
||||
const fieldClassName = getSizedFieldClassName(options, "md");
|
||||
const hasProfiles = profiles.length > 0;
|
||||
const waiting = isCameraLevel && !!cameraName && hasOnvifHost && !hasProfiles;
|
||||
|
||||
const selected = value ?? AUTO_VALUE;
|
||||
|
||||
if (waiting) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", fieldClassName)}>
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("onvif.profileLoading")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={String(selected)}
|
||||
onValueChange={(val) => {
|
||||
onChange(val === AUTO_VALUE ? null : val);
|
||||
}}
|
||||
disabled={disabled || readonly}
|
||||
>
|
||||
<SelectTrigger id={id} className={cn("text-left", fieldClassName)}>
|
||||
<SelectValue placeholder={schema.title || "Select..."} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={AUTO_VALUE}>{t("onvif.profileAuto")}</SelectItem>
|
||||
{profiles.map((p) => (
|
||||
<SelectItem key={p.token} value={p.token}>
|
||||
{p.name !== p.token ? `${p.name} (${p.token})` : p.token}
|
||||
</SelectItem>
|
||||
))}
|
||||
{!hasProfiles && value && value !== AUTO_VALUE && (
|
||||
<SelectItem value={String(value)}>{String(value)}</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -284,7 +284,9 @@ export default function PtzControlPanel({
|
||||
<p>
|
||||
{clickOverlay
|
||||
? t("ptz.move.clickMove.disable")
|
||||
: t("ptz.move.clickMove.enable")}
|
||||
: ptz?.features?.includes("zoom-r")
|
||||
? t("ptz.move.clickMove.enableWithZoom")
|
||||
: t("ptz.move.clickMove.enable")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -7,8 +7,14 @@ type PtzFeature =
|
||||
| "pt-r-fov"
|
||||
| "focus";
|
||||
|
||||
export type OnvifProfile = {
|
||||
name: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type CameraPtzInfo = {
|
||||
name: string;
|
||||
features: PtzFeature[];
|
||||
presets: string[];
|
||||
profiles: OnvifProfile[];
|
||||
};
|
||||
|
||||
@@ -122,6 +122,11 @@ import {
|
||||
SnapshotResult,
|
||||
} from "@/utils/snapshotUtil";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { Stage, Layer, Rect } from "react-konva";
|
||||
import type { KonvaEventObject } from "konva/lib/Node";
|
||||
|
||||
/** Pixel threshold to distinguish drag from click. */
|
||||
const DRAG_MIN_PX = 15;
|
||||
|
||||
type LiveCameraViewProps = {
|
||||
config?: FrigateConfig;
|
||||
@@ -213,45 +218,112 @@ export default function LiveCameraView({
|
||||
};
|
||||
}, [audioTranscriptionState, sendTranscription]);
|
||||
|
||||
// click overlay for ptzs
|
||||
// click-to-move / drag-to-zoom overlay for PTZ cameras
|
||||
|
||||
const [clickOverlay, setClickOverlay] = useState(false);
|
||||
const clickOverlayRef = useRef<HTMLDivElement>(null);
|
||||
const { send: sendPtz } = usePtzCommand(camera.name);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(
|
||||
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (!clickOverlay) {
|
||||
return;
|
||||
}
|
||||
// drag rectangle state in stage-local coordinates
|
||||
const [ptzRect, setPtzRect] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const [isPtzDrawing, setIsPtzDrawing] = useState(false);
|
||||
// raw origin to determine drag direction (not min/max corrected)
|
||||
const ptzOriginRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
let clientX;
|
||||
let clientY;
|
||||
if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) {
|
||||
clientX = e.nativeEvent.touches[0].clientX;
|
||||
clientY = e.nativeEvent.touches[0].clientY;
|
||||
} else if (e.nativeEvent instanceof MouseEvent) {
|
||||
clientX = e.nativeEvent.clientX;
|
||||
clientY = e.nativeEvent.clientY;
|
||||
}
|
||||
const [overlaySize] = useResizeObserver(clickOverlayRef);
|
||||
|
||||
if (clickOverlayRef.current && clientX && clientY) {
|
||||
const rect = clickOverlayRef.current.getBoundingClientRect();
|
||||
|
||||
const normalizedX = (clientX - rect.left) / rect.width;
|
||||
const normalizedY = (clientY - rect.top) / rect.height;
|
||||
|
||||
const pan = (normalizedX - 0.5) * 2;
|
||||
const tilt = (0.5 - normalizedY) * 2;
|
||||
|
||||
sendPtz(`move_relative_${pan}_${tilt}`);
|
||||
const onPtzStageDown = useCallback(
|
||||
(e: KonvaEventObject<MouseEvent> | KonvaEventObject<TouchEvent>) => {
|
||||
const pos = e.target.getStage()?.getPointerPosition();
|
||||
if (pos) {
|
||||
setIsPtzDrawing(true);
|
||||
ptzOriginRef.current = { x: pos.x, y: pos.y };
|
||||
setPtzRect({ x: pos.x, y: pos.y, width: 0, height: 0 });
|
||||
}
|
||||
},
|
||||
[clickOverlayRef, clickOverlay, sendPtz],
|
||||
[],
|
||||
);
|
||||
|
||||
const onPtzStageMove = useCallback(
|
||||
(e: KonvaEventObject<MouseEvent> | KonvaEventObject<TouchEvent>) => {
|
||||
if (!isPtzDrawing || !ptzRect) return;
|
||||
const pos = e.target.getStage()?.getPointerPosition();
|
||||
if (pos) {
|
||||
setPtzRect({
|
||||
...ptzRect,
|
||||
width: pos.x - ptzRect.x,
|
||||
height: pos.y - ptzRect.y,
|
||||
});
|
||||
}
|
||||
},
|
||||
[isPtzDrawing, ptzRect],
|
||||
);
|
||||
|
||||
const onPtzStageUp = useCallback(() => {
|
||||
setIsPtzDrawing(false);
|
||||
|
||||
if (!ptzRect || !ptzOriginRef.current || overlaySize.width === 0) {
|
||||
setPtzRect(null);
|
||||
ptzOriginRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const endX = ptzRect.x + ptzRect.width;
|
||||
const endY = ptzRect.y + ptzRect.height;
|
||||
const distX = Math.abs(ptzRect.width);
|
||||
const distY = Math.abs(ptzRect.height);
|
||||
|
||||
if (distX < DRAG_MIN_PX && distY < DRAG_MIN_PX) {
|
||||
// click — pan/tilt to point without zoom
|
||||
const normX = endX / overlaySize.width;
|
||||
const normY = endY / overlaySize.height;
|
||||
const pan = (normX - 0.5) * 2;
|
||||
const tilt = (0.5 - normY) * 2;
|
||||
sendPtz(`move_relative_${pan}_${tilt}`);
|
||||
} else {
|
||||
// drag — pan/tilt to box center, zoom based on box size
|
||||
const origin = ptzOriginRef.current;
|
||||
|
||||
const n0x = Math.min(origin.x, endX) / overlaySize.width;
|
||||
const n0y = Math.min(origin.y, endY) / overlaySize.height;
|
||||
const n1x = Math.max(origin.x, endX) / overlaySize.width;
|
||||
const n1y = Math.max(origin.y, endY) / overlaySize.height;
|
||||
|
||||
let boxW = n1x - n0x;
|
||||
let boxH = n1y - n0y;
|
||||
|
||||
// correct box to match camera aspect ratio so zoom is uniform
|
||||
const frameAR = overlaySize.width / overlaySize.height;
|
||||
const boxAR = boxW / boxH;
|
||||
if (boxAR > frameAR) {
|
||||
boxH = boxW / frameAR;
|
||||
} else {
|
||||
boxW = boxH * frameAR;
|
||||
}
|
||||
|
||||
const centerX = (n0x + n1x) / 2;
|
||||
const centerY = (n0y + n1y) / 2;
|
||||
const pan = (centerX - 0.5) * 2;
|
||||
const tilt = (0.5 - centerY) * 2;
|
||||
|
||||
// zoom magnitude from box size (small box = more zoom)
|
||||
let zoom = Math.max(0.01, Math.min(1, Math.max(boxW, boxH)));
|
||||
// drag direction: top-left → bottom-right = zoom in, reverse = zoom out
|
||||
const zoomIn = endX > origin.x && endY > origin.y;
|
||||
if (!zoomIn) zoom = -zoom;
|
||||
|
||||
sendPtz(`move_relative_${pan}_${tilt}_${zoom}`);
|
||||
}
|
||||
|
||||
setPtzRect(null);
|
||||
ptzOriginRef.current = null;
|
||||
}, [ptzRect, overlaySize, sendPtz]);
|
||||
|
||||
// pip state
|
||||
|
||||
useEffect(() => {
|
||||
@@ -440,7 +512,8 @@ export default function LiveCameraView({
|
||||
<TransformWrapper
|
||||
minScale={1.0}
|
||||
wheel={{ smoothStep: 0.005 }}
|
||||
disabled={debug}
|
||||
disabled={debug || clickOverlay}
|
||||
panning={{ disabled: clickOverlay }}
|
||||
>
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div
|
||||
@@ -634,13 +707,41 @@ export default function LiveCameraView({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center ${growClassName}`}
|
||||
className={`relative flex flex-col items-center justify-center ${growClassName}`}
|
||||
ref={clickOverlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
style={{
|
||||
aspectRatio: constrainedAspectRatio,
|
||||
}}
|
||||
>
|
||||
{clickOverlay && overlaySize.width > 0 && (
|
||||
<div className="absolute inset-0 z-40 cursor-crosshair">
|
||||
<Stage
|
||||
width={overlaySize.width}
|
||||
height={overlaySize.height}
|
||||
onMouseDown={onPtzStageDown}
|
||||
onMouseMove={onPtzStageMove}
|
||||
onMouseUp={onPtzStageUp}
|
||||
onTouchStart={onPtzStageDown}
|
||||
onTouchMove={onPtzStageMove}
|
||||
onTouchEnd={onPtzStageUp}
|
||||
>
|
||||
<Layer>
|
||||
{ptzRect && (
|
||||
<Rect
|
||||
x={ptzRect.x}
|
||||
y={ptzRect.y}
|
||||
width={ptzRect.width}
|
||||
height={ptzRect.height}
|
||||
stroke="white"
|
||||
strokeWidth={2}
|
||||
dash={[6, 4]}
|
||||
opacity={0.8}
|
||||
/>
|
||||
)}
|
||||
</Layer>
|
||||
</Stage>
|
||||
</div>
|
||||
)}
|
||||
<LivePlayer
|
||||
key={camera.name}
|
||||
className={`${fullscreen ? "*:rounded-none" : ""}`}
|
||||
|
||||
Reference in New Issue
Block a user