mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-26 06:11:54 +03:00
Compare commits
2 Commits
08be019bed
...
ae60197cb0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae60197cb0 | ||
|
|
407817a3b1 |
@ -143,6 +143,11 @@ If your ONVIF camera does not require authentication credentials, you may still
|
||||
|
||||
:::
|
||||
|
||||
If a camera connects but fails to authenticate, two optional fields can help:
|
||||
|
||||
- `tls_insecure`: Skips TLS certificate verification and sends the ONVIF password as plaintext (`PasswordText`) instead of a hashed digest (`PasswordDigest`). Some cameras reject the digest token and only accept plaintext. This weakens connection security, so only enable it on a trusted local network.
|
||||
- `ignore_time_mismatch`: ONVIF authentication tokens include a timestamp, and a camera will reject the token if its clock differs too much from Frigate's. Enabling this makes Frigate compensate for the time offset so authentication can still succeed. Running NTP on both the camera and the Frigate host is the recommended fix; only use this in a "safe" environment, as it slightly weakens token validation.
|
||||
|
||||
If your camera has multiple ONVIF profiles, you can specify which one to use for PTZ control with the `profile` option, matched by token or name. When not set, Frigate selects the first profile with a valid PTZ configuration. Check the Frigate debug logs (`frigate.ptz.onvif: debug`) to see available profile names and tokens for your camera.
|
||||
|
||||
An ONVIF-capable camera that supports relative movement within the field of view (FOV) can also be configured to automatically track moving objects and keep them in the center of the frame. For autotracking setup, see the [autotracking](autotracking.md) docs.
|
||||
|
||||
@ -153,7 +153,7 @@ Clicking a preview clip seeks the recording player to that timestamp so you can
|
||||
|
||||
Motion Search lets you scan recorded footage for changes inside a region of interest you draw on the camera. Unlike Motion Previews, which surfaces what Frigate's motion detector flagged in real time, Motion Search re-analyzes the saved recordings, so it can find changes that were missed (for example, an object that appeared while motion detection was paused by `lightning_threshold`, or in a region that is normally motion-masked).
|
||||
|
||||
To start a search, click the kebab menu on a camera in the <NavPath path="Review > Motion" /> page and choose **Motion Search**. In the dialog:
|
||||
To start a search, open the Actions menu in History or click the kebab menu on a camera in the <NavPath path="Review > Motion" /> page and choose **Motion Search**. In the dialog:
|
||||
|
||||
1. Pick the camera and time range to scan.
|
||||
2. Draw a polygon on the camera frame to define the region of interest.
|
||||
@ -170,3 +170,21 @@ To start a search, click the kebab menu on a camera in the <NavPath path="Review
|
||||
Once running, Frigate scans the recording segments that overlap the time range and reports timestamps where changes were detected inside the polygon, along with the percentage of the ROI that changed. Clicking a result seeks the player to that moment so you can review what happened.
|
||||
|
||||
The status panel shows live progress and metrics such as how many segments were scanned, how many were skipped because no motion was recorded for that segment (using the stored motion heatmap), how many frames were decoded, and the total wall-clock time. Segments with no recorded motion in the selected ROI are skipped automatically, which is what makes searching long time ranges practical.
|
||||
|
||||
#### Common use cases
|
||||
|
||||
Frigate's main use case is to record and surface tracked objects, so Motion Search is most useful for the cases where object detection produced nothing — there is no object to find in Explore, but you suspect something happened.
|
||||
|
||||
- **Locating an unattributed change.** You know something appeared, disappeared, or moved in a window of footage — a package now gone, a gate left open — but no detection points to it. A search returns the candidate timestamps instead of scrubbing the timeline by hand.
|
||||
- **An object that was never detected.** Something Frigate doesn't have a model label for, an object too small or distant to be detected, or movement in a region where detection isn't running. The activity left no tracked object but did change the pixels, so a search can still find it.
|
||||
- **Activity while detection was effectively paused.** Changes that occurred while object detection was disabled, motion was suppressed by `skip_motion_threshold`, or inside an area covered by a motion mask, won't appear as review items or tracked objects but can be recovered by searching the recordings directly.
|
||||
|
||||
#### Expected performance
|
||||
|
||||
Motion Search analyzes the saved recordings on demand rather than reading a pre-built index, so a search over a long range takes longer than browsing Motion Previews. Cost scales mainly with how much footage has to be examined: segments with no recorded motion in your ROI are skipped using the stored motion heatmap (shown as "segments skipped" in the status panel), so a quiet range finishes quickly while a busy one takes longer.
|
||||
|
||||
To increase the speed of searches:
|
||||
|
||||
- Draw a tight ROI. Because **Minimum Change Area** is measured as a percentage of the region you draw, a tight ROI around where you expect the change makes the object fill a larger share of the area, so it clears the threshold more easily. A loose ROI makes the same object a small fraction of the region, so it can fall below the threshold and be missed — forcing you to lower Minimum Change Area, which lets in more noise.
|
||||
- Keep Frame Skip high. A higher value samples fewer frames and speeds up the search considerably, while still landing within a few seconds of when the motion or object appeared — close enough to seek to in the recording. Only lower it when you need to pinpoint the exact frame something appears or disappears.
|
||||
- Use Parallel mode to shorten wall-clock time on multi-core systems, at the cost of higher CPU usage while it runs.
|
||||
|
||||
@ -529,6 +529,68 @@ def _extract_fps(r_frame_rate: str) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def _build_digest_transport(username: str, password: str) -> AsyncTransport:
|
||||
"""Build a zeep transport backed by an httpx client using HTTP digest auth."""
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
return AsyncTransport(client=client)
|
||||
|
||||
|
||||
async def _connect_onvif_camera(
|
||||
host: str,
|
||||
port: int,
|
||||
username: str,
|
||||
password: str,
|
||||
wsdl_base: str | None,
|
||||
auth_type: str,
|
||||
) -> ONVIFCamera:
|
||||
"""Connect to an ONVIF device, trying both WS-Security password encodings.
|
||||
|
||||
Cameras disagree on whether the WS-Security UsernameToken should carry a
|
||||
hashed PasswordDigest or a plaintext PasswordText. The wizard can't know
|
||||
which a given camera expects, so we try PasswordDigest first (the common
|
||||
case) and fall back to PasswordText when the device rejects the token. This
|
||||
is independent of auth_type, which controls HTTP transport-level auth.
|
||||
"""
|
||||
first_error: Fault | None = None
|
||||
|
||||
# encrypt=True -> PasswordDigest, encrypt=False -> PasswordText
|
||||
for encrypt in (True, False):
|
||||
onvif_camera = ONVIFCamera(
|
||||
host,
|
||||
port,
|
||||
username or "",
|
||||
password or "",
|
||||
wsdl_dir=wsdl_base,
|
||||
encrypt=encrypt,
|
||||
)
|
||||
|
||||
try:
|
||||
await onvif_camera.update_xaddrs()
|
||||
except Fault as e:
|
||||
# A SOAP fault here is how a camera signals the wrong password
|
||||
# encoding, so retry with the other encoding before giving up.
|
||||
logger.debug(
|
||||
"ONVIF connect with %s rejected, trying alternate encoding",
|
||||
"PasswordDigest" if encrypt else "PasswordText",
|
||||
)
|
||||
if first_error is None:
|
||||
first_error = e
|
||||
continue
|
||||
|
||||
if auth_type == "digest" and username and password:
|
||||
transport = _build_digest_transport(username, password)
|
||||
for service in ("devicemgmt", "media", "ptz"):
|
||||
if hasattr(onvif_camera, service):
|
||||
getattr(onvif_camera, service).zeep_client.transport = transport
|
||||
logger.debug("Configured digest authentication")
|
||||
|
||||
return onvif_camera
|
||||
|
||||
# Both encodings failed authentication; surface the original fault.
|
||||
raise first_error
|
||||
|
||||
|
||||
@router.get(
|
||||
"/onvif/probe",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
@ -605,34 +667,10 @@ async def onvif_probe(
|
||||
except Exception:
|
||||
wsdl_base = None
|
||||
|
||||
onvif_camera = ONVIFCamera(
|
||||
host, port, username or "", password or "", wsdl_dir=wsdl_base
|
||||
onvif_camera = await _connect_onvif_camera(
|
||||
host, port, username, password, wsdl_base, auth_type
|
||||
)
|
||||
|
||||
# Configure digest authentication if requested
|
||||
if auth_type == "digest" and username and password:
|
||||
# Create httpx client with digest auth
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
|
||||
# Replace the transport in the zeep client
|
||||
transport = AsyncTransport(client=client)
|
||||
|
||||
# Update the xaddr before setting transport
|
||||
await onvif_camera.update_xaddrs()
|
||||
|
||||
# Replace transport in all services
|
||||
if hasattr(onvif_camera, "devicemgmt"):
|
||||
onvif_camera.devicemgmt.zeep_client.transport = transport
|
||||
if hasattr(onvif_camera, "media"):
|
||||
onvif_camera.media.zeep_client.transport = transport
|
||||
if hasattr(onvif_camera, "ptz"):
|
||||
onvif_camera.ptz.zeep_client.transport = transport
|
||||
|
||||
logger.debug("Configured digest authentication")
|
||||
else:
|
||||
await onvif_camera.update_xaddrs()
|
||||
|
||||
# Get device information
|
||||
device_info = {
|
||||
"manufacturer": "Unknown",
|
||||
@ -644,10 +682,9 @@ async def onvif_probe(
|
||||
|
||||
# Update transport for device service if digest auth
|
||||
if auth_type == "digest" and username and password:
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
transport = AsyncTransport(client=client)
|
||||
device_service.zeep_client.transport = transport
|
||||
device_service.zeep_client.transport = _build_digest_transport(
|
||||
username, password
|
||||
)
|
||||
|
||||
device_info_resp = await device_service.GetDeviceInformation()
|
||||
manufacturer = getattr(device_info_resp, "Manufacturer", None) or (
|
||||
@ -685,10 +722,9 @@ async def onvif_probe(
|
||||
|
||||
# Update transport for media service if digest auth
|
||||
if auth_type == "digest" and username and password:
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
transport = AsyncTransport(client=client)
|
||||
media_service.zeep_client.transport = transport
|
||||
media_service.zeep_client.transport = _build_digest_transport(
|
||||
username, password
|
||||
)
|
||||
|
||||
profiles = await media_service.GetProfiles()
|
||||
profiles_count = len(profiles) if profiles else 0
|
||||
@ -720,10 +756,9 @@ async def onvif_probe(
|
||||
|
||||
# Update transport for PTZ service if digest auth
|
||||
if auth_type == "digest" and username and password:
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
transport = AsyncTransport(client=client)
|
||||
ptz_service.zeep_client.transport = transport
|
||||
ptz_service.zeep_client.transport = _build_digest_transport(
|
||||
username, password
|
||||
)
|
||||
|
||||
# Check if PTZ service is available
|
||||
try:
|
||||
@ -876,10 +911,9 @@ async def onvif_probe(
|
||||
|
||||
# Update transport for media service if digest auth
|
||||
if auth_type == "digest" and username and password:
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
transport = AsyncTransport(client=client)
|
||||
media_service.zeep_client.transport = transport
|
||||
media_service.zeep_client.transport = _build_digest_transport(
|
||||
username, password
|
||||
)
|
||||
|
||||
if profiles_count and media_service:
|
||||
for p in profiles or []:
|
||||
|
||||
@ -42,9 +42,9 @@ class MotionSearchRequest(BaseModel):
|
||||
description="Minimum change area as a percentage of the ROI",
|
||||
)
|
||||
frame_skip: int = Field(
|
||||
default=5,
|
||||
default=30,
|
||||
ge=1,
|
||||
le=30,
|
||||
le=120,
|
||||
description="Process every Nth frame (1=all frames, 5=every 5th frame)",
|
||||
)
|
||||
parallel: bool = Field(
|
||||
|
||||
124
frigate/test/test_onvif_probe.py
Normal file
124
frigate/test/test_onvif_probe.py
Normal file
@ -0,0 +1,124 @@
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from zeep.exceptions import Fault, TransportError
|
||||
from zeep.transports import AsyncTransport
|
||||
|
||||
from frigate.api.camera import _build_digest_transport, _connect_onvif_camera
|
||||
|
||||
|
||||
def _make_camera(update_side_effect=None):
|
||||
"""Build a mock ONVIFCamera whose update_xaddrs can raise or succeed."""
|
||||
camera = MagicMock()
|
||||
camera.update_xaddrs = AsyncMock(side_effect=update_side_effect)
|
||||
return camera
|
||||
|
||||
|
||||
class TestConnectOnvifCamera(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_password_digest_succeeds_first(self):
|
||||
# Cameras that accept PasswordDigest authenticate on the first attempt
|
||||
# and should never trigger the PasswordText fallback.
|
||||
camera = _make_camera()
|
||||
|
||||
with patch("frigate.api.camera.ONVIFCamera", return_value=camera) as mock_cls:
|
||||
result = await _connect_onvif_camera(
|
||||
"cam.local", 80, "user", "pass", None, "basic"
|
||||
)
|
||||
|
||||
self.assertIs(result, camera)
|
||||
mock_cls.assert_called_once()
|
||||
self.assertTrue(mock_cls.call_args.kwargs["encrypt"])
|
||||
|
||||
async def test_falls_back_to_password_text(self):
|
||||
# A PasswordDigest rejection should retry once with PasswordText.
|
||||
camera_digest = _make_camera(update_side_effect=Fault("token rejected"))
|
||||
camera_text = _make_camera()
|
||||
|
||||
with patch(
|
||||
"frigate.api.camera.ONVIFCamera",
|
||||
side_effect=[camera_digest, camera_text],
|
||||
) as mock_cls:
|
||||
result = await _connect_onvif_camera(
|
||||
"cam.local", 80, "user", "pass", None, "basic"
|
||||
)
|
||||
|
||||
self.assertIs(result, camera_text)
|
||||
self.assertEqual(mock_cls.call_count, 2)
|
||||
self.assertTrue(mock_cls.call_args_list[0].kwargs["encrypt"])
|
||||
self.assertFalse(mock_cls.call_args_list[1].kwargs["encrypt"])
|
||||
|
||||
async def test_both_encodings_fail_raises_first_fault(self):
|
||||
# When both encodings fault, the original (PasswordDigest) fault is
|
||||
# surfaced so the caller's existing Fault handler reports it.
|
||||
first_fault = Fault("digest rejected")
|
||||
camera_digest = _make_camera(update_side_effect=first_fault)
|
||||
camera_text = _make_camera(update_side_effect=Fault("text rejected"))
|
||||
|
||||
with patch(
|
||||
"frigate.api.camera.ONVIFCamera",
|
||||
side_effect=[camera_digest, camera_text],
|
||||
) as mock_cls:
|
||||
with self.assertRaises(Fault) as ctx:
|
||||
await _connect_onvif_camera(
|
||||
"cam.local", 80, "user", "pass", None, "basic"
|
||||
)
|
||||
|
||||
self.assertIs(ctx.exception, first_fault)
|
||||
self.assertEqual(mock_cls.call_count, 2)
|
||||
|
||||
async def test_transport_error_is_not_retried(self):
|
||||
# Connection-level errors (timeout, refused, unreachable) should
|
||||
# propagate immediately without doubling latency on a second encoding.
|
||||
camera = _make_camera(update_side_effect=TransportError("unreachable"))
|
||||
|
||||
with patch("frigate.api.camera.ONVIFCamera", side_effect=[camera]) as mock_cls:
|
||||
with self.assertRaises(TransportError):
|
||||
await _connect_onvif_camera(
|
||||
"cam.local", 80, "user", "pass", None, "basic"
|
||||
)
|
||||
|
||||
mock_cls.assert_called_once()
|
||||
|
||||
async def test_digest_auth_replaces_service_transports(self):
|
||||
# auth_type "digest" wires an HTTP digest transport onto each service,
|
||||
# independently of the WS-Security encoding.
|
||||
camera = _make_camera()
|
||||
|
||||
with (
|
||||
patch("frigate.api.camera.ONVIFCamera", return_value=camera),
|
||||
patch(
|
||||
"frigate.api.camera._build_digest_transport",
|
||||
return_value="TRANSPORT",
|
||||
) as mock_transport,
|
||||
):
|
||||
result = await _connect_onvif_camera(
|
||||
"cam.local", 80, "user", "pass", None, "digest"
|
||||
)
|
||||
|
||||
self.assertIs(result, camera)
|
||||
mock_transport.assert_called_once_with("user", "pass")
|
||||
self.assertEqual(camera.devicemgmt.zeep_client.transport, "TRANSPORT")
|
||||
self.assertEqual(camera.media.zeep_client.transport, "TRANSPORT")
|
||||
self.assertEqual(camera.ptz.zeep_client.transport, "TRANSPORT")
|
||||
|
||||
async def test_basic_auth_does_not_replace_transports(self):
|
||||
# Without digest auth, no transport override is built.
|
||||
camera = _make_camera()
|
||||
|
||||
with (
|
||||
patch("frigate.api.camera.ONVIFCamera", return_value=camera),
|
||||
patch("frigate.api.camera._build_digest_transport") as mock_transport,
|
||||
):
|
||||
await _connect_onvif_camera("cam.local", 80, "user", "pass", None, "basic")
|
||||
|
||||
mock_transport.assert_not_called()
|
||||
|
||||
|
||||
class TestBuildDigestTransport(unittest.TestCase):
|
||||
def test_returns_async_transport(self):
|
||||
transport = _build_digest_transport("user", "pass")
|
||||
self.assertIsInstance(transport, AsyncTransport)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -24,7 +24,9 @@
|
||||
"points_one": "{{count}} point",
|
||||
"points_other": "{{count}} points",
|
||||
"undo": "Undo last point",
|
||||
"reset": "Reset polygon"
|
||||
"reset": "Reset polygon",
|
||||
"drawMode": "Draw",
|
||||
"moveMode": "Move"
|
||||
},
|
||||
"motionHeatmapLabel": "Motion Heatmap",
|
||||
"dialog": {
|
||||
|
||||
@ -3,9 +3,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { isDesktop, isIOS, isMobile } from "react-device-detect";
|
||||
import { FaArrowRight, FaCalendarAlt, FaCheckCircle } from "react-icons/fa";
|
||||
import { MdOutlineRestartAlt, MdUndo } from "react-icons/md";
|
||||
import { LuHand, LuPencil } from "react-icons/lu";
|
||||
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
import { ASPECT_PORTRAIT_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@ -113,12 +115,34 @@ export default function MotionSearchDialog({
|
||||
const { t } = useTranslation(["views/motionSearch", "common"]);
|
||||
const apiHost = useApiHost();
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [panMode, setPanMode] = useState(false);
|
||||
|
||||
const cameraConfig = useMemo(() => {
|
||||
if (!selectedCamera) return undefined;
|
||||
return config.cameras[selectedCamera];
|
||||
}, [config, selectedCamera]);
|
||||
|
||||
const aspectRatio = useMemo(() => {
|
||||
if (!cameraConfig) {
|
||||
return 16 / 9;
|
||||
}
|
||||
|
||||
return cameraConfig.detect.width / cameraConfig.detect.height;
|
||||
}, [cameraConfig]);
|
||||
|
||||
// Determine camera aspect ratio category
|
||||
const cameraAspect = useMemo(() => {
|
||||
if (!aspectRatio) {
|
||||
return "normal";
|
||||
} else if (aspectRatio > ASPECT_WIDE_LAYOUT) {
|
||||
return "wide";
|
||||
} else if (aspectRatio < ASPECT_PORTRAIT_LAYOUT) {
|
||||
return "tall";
|
||||
} else {
|
||||
return "normal";
|
||||
}
|
||||
}, [aspectRatio]);
|
||||
|
||||
const polygonClosed = useMemo(
|
||||
() => !isDrawingROI && polygonPoints.length >= 3,
|
||||
[isDrawingROI, polygonPoints.length],
|
||||
@ -144,6 +168,7 @@ export default function MotionSearchDialog({
|
||||
|
||||
useEffect(() => {
|
||||
setImageLoaded(false);
|
||||
setPanMode(false);
|
||||
}, [selectedCamera]);
|
||||
|
||||
const Overlay = isDesktop ? Dialog : Drawer;
|
||||
@ -218,7 +243,13 @@ export default function MotionSearchDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
|
||||
<TransformWrapper
|
||||
minScale={1.0}
|
||||
wheel={{ smoothStep: 0.005 }}
|
||||
panning={{ disabled: !isDesktop && !panMode }}
|
||||
pinch={{ disabled: !isDesktop && !panMode }}
|
||||
doubleClick={{ disabled: !isDesktop && !panMode }}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<TransformComponent
|
||||
wrapperStyle={{
|
||||
@ -231,7 +262,15 @@ export default function MotionSearchDialog({
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<div className="relative flex aspect-video w-full items-center justify-center overflow-hidden rounded-lg border bg-secondary">
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-auto flex items-center justify-center overflow-hidden rounded-lg border bg-secondary",
|
||||
cameraAspect === "tall"
|
||||
? "max-h-[50dvh] lg:max-h-[60dvh]"
|
||||
: "w-full",
|
||||
)}
|
||||
style={{ aspectRatio }}
|
||||
>
|
||||
{selectedCamera && cameraConfig ? (
|
||||
<div className="relative h-full w-full">
|
||||
<img
|
||||
@ -261,6 +300,7 @@ export default function MotionSearchDialog({
|
||||
isDrawing={isDrawingROI}
|
||||
setIsDrawing={setIsDrawingROI}
|
||||
isInteractive={true}
|
||||
panMode={panMode}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@ -282,11 +322,41 @@ export default function MotionSearchDialog({
|
||||
{polygonClosed && <FaCheckCircle className="ml-2 size-5" />}
|
||||
</div>
|
||||
<div className="flex flex-row justify-center gap-2">
|
||||
{!isDesktop && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={panMode ? "select" : "default"}
|
||||
className="size-8 rounded-md p-1.5"
|
||||
aria-label={
|
||||
panMode
|
||||
? t("polygonControls.moveMode")
|
||||
: t("polygonControls.drawMode")
|
||||
}
|
||||
onClick={() => setPanMode((prev) => !prev)}
|
||||
>
|
||||
{panMode ? (
|
||||
<LuHand className="text-selected-foreground" />
|
||||
) : (
|
||||
<LuPencil className="text-secondary-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{panMode
|
||||
? t("polygonControls.moveMode")
|
||||
: t("polygonControls.drawMode")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className="size-6 rounded-md p-1"
|
||||
className={cn(
|
||||
"rounded-md",
|
||||
isDesktop ? "size-6 p-1" : "size-8 p-1.5",
|
||||
)}
|
||||
aria-label={t("polygonControls.undo")}
|
||||
disabled={polygonPoints.length === 0 || isSearching}
|
||||
onClick={undoPolygonPoint}
|
||||
@ -302,7 +372,10 @@ export default function MotionSearchDialog({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className="size-6 rounded-md p-1"
|
||||
className={cn(
|
||||
"rounded-md",
|
||||
isDesktop ? "size-6 p-1" : "size-8 p-1.5",
|
||||
)}
|
||||
aria-label={t("polygonControls.reset")}
|
||||
disabled={polygonPoints.length === 0 || isSearching}
|
||||
onClick={resetPolygon}
|
||||
@ -370,7 +443,7 @@ export default function MotionSearchDialog({
|
||||
<Slider
|
||||
id="frameSkip"
|
||||
min={1}
|
||||
max={60}
|
||||
max={120}
|
||||
step={1}
|
||||
value={[frameSkip]}
|
||||
onValueChange={([value]) => setFrameSkip(value)}
|
||||
|
||||
@ -14,6 +14,7 @@ type MotionSearchROICanvasProps = {
|
||||
isDrawing: boolean;
|
||||
setIsDrawing: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isInteractive?: boolean;
|
||||
panMode?: boolean;
|
||||
motionHeatmap?: Record<string, number> | null;
|
||||
showMotionHeatmap?: boolean;
|
||||
};
|
||||
@ -26,6 +27,7 @@ export default function MotionSearchROICanvas({
|
||||
isDrawing,
|
||||
setIsDrawing,
|
||||
isInteractive = true,
|
||||
panMode = false,
|
||||
motionHeatmap,
|
||||
showMotionHeatmap = false,
|
||||
}: MotionSearchROICanvasProps) {
|
||||
@ -341,7 +343,9 @@ export default function MotionSearchROICanvas({
|
||||
ref={setContainerNode}
|
||||
className={cn(
|
||||
"absolute inset-0 z-10",
|
||||
isInteractive ? "pointer-events-auto" : "pointer-events-none",
|
||||
isInteractive && !panMode
|
||||
? "pointer-events-auto"
|
||||
: "pointer-events-none",
|
||||
)}
|
||||
style={{ cursor: isDrawing ? "crosshair" : "default" }}
|
||||
>
|
||||
|
||||
@ -146,7 +146,7 @@ export default function MotionSearchView({
|
||||
const [parallelMode, setParallelMode] = useState(false);
|
||||
const [threshold, setThreshold] = useState(30);
|
||||
const [minArea, setMinArea] = useState(20);
|
||||
const [frameSkip, setFrameSkip] = useState(10);
|
||||
const [frameSkip, setFrameSkip] = useState(30);
|
||||
const [maxResults, setMaxResults] = useState(25);
|
||||
|
||||
// Job state
|
||||
@ -846,7 +846,13 @@ export default function MotionSearchView({
|
||||
responseData.errors;
|
||||
|
||||
if (Array.isArray(apiMessage)) {
|
||||
errorMessage = apiMessage.join(", ");
|
||||
errorMessage = apiMessage
|
||||
.map((item) =>
|
||||
typeof item === "string"
|
||||
? item
|
||||
: ((item as { msg?: string })?.msg ?? JSON.stringify(item)),
|
||||
)
|
||||
.join(", ");
|
||||
} else if (typeof apiMessage === "string") {
|
||||
errorMessage = apiMessage;
|
||||
} else if (apiMessage) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user