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 && ( + + )} + + +
+ )}