add drag to zoom

This commit is contained in:
Josh Hawkins 2026-03-23 12:53:42 -05:00
parent 380780b4fa
commit 0392e86577
4 changed files with 150 additions and 36 deletions

View File

@ -619,7 +619,7 @@ class OnvifController:
}, },
"Zoom": {"x": speed}, "Zoom": {"x": speed},
} }
move_request.Translation.Zoom.x = zoom move_request["Translation"]["Zoom"] = {"x": zoom}
await self.cams[camera_name]["ptz"].RelativeMove(move_request) await self.cams[camera_name]["ptz"].RelativeMove(move_request)
@ -628,7 +628,7 @@ class OnvifController:
move_request.Translation.PanTilt.y = 0 move_request.Translation.PanTilt.y = 0
if zoom != 0 and "zoom-r" in self.cams[camera_name]["features"]: 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 self.cams[camera_name]["active"] = False
@ -772,8 +772,18 @@ class OnvifController:
elif command == OnvifCommandEnum.preset: elif command == OnvifCommandEnum.preset:
await self._move_to_preset(camera_name, param) await self._move_to_preset(camera_name, param)
elif command == OnvifCommandEnum.move_relative: elif command == OnvifCommandEnum.move_relative:
_, pan, tilt = param.split("_") parts = param.split("_")
await self._move_relative(camera_name, float(pan), float(tilt), 0, 1) 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): elif command in (OnvifCommandEnum.zoom_in, OnvifCommandEnum.zoom_out):
await self._zoom(camera_name, command) await self._zoom(camera_name, command)
elif command in (OnvifCommandEnum.focus_in, OnvifCommandEnum.focus_out): elif command in (OnvifCommandEnum.focus_in, OnvifCommandEnum.focus_out):

View File

@ -17,6 +17,7 @@
"clickMove": { "clickMove": {
"label": "Click in the frame to center the camera", "label": "Click in the frame to center the camera",
"enable": "Enable click to move", "enable": "Enable click to move",
"enableWithZoom": "Enable click to move / drag to zoom",
"disable": "Disable click to move" "disable": "Disable click to move"
}, },
"left": { "left": {

View File

@ -284,7 +284,9 @@ export default function PtzControlPanel({
<p> <p>
{clickOverlay {clickOverlay
? t("ptz.move.clickMove.disable") ? 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> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>

View File

@ -122,6 +122,11 @@ import {
SnapshotResult, SnapshotResult,
} from "@/utils/snapshotUtil"; } from "@/utils/snapshotUtil";
import ActivityIndicator from "@/components/indicators/activity-indicator"; 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 = { type LiveCameraViewProps = {
config?: FrigateConfig; config?: FrigateConfig;
@ -213,45 +218,112 @@ export default function LiveCameraView({
}; };
}, [audioTranscriptionState, sendTranscription]); }, [audioTranscriptionState, sendTranscription]);
// click overlay for ptzs // click-to-move / drag-to-zoom overlay for PTZ cameras
const [clickOverlay, setClickOverlay] = useState(false); const [clickOverlay, setClickOverlay] = useState(false);
const clickOverlayRef = useRef<HTMLDivElement>(null); const clickOverlayRef = useRef<HTMLDivElement>(null);
const { send: sendPtz } = usePtzCommand(camera.name); const { send: sendPtz } = usePtzCommand(camera.name);
const handleOverlayClick = useCallback( // drag rectangle state in stage-local coordinates
( const [ptzRect, setPtzRect] = useState<{
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>, x: number;
) => { y: number;
if (!clickOverlay) { width: number;
return; 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; const [overlaySize] = useResizeObserver(clickOverlayRef);
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;
}
if (clickOverlayRef.current && clientX && clientY) { const onPtzStageDown = useCallback(
const rect = clickOverlayRef.current.getBoundingClientRect(); (e: KonvaEventObject<MouseEvent> | KonvaEventObject<TouchEvent>) => {
const pos = e.target.getStage()?.getPointerPosition();
const normalizedX = (clientX - rect.left) / rect.width; if (pos) {
const normalizedY = (clientY - rect.top) / rect.height; setIsPtzDrawing(true);
ptzOriginRef.current = { x: pos.x, y: pos.y };
const pan = (normalizedX - 0.5) * 2; setPtzRect({ x: pos.x, y: pos.y, width: 0, height: 0 });
const tilt = (0.5 - normalizedY) * 2;
sendPtz(`move_relative_${pan}_${tilt}`);
} }
}, },
[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 // pip state
useEffect(() => { useEffect(() => {
@ -440,7 +512,8 @@ export default function LiveCameraView({
<TransformWrapper <TransformWrapper
minScale={1.0} minScale={1.0}
wheel={{ smoothStep: 0.005 }} wheel={{ smoothStep: 0.005 }}
disabled={debug} disabled={debug || clickOverlay}
panning={{ disabled: clickOverlay }}
> >
<Toaster position="top-center" closeButton={true} /> <Toaster position="top-center" closeButton={true} />
<div <div
@ -634,13 +707,41 @@ export default function LiveCameraView({
}} }}
> >
<div <div
className={`flex flex-col items-center justify-center ${growClassName}`} className={`relative flex flex-col items-center justify-center ${growClassName}`}
ref={clickOverlayRef} ref={clickOverlayRef}
onClick={handleOverlayClick}
style={{ style={{
aspectRatio: constrainedAspectRatio, 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 <LivePlayer
key={camera.name} key={camera.name}
className={`${fullscreen ? "*:rounded-none" : ""}`} className={`${fullscreen ? "*:rounded-none" : ""}`}