mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-05 14:47:40 +03:00
add drag to zoom
This commit is contained in:
parent
380780b4fa
commit
0392e86577
@ -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):
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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" : ""}`}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user