From 0392e8657767346ac921a6765c1708ffa13d715f Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 23 Mar 2026 12:53:42 -0500
Subject: [PATCH] add drag to zoom
---
frigate/ptz/onvif.py | 18 +-
web/public/locales/en/views/live.json | 1 +
.../components/overlay/PtzControlPanel.tsx | 4 +-
web/src/views/live/LiveCameraView.tsx | 163 ++++++++++++++----
4 files changed, 150 insertions(+), 36 deletions(-)
diff --git a/frigate/ptz/onvif.py b/frigate/ptz/onvif.py
index 7f7f22fc1..51e652af6 100644
--- a/frigate/ptz/onvif.py
+++ b/frigate/ptz/onvif.py
@@ -619,7 +619,7 @@ class OnvifController:
},
"Zoom": {"x": speed},
}
- move_request.Translation.Zoom.x = zoom
+ move_request["Translation"]["Zoom"] = {"x": zoom}
await self.cams[camera_name]["ptz"].RelativeMove(move_request)
@@ -628,7 +628,7 @@ class OnvifController:
move_request.Translation.PanTilt.y = 0
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]:
- move_request.Translation.Zoom.x = 0
+ del move_request["Translation"]["Zoom"]
self.cams[camera_name]["active"] = False
@@ -772,8 +772,18 @@ class OnvifController:
elif command == OnvifCommandEnum.preset:
await self._move_to_preset(camera_name, param)
elif command == OnvifCommandEnum.move_relative:
- _, pan, tilt = param.split("_")
- await self._move_relative(camera_name, float(pan), float(tilt), 0, 1)
+ parts = param.split("_")
+ if len(parts) == 3:
+ _, pan, tilt = parts
+ zoom = 0.0
+ elif len(parts) == 4:
+ _, pan, tilt, zoom = parts
+ else:
+ logger.error(f"Invalid move_relative params: {param}")
+ return
+ await self._move_relative(
+ camera_name, float(pan), float(tilt), float(zoom), 1
+ )
elif command in (OnvifCommandEnum.zoom_in, OnvifCommandEnum.zoom_out):
await self._zoom(camera_name, command)
elif command in (OnvifCommandEnum.focus_in, OnvifCommandEnum.focus_out):
diff --git a/web/public/locales/en/views/live.json b/web/public/locales/en/views/live.json
index 878470187..37e6b15db 100644
--- a/web/public/locales/en/views/live.json
+++ b/web/public/locales/en/views/live.json
@@ -17,6 +17,7 @@
"clickMove": {
"label": "Click in the frame to center the camera",
"enable": "Enable click to move",
+ "enableWithZoom": "Enable click to move / drag to zoom",
"disable": "Disable click to move"
},
"left": {
diff --git a/web/src/components/overlay/PtzControlPanel.tsx b/web/src/components/overlay/PtzControlPanel.tsx
index 5deb62fd3..32e2c26f1 100644
--- a/web/src/components/overlay/PtzControlPanel.tsx
+++ b/web/src/components/overlay/PtzControlPanel.tsx
@@ -284,7 +284,9 @@ export default function PtzControlPanel({
{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")}
diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx
index 418c74068..f8a36eb7b 100644
--- a/web/src/views/live/LiveCameraView.tsx
+++ b/web/src/views/live/LiveCameraView.tsx
@@ -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(null);
const { send: sendPtz } = usePtzCommand(camera.name);
- const handleOverlayClick = useCallback(
- (
- e: React.MouseEvent | React.TouchEvent,
- ) => {
- 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 | KonvaEventObject) => {
+ 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 | KonvaEventObject) => {
+ 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({
+ {clickOverlay && overlaySize.width > 0 && (
+
+
+
+ {ptzRect && (
+
+ )}
+
+
+
+ )}