Compare commits

...

7 Commits

Author SHA1 Message Date
ryzendigo
ddc64335c5
Merge 952dc1c3ef into 47a06c8b30 2026-06-01 15:00:03 +10:00
Josh Hawkins
47a06c8b30
Tweaks (#23367)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* add ptz presets and default role widgets

* language tweaks

* fix width in triggers view

* tweak iOS PWA message in notifications settings

* deprecate ui.date_style and ui.time_style

these have been unused since date/time formatting has been pushed to i18n

* add config migrator to remove date_style and time_style

* remove date_style and time_style from reference config

* fix camera list scrolling in state classification wizard on mobile
2026-05-31 15:09:10 -06:00
Josh Hawkins
ae60197cb0
Support onvif PasswordText cameras in the add camera wizard (#23365)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* try both onvif WS-Security password encodings when probing in the add camera wizard

* update onvif docs

* add tests
2026-05-31 08:20:09 -06:00
Josh Hawkins
407817a3b1
Motion search fixes (#23359)
* improve error parsing and increase skip default

* improve motion search  layout to match tracking details

* implement draw and move mode on mobile

* update motion search docs

* language tweaks

* improve tips

* note actions menu
2026-05-31 07:51:32 -06:00
Josh Hawkins
08be019bed
Miscellaneous fixes (#23358)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / Assemble and push default build (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
* improve visibility of blurred icon buttons

* add motion search to history actions menu and mobile drawer

* i18n

* use pure css for motion search dialog video

* defer profile restoration until subscribers are connected

* change order of features in mobile review settings drawer
2026-05-30 21:35:03 -06:00
Jing T
2dd05ca984
Allow rtsps:// in camera wizard URL validation (#23352)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
Extends the custom URL validator to accept both rtsp:// and rtsps://, and updates the error message in all 25 translated locales to reflect both schemes. Also fixes a pre-existing typo in the Slovak translation (\"rtsp / \" → \"rtsp://\").

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:11:14 -05:00
ryzendigo
952dc1c3ef fix: handle short reads from ffmpeg stdout in capture loop
io.BufferedReader.read(n) is documented to return "up to" n bytes, and
a subprocess pipe will return a short read whenever the producer closes
the pipe mid-frame — even on a blocking pipe. The existing assignment

    frame_buffer[:] = ffmpeg_process.stdout.read(frame_size)

then raises ValueError on the slice assignment, and the surrounding
``except`` only breaks out of the capture loop when ``poll()`` reports
the process has already exited. If ffmpeg is still alive (e.g. an RTSP
source that dropped a chunk and recovered), the loop hits ``continue``
and the next read picks up data from the middle of the next frame,
putting capture out of sync with frame boundaries until the watchdog
later forces a restart.

Read into the pre-allocated buffer in a loop and treat any short read
as the end of the stream, so the watchdog can respawn ffmpeg from a
clean state rather than continuing at the wrong offset. As a side
effect, ``readinto`` writes directly into the shared-memory frame
buffer, avoiding the per-frame Python ``bytes`` allocation and copy
that the previous slice assignment performed.
2026-05-09 22:13:37 +08:00
38 changed files with 801 additions and 224 deletions

View File

@ -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.

View File

@ -1083,22 +1083,6 @@ ui:
# Optional: Set the time format used.
# Options are browser, 12hour, or 24hour (default: shown below)
time_format: browser
# Optional: Set the date style for a specified length.
# Options are: full, long, medium, short
# Examples:
# short: 2/11/23
# medium: Feb 11, 2023
# full: Saturday, February 11, 2023
# (default: shown below).
date_style: short
# Optional: Set the time style for a specified length.
# Options are: full, long, medium, short
# Examples:
# short: 8:14 PM
# medium: 8:15:22 PM
# full: 8:15:22 PM Mountain Standard Time
# (default: shown below).
time_style: medium
# Optional: Set the unit system to either "imperial" or "metric" (default: metric)
# Used in the UI and in MQTT topics
unit_system: metric

View File

@ -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.

View File

@ -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 []:

View File

@ -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(

View File

@ -343,13 +343,21 @@ class FrigateApp:
)
self.dispatcher.profile_manager = self.profile_manager
def restore_active_profile(self) -> None:
"""Re-activate the persisted profile after subscribers are connected.
ZMQ PUB/SUB drops messages with no subscribers, so activation must
run after every config_updater subscriber is up.
"""
if self.profile_manager is None:
return
persisted = ProfileManager.load_persisted_profile()
if persisted and any(
persisted in cam.profiles for cam in self.config.cameras.values()
):
logger.info("Restoring persisted profile '%s'", persisted)
# don't clear runtime overrides here, restore_runtime_state() later
# in startup replays it on top of the activated profile
# runtime overrides are layered on top via restore_runtime_state()
self.profile_manager.activate_profile(
persisted, clear_runtime_overrides=False
)
@ -617,6 +625,7 @@ class FrigateApp:
self.start_watchdog()
# restore persisted runtime overrides on top of config
self.restore_active_profile()
self.dispatcher.restore_runtime_state()
self.init_auth()

View File

@ -146,7 +146,7 @@ class CameraConfig(FrigateBaseModel):
timestamp_style: TimestampStyleConfig = Field(
default_factory=TimestampStyleConfig,
title="Timestamp style",
description="Styling options for in-feed timestamps applied to recordings and snapshots.",
description="Styling options for timestamps applied to snapshots and Debug view.",
)
# Options without global fallback

View File

@ -45,7 +45,7 @@ class ProxyConfig(FrigateBaseModel):
default_role: Optional[str] = Field(
default="viewer",
title="Default role",
description="Default role assigned to proxy-authenticated users when no role mapping applies (admin or viewer).",
description="Default role assigned to proxy-authenticated users when no role mapping applies.",
)
separator: Optional[str] = Field(
default=",",

View File

@ -5,7 +5,7 @@ from pydantic import Field
from .base import FrigateBaseModel
__all__ = ["TimeFormatEnum", "DateTimeStyleEnum", "UnitSystemEnum", "UIConfig"]
__all__ = ["TimeFormatEnum", "UnitSystemEnum", "UIConfig"]
class TimeFormatEnum(str, Enum):
@ -14,13 +14,6 @@ class TimeFormatEnum(str, Enum):
hours24 = "24hour"
class DateTimeStyleEnum(str, Enum):
full = "full"
long = "long"
medium = "medium"
short = "short"
class UnitSystemEnum(str, Enum):
imperial = "imperial"
metric = "metric"
@ -37,16 +30,6 @@ class UIConfig(FrigateBaseModel):
title="Time format",
description="Time format to use in the UI (browser, 12hour, or 24hour).",
)
date_style: DateTimeStyleEnum = Field(
default=DateTimeStyleEnum.short,
title="Date style",
description="Date style to use in the UI (full, long, medium, short).",
)
time_style: DateTimeStyleEnum = Field(
default=DateTimeStyleEnum.medium,
title="Time style",
description="Time style to use in the UI (full, long, medium, short).",
)
unit_system: UnitSystemEnum = Field(
default=UnitSystemEnum.metric,
title="Unit system",

View 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()

View File

@ -1,3 +1,4 @@
import io
import unittest
import cv2
@ -13,6 +14,7 @@ from frigate.util.object import (
get_region_from_grid,
reduce_detections,
)
from frigate.video.ffmpeg import _read_frame_into
def draw_box(frame, box, color=(255, 0, 0), thickness=2):
@ -352,3 +354,57 @@ class TestRegionGrid(unittest.TestCase):
region = get_region_from_grid(frame_shape, box, 320, region_grid)
assert region[2] - region[0] > 320
class _ChunkedPipe(io.RawIOBase):
"""Stub stdout that returns the queued chunks one readinto at a time."""
def __init__(self, chunks):
self._chunks = [bytes(c) for c in chunks]
def readable(self) -> bool:
return True
def readinto(self, b) -> int:
if not self._chunks:
return 0
chunk = self._chunks.pop(0)
n = min(len(b), len(chunk))
b[:n] = chunk[:n]
if n < len(chunk):
self._chunks.insert(0, chunk[n:])
return n
class TestReadFrameInto(unittest.TestCase):
"""Cover the partial-read path of the capture loop helper."""
def test_full_frame_in_one_call(self):
buf = bytearray(8)
n = _read_frame_into(_ChunkedPipe([b"\x01\x02\x03\x04\x05\x06\x07\x08"]), buf)
assert n == 8
assert bytes(buf) == b"\x01\x02\x03\x04\x05\x06\x07\x08"
def test_short_reads_are_reassembled(self):
buf = bytearray(8)
pipe = _ChunkedPipe([b"\x01\x02\x03", b"\x04\x05", b"\x06\x07\x08"])
n = _read_frame_into(pipe, buf)
assert n == 8
assert bytes(buf) == b"\x01\x02\x03\x04\x05\x06\x07\x08"
def test_eof_before_full_buffer_returns_partial_count(self):
buf = bytearray(8)
n = _read_frame_into(_ChunkedPipe([b"\x01\x02\x03"]), buf)
assert n == 3
def test_immediate_eof_returns_zero(self):
buf = bytearray(8)
n = _read_frame_into(_ChunkedPipe([]), buf)
assert n == 0
def test_writes_into_existing_memoryview(self):
backing = bytearray(8)
view = memoryview(backing)
n = _read_frame_into(_ChunkedPipe([b"abcdefgh"]), view)
assert n == 8
assert bytes(backing) == b"abcdefgh"

View File

@ -618,6 +618,16 @@ def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
new_config["cameras"][name] = camera_config
# Remove deprecated date_style and time_style from global ui config
global_ui = new_config.get("ui", {})
if global_ui.get("date_style") is not None:
del new_config["ui"]["date_style"]
if global_ui.get("time_style") is not None:
del new_config["ui"]["time_style"]
# Remove ui section if empty
if "ui" in new_config and not new_config["ui"]:
del new_config["ui"]
new_config["version"] = "0.18-0"
return new_config

View File

@ -52,6 +52,32 @@ def _get_record_segment_time(config: CameraConfig) -> int:
return DEFAULT_RECORD_SEGMENT_TIME
def _read_frame_into(stream, buffer) -> int:
"""Read len(buffer) bytes from stream directly into buffer.
Returns the number of bytes actually read. A return value less than
len(buffer) indicates the stream reached EOF (or the pipe was closed)
before the buffer could be filled the contents of buffer past the
returned offset must be treated as undefined.
BufferedReader.read(n) is documented to return "up to" n bytes, and a
subprocess pipe will hand back a short read if the producer closes
mid-frame even on a blocking pipe. Reading directly into the
pre-allocated frame buffer also avoids the extra Python bytes
allocation and copy that ``buffer[:] = stream.read(n)`` performs on
every frame.
"""
target = len(buffer)
view = memoryview(buffer)
pos = 0
while pos < target:
n = stream.readinto(view[pos:])
if not n:
return pos
pos += n
return pos
def capture_frames(
ffmpeg_process: sp.Popen[Any],
config: CameraConfig,
@ -92,7 +118,7 @@ def capture_frames(
frame_name = f"{config.name}_frame{frame_index}"
frame_buffer = frame_manager.write(frame_name)
try:
frame_buffer[:] = ffmpeg_process.stdout.read(frame_size)
bytes_read = _read_frame_into(ffmpeg_process.stdout, frame_buffer)
except Exception:
# shutdown has been initiated
if stop_event.is_set():
@ -110,6 +136,23 @@ def capture_frames(
continue
if bytes_read < frame_size:
# ffmpeg's stdout pipe was closed (or returned EOF) before
# the full frame could be received. The buffer holds a
# partial frame, so don't pass it to detection: a
# half-frame in shared memory will produce garbage
# detections, and continuing to read would resume in the
# middle of the next frame, putting capture out of sync
# with frame boundaries until the next watchdog restart.
if stop_event.is_set():
break
logger.error(
f"{config.name}: ffmpeg returned an incomplete frame "
f"({bytes_read}/{frame_size} bytes); exiting capture thread..."
)
break
frame_rate.update()
# don't lock the queue to check, just try since it should rarely be full

View File

@ -682,7 +682,7 @@
},
"timestamp_style": {
"label": "Timestamp style",
"description": "Styling options for in-feed timestamps applied to recordings and snapshots.",
"description": "Styling options for timestamps applied to snapshots and Debug view.",
"position": {
"label": "Timestamp position",
"description": "Position of the timestamp on the image (tl/tr/bl/br)."

View File

@ -212,7 +212,7 @@
},
"default_role": {
"label": "Default role",
"description": "Default role assigned to proxy-authenticated users when no role mapping applies (admin or viewer)."
"description": "Default role assigned to proxy-authenticated users when no role mapping applies."
},
"separator": {
"label": "Separator character",
@ -270,14 +270,6 @@
"label": "Time format",
"description": "Time format to use in the UI (browser, 12hour, or 24hour)."
},
"date_style": {
"label": "Date style",
"description": "Date style to use in the UI (full, long, medium, short)."
},
"time_style": {
"label": "Time style",
"description": "Time style to use in the UI (full, long, medium, short)."
},
"unit_system": {
"label": "Unit system",
"description": "Unit system for display (metric or imperial) used in the UI and MQTT."

View File

@ -67,7 +67,7 @@
"needsReview": "Needs review",
"securityConcern": "Security concern",
"motionSearch": {
"menuItem": "Motion search",
"menuItem": "Motion Search",
"openMenu": "Camera options"
},
"motionPreviews": {

View File

@ -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": {

View File

@ -320,7 +320,7 @@
"nameLength": "Camera name must be 64 characters or less",
"invalidCharacters": "Camera name contains invalid characters",
"nameExists": "Camera name already exists",
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\". Manual configuration is required for non-RTSP camera streams."
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\" or \"rtsps://\". Manual configuration is required for non-RTSP camera streams."
}
},
"step2": {
@ -1154,7 +1154,8 @@
},
"notificationUnavailable": {
"title": "Notifications Unavailable",
"desc": "Web push notifications require a secure context (<code>https://…</code>). This is a browser limitation. Access Frigate securely to use notifications."
"desc": "Web push notifications require a secure context (<code>https://…</code>). This is a browser limitation. Access Frigate securely to use notifications.",
"descPwa": "On iOS, web push notifications are only available when Frigate is installed to your Home Screen. Open the <strong>Share</strong> menu, choose <strong>Add to Home Screen</strong>, then open Frigate from the new icon to register this device for notifications."
},
"globalSettings": {
"title": "Global Settings",
@ -1674,6 +1675,17 @@
"refresh": "Refresh models",
"probeFailed": "Failed to probe models",
"fetchedModels": "Successfully fetched model list"
},
"ptzPresets": {
"placeholder": "Select or enter a preset...",
"search": "Search or enter a preset...",
"noPresets": "No presets available",
"available": "Camera presets",
"useCustom": "Use \"{{value}}\""
},
"defaultRole": {
"admin": "Admin",
"viewer": "Viewer"
}
},
"globalConfig": {
@ -1763,7 +1775,7 @@
"addStream": "Add stream",
"addStreamDesc": "Enter a name for the new stream. This name will be used to reference the stream in your camera configuration.",
"addUrl": "Add URL",
"streamNumber": "Stream {{index}}",
"sourceNumber": "Source {{index}}",
"streamName": "Stream name",
"streamNamePlaceholder": "e.g., front_door",
"streamUrlPlaceholder": "e.g., rtsp://user:pass@192.168.1.100/stream",
@ -1840,12 +1852,6 @@
"12hour": "12 hour",
"24hour": "24 hour"
},
"TimeOrDateStyle": {
"full": "Full",
"long": "Long",
"medium": "Medium",
"short": "Short"
},
"unitSystem": {
"metric": "Metric",
"imperial": "Imperial"
@ -1928,6 +1934,9 @@
},
"semanticSearch": {
"jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended."
},
"onvif": {
"autotrackingNoZones": "Autotracking requires at least one zone. Define a zone for this camera in Masks / Zones, then set it as a required zone below."
}
}
}

View File

@ -14,8 +14,8 @@ const BlurredIconButton = forwardRef<HTMLDivElement, BlurredIconButtonProps>(
)}
{...rest}
>
<div className="pointer-events-none absolute inset-0 m-auto size-5 scale-95 rounded-full bg-black opacity-0 blur-sm transition-all duration-200 group-hover:scale-100 group-hover:opacity-100 group-hover:blur-xl" />
<div className="relative z-10 cursor-pointer text-white/85 hover:text-white">
<div className="pointer-events-none absolute inset-0 m-auto size-5 scale-95 rounded-full bg-black opacity-30 blur-md transition-all duration-200 group-hover:scale-100 group-hover:opacity-100 group-hover:blur-xl" />
<div className="relative z-10 cursor-pointer text-white/85 drop-shadow-[0_1px_1px_rgba(0,0,0,0.9)] hover:text-white">
{children}
</div>
</div>

View File

@ -51,6 +51,7 @@ export default function Step2StateArea({
const [imageLoaded, setImageLoaded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const popoverContainerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const stageRef = useRef<Konva.Stage>(null);
const rectRef = useRef<Konva.Rect>(null);
@ -224,7 +225,7 @@ export default function Step2StateArea({
const canContinue = cameraAreas.length > 0;
return (
<div className="flex flex-col gap-4">
<div ref={popoverContainerRef} className="flex flex-col gap-4">
<div
className={cn(
"flex gap-4 overflow-hidden",
@ -255,6 +256,7 @@ export default function Step2StateArea({
className="scrollbar-container w-64 border bg-background p-3 shadow-lg"
align="start"
sideOffset={5}
container={popoverContainerRef.current}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-2">

View File

@ -25,6 +25,24 @@ const onvif: SectionConfigOverrides = {
advancedFields: ["tls_insecure", "ignore_time_mismatch"],
overrideFields: [],
restartRequired: ["autotracking.calibrate_on_startup"],
fieldMessages: [
{
key: "autotracking-no-zones",
field: "autotracking.required_zones",
messageKey: "configMessages.onvif.autotrackingNoZones",
severity: "error",
position: "before",
condition: (ctx) => {
if (ctx.level !== "camera") return false;
const zones = ctx.fullCameraConfig?.zones;
return (
!zones ||
typeof zones !== "object" ||
Object.keys(zones).length === 0
);
},
},
],
uiSchema: {
host: {
"ui:options": { size: "sm" },
@ -39,11 +57,16 @@ const onvif: SectionConfigOverrides = {
required_zones: {
"ui:widget": "zoneNames",
},
return_preset: {
"ui:options": { size: "sm" },
"ui:widget": "ptzPresets",
},
track: {
"ui:widget": "objectLabels",
},
zooming: {
"ui:options": {
size: "xs",
enumI18nPrefix: "onvif.autotracking.zooming",
},
},

View File

@ -21,6 +21,10 @@ const proxy: SectionConfigOverrides = {
"ui:widget": "password",
"ui:options": { size: "md" },
},
default_role: {
"ui:widget": "defaultRole",
"ui:options": { size: "sm" },
},
header_map: {
"ui:after": { render: "ProxyRoleMap" },
},

View File

@ -10,13 +10,7 @@ const ui: SectionConfigOverrides = {
overrideFields: [],
},
global: {
fieldOrder: [
"timezone",
"time_format",
"date_style",
"time_style",
"unit_system",
],
fieldOrder: ["timezone", "time_format", "unit_system"],
advancedFields: [],
restartRequired: ["unit_system"],
uiSchema: {
@ -26,12 +20,6 @@ const ui: SectionConfigOverrides = {
time_format: {
"ui:options": { enumI18nPrefix: "ui.timeFormat" },
},
date_style: {
"ui:options": { enumI18nPrefix: "ui.TimeOrDateStyle" },
},
time_style: {
"ui:options": { enumI18nPrefix: "ui.TimeOrDateStyle" },
},
unit_system: {
"ui:options": { enumI18nPrefix: "ui.unitSystem" },
},

View File

@ -48,6 +48,8 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Trans, useTranslation } from "react-i18next";
import { useDateLocale } from "@/hooks/use-date-locale";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { isPWA } from "@/utils/isPWA";
import { isIOS } from "react-device-detect";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { cn } from "@/lib/utils";
@ -437,6 +439,12 @@ export default function NotificationsSettingsExtras({
}
if (!("Notification" in window) || !window.isSecureContext) {
// iOS only exposes web push to apps installed to the Home Screen, so a
// secure-context iOS browser tab that isn't an installed PWA has no
// Notification API. Android supports web push in a normal tab, so it never
// reaches this case and keeps the generic secure-context message.
const requiresPwaInstall = isIOS && window.isSecureContext && !isPWA;
return (
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
<div className="w-full max-w-5xl">
@ -465,12 +473,21 @@ export default function NotificationsSettingsExtras({
{t("notification.notificationUnavailable.title")}
</AlertTitle>
<AlertDescription>
<Trans ns="views/settings">
notification.notificationUnavailable.desc
</Trans>
<Trans
ns="views/settings"
i18nKey={
requiresPwaInstall
? "notification.notificationUnavailable.descPwa"
: "notification.notificationUnavailable.desc"
}
/>
<div className="mt-3 flex items-center">
<Link
to={getLocaleDocUrl("configuration/authentication")}
to={getLocaleDocUrl(
requiresPwaInstall
? "configuration/notifications"
: "configuration/authentication",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"

View File

@ -33,6 +33,8 @@ import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
import { SemanticSearchModelWidget } from "./widgets/SemanticSearchModelWidget";
import { SemanticSearchModelSizeWidget } from "./widgets/SemanticSearchModelSizeWidget";
import { OnvifProfileWidget } from "./widgets/OnvifProfileWidget";
import { PTZPresetsWidget } from "./widgets/PTZPresetsWidget";
import { DefaultRoleWidget } from "./widgets/DefaultRoleWidget";
import { FieldTemplate } from "./templates/FieldTemplate";
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
@ -90,6 +92,8 @@ export const frigateTheme: FrigateTheme = {
semanticSearchModel: SemanticSearchModelWidget,
semanticSearchModelSize: SemanticSearchModelSizeWidget,
onvifProfile: OnvifProfileWidget,
ptzPresets: PTZPresetsWidget,
defaultRole: DefaultRoleWidget,
},
templates: {
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,

View File

@ -0,0 +1,56 @@
import { useMemo } from "react";
import type { WidgetProps } from "@rjsf/utils";
import { useTranslation } from "react-i18next";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ConfigFormContext } from "@/types/configForm";
import { getSizedFieldClassName } from "../utils";
const BUILT_IN_ROLES = ["admin", "viewer"];
export function DefaultRoleWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, schema, options, registry } =
props;
const { t } = useTranslation(["views/settings"]);
const fieldClassName = getSizedFieldClassName(options, "sm");
const formContext = registry?.formContext as ConfigFormContext | undefined;
const roles = useMemo<string[]>(() => {
const configured = Object.keys(formContext?.fullConfig?.auth?.roles ?? {});
// Keep admin/viewer first, then any custom roles in config order.
const custom = configured.filter((r) => !BUILT_IN_ROLES.includes(r));
return [...BUILT_IN_ROLES, ...custom];
}, [formContext]);
const selectedValue = typeof value === "string" && value ? value : "viewer";
const getLabel = (role: string) =>
BUILT_IN_ROLES.includes(role) ? t(`configForm.defaultRole.${role}`) : role;
return (
<Select
value={selectedValue}
onValueChange={onChange}
disabled={disabled || readonly}
>
<SelectTrigger id={id} className={fieldClassName}>
<SelectValue placeholder={schema.title} />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role} value={role}>
{getLabel(role)}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export default DefaultRoleWidget;

View File

@ -0,0 +1,151 @@
// Combobox widget for ONVIF PTZ preset fields (e.g. autotracking.return_preset).
// Fetches the camera's PTZ presets and shows them in a dropdown, while still
// allowing a typed custom value so existing presets that the camera does not
// report (such as "home") are preserved.
import { useState, useMemo } from "react";
import type { WidgetProps } from "@rjsf/utils";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { Check, ChevronsUpDown, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { ConfigFormContext } from "@/types/configForm";
import type { CameraPtzInfo } from "@/types/ptz";
import { getSizedFieldClassName } from "../utils";
export function PTZPresetsWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, options, registry } = props;
const { t } = useTranslation(["views/settings"]);
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const fieldClassName = getSizedFieldClassName(options, "md");
const formContext = registry?.formContext as ConfigFormContext | undefined;
const cameraName = formContext?.cameraName;
const isCameraLevel = formContext?.level === "camera";
const hasOnvifHost = !!formContext?.fullCameraConfig?.onvif?.host;
const { data: ptzInfo } = useSWR<CameraPtzInfo>(
isCameraLevel && cameraName && hasOnvifHost
? `${cameraName}/ptz/info`
: null,
{
// ONVIF may not be initialized yet when the settings page loads,
// so retry until presets become available
refreshInterval: (data) =>
data?.presets && data.presets.length > 0 ? 0 : 5000,
},
);
const presets = useMemo<string[]>(() => ptzInfo?.presets ?? [], [ptzInfo]);
const trimmedSearch = searchValue.trim();
const matchesPreset = useMemo(
() => presets.some((p) => p.toLowerCase() === trimmedSearch.toLowerCase()),
[presets, trimmedSearch],
);
const showCustomOption = trimmedSearch.length > 0 && !matchesPreset;
const commit = (next: string) => {
onChange(next);
setSearchValue("");
setOpen(false);
};
const currentLabel = typeof value === "string" && value ? value : undefined;
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setSearchValue("");
}}
>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || readonly}
className={cn(
"justify-between font-normal",
!currentLabel && "text-muted-foreground",
fieldClassName,
)}
>
{currentLabel ?? t("configForm.ptzPresets.placeholder")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command>
<CommandInput
placeholder={t("configForm.ptzPresets.search")}
value={searchValue}
onValueChange={setSearchValue}
onKeyDown={(e) => {
if (e.key === "Enter" && showCustomOption) {
e.preventDefault();
commit(trimmedSearch);
}
}}
/>
<CommandList>
{showCustomOption && (
<CommandGroup>
<CommandItem
value={trimmedSearch}
onSelect={() => commit(trimmedSearch)}
>
<Plus className="mr-2 h-4 w-4" />
{t("configForm.ptzPresets.useCustom", {
value: trimmedSearch,
})}
</CommandItem>
</CommandGroup>
)}
{presets.length > 0 ? (
<CommandGroup heading={t("configForm.ptzPresets.available")}>
{presets.map((preset) => (
<CommandItem
key={preset}
value={preset}
onSelect={() => commit(preset)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === preset ? "opacity-100" : "opacity-0",
)}
/>
{preset}
</CommandItem>
))}
</CommandGroup>
) : !showCustomOption ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("configForm.ptzPresets.noPresets")}
</div>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -12,14 +12,21 @@ type ActionsDropdownProps = {
onDebugReplayClick?: () => void;
onExportClick: () => void;
onShareTimestampClick: () => void;
onMotionSearchClick?: () => void;
};
export default function ActionsDropdown({
onDebugReplayClick,
onExportClick,
onShareTimestampClick,
onMotionSearchClick,
}: Readonly<ActionsDropdownProps>) {
const { t } = useTranslation(["components/dialog", "views/replay", "common"]);
const { t } = useTranslation([
"components/dialog",
"views/replay",
"views/events",
"common",
]);
return (
<DropdownMenu>
@ -42,6 +49,11 @@ export default function ActionsDropdown({
<DropdownMenuItem onClick={onShareTimestampClick}>
{t("recording.shareTimestamp.label", { ns: "components/dialog" })}
</DropdownMenuItem>
{onMotionSearchClick && (
<DropdownMenuItem onClick={onMotionSearchClick}>
{t("motionSearch.menuItem", { ns: "views/events" })}
</DropdownMenuItem>
)}
{onDebugReplayClick && (
<DropdownMenuItem onClick={onDebugReplayClick}>
{t("title", { ns: "views/replay" })}

View File

@ -3,7 +3,7 @@ import { baseUrl } from "@/api/baseUrl";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Button } from "../ui/button";
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
import { LuBug, LuShare2 } from "react-icons/lu";
import { LuBug, LuSearch, LuShare2 } from "react-icons/lu";
import { TimeRange } from "@/types/timeline";
import { ExportContent, ExportPreviewDialog, ExportTab } from "./ExportDialog";
import {
@ -46,6 +46,7 @@ const DRAWER_FEATURES = [
"filter",
"debug-replay",
"share-timestamp",
"motion-search",
] as const;
export type DrawerFeatures = (typeof DRAWER_FEATURES)[number];
const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [
@ -54,6 +55,7 @@ const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [
"filter",
"debug-replay",
"share-timestamp",
"motion-search",
];
type MobileReviewSettingsDrawerProps = {
@ -75,6 +77,7 @@ type MobileReviewSettingsDrawerProps = {
setDebugReplayMode?: (mode: ExportMode) => void;
setDebugReplayRange?: (range: TimeRange | undefined) => void;
onShareTimestamp?: (timestamp: number) => void;
onMotionSearch?: () => void;
onUpdateFilter: (filter: ReviewFilter) => void;
setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void;
@ -99,6 +102,7 @@ export default function MobileReviewSettingsDrawer({
setDebugReplayMode = () => {},
setDebugReplayRange = () => {},
onShareTimestamp = () => {},
onMotionSearch,
onUpdateFilter,
setRange,
setMode,
@ -108,6 +112,7 @@ export default function MobileReviewSettingsDrawer({
"views/recording",
"components/dialog",
"views/replay",
"views/events",
"common",
]);
const isAdmin = useIsAdmin();
@ -343,27 +348,6 @@ export default function MobileReviewSettingsDrawer({
{t("export")}
</Button>
)}
{features.includes("share-timestamp") && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label={t("recording.shareTimestamp.label", {
ns: "components/dialog",
})}
onClick={() => {
const initialTimestamp = Math.floor(currentTime);
setShareTimestampAtOpen(initialTimestamp);
setCustomShareTimestamp(initialTimestamp);
setSelectedShareOption("current");
setDrawerMode("share-timestamp");
}}
>
<LuShare2 className="size-5 rounded-md bg-secondary-foreground stroke-secondary p-1" />
{t("recording.shareTimestamp.label", {
ns: "components/dialog",
})}
</Button>
)}
{features.includes("calendar") && (
<Button
className="flex w-full items-center justify-center gap-2"
@ -390,6 +374,40 @@ export default function MobileReviewSettingsDrawer({
{t("filter")}
</Button>
)}
{features.includes("share-timestamp") && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label={t("recording.shareTimestamp.label", {
ns: "components/dialog",
})}
onClick={() => {
const initialTimestamp = Math.floor(currentTime);
setShareTimestampAtOpen(initialTimestamp);
setCustomShareTimestamp(initialTimestamp);
setSelectedShareOption("current");
setDrawerMode("share-timestamp");
}}
>
<LuShare2 className="size-5 rounded-md bg-secondary-foreground stroke-secondary p-1" />
{t("recording.shareTimestamp.label", {
ns: "components/dialog",
})}
</Button>
)}
{features.includes("motion-search") && onMotionSearch && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label={t("motionSearch.menuItem", { ns: "views/events" })}
onClick={() => {
onMotionSearch();
setDrawerMode("none");
}}
>
<LuSearch className="size-5 rounded-md bg-secondary-foreground stroke-secondary p-1" />
{t("motionSearch.menuItem", { ns: "views/events" })}
</Button>
)}
{isAdmin && features.includes("debug-replay") && (
<Button
className="flex w-full items-center justify-center gap-2"

View File

@ -87,7 +87,8 @@ export default function Step1NameCamera({
.string()
.optional()
.refine(
(val) => !val || val.startsWith("rtsp://"),
(val) =>
!val || val.startsWith("rtsp://") || val.startsWith("rtsps://"),
t("cameraWizard.step1.errors.customUrlRtspRequired"),
),
})

View File

@ -56,11 +56,9 @@ export default function Events() {
false,
);
const [recording, setRecording] = useOverlayState<RecordingStartingPoint>(
"recording",
undefined,
false,
);
const [recording, setRecording] = useOverlayState<
RecordingStartingPoint | undefined
>("recording", undefined, false);
const [motionPreviewsCamera, setMotionPreviewsCamera] = useOverlayState<
string | undefined
>("motionPreviewsCamera", undefined);
@ -668,6 +666,10 @@ export default function Events() {
filter={reviewFilter}
updateFilter={onUpdateFilter}
refreshData={reloadData}
onMotionSearch={(camera) => {
setMotionSearchCamera(camera);
setRecording(undefined);
}}
/>
);
}

View File

@ -4,8 +4,6 @@ import { TriggerAction, TriggerType } from "./trigger";
export interface UiConfig {
timezone?: string;
time_format?: "browser" | "12hour" | "24hour";
date_style?: "full" | "long" | "medium" | "short";
time_style?: "full" | "long" | "medium" | "short";
dashboard: boolean;
order: number;
unit_system?: "metric" | "imperial";

View File

@ -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 {
@ -112,40 +114,35 @@ export default function MotionSearchDialog({
}: MotionSearchDialogProps) {
const { t } = useTranslation(["views/motionSearch", "common"]);
const apiHost = useApiHost();
const [containerNode, setContainerNode] = useState<HTMLDivElement | null>(
null,
);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const containerWidth = containerSize.width;
const containerHeight = containerSize.height;
const [imageLoaded, setImageLoaded] = useState(false);
useEffect(() => {
if (!containerNode) {
return;
}
const measure = () => {
const rect = containerNode.getBoundingClientRect();
setContainerSize((prev) =>
prev.width === rect.width && prev.height === rect.height
? prev
: { width: rect.width, height: rect.height },
);
};
measure();
const observer = new ResizeObserver(() => measure());
observer.observe(containerNode);
return () => observer.disconnect();
}, [containerNode]);
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],
@ -169,30 +166,9 @@ export default function MotionSearchDialog({
setIsDrawingROI(true);
}, [isSearching, polygonPoints.length, setIsDrawingROI, setPolygonPoints]);
const imageSize = useMemo(() => {
if (!containerWidth || !containerHeight || !cameraConfig) {
return { width: 0, height: 0 };
}
const cameraAspectRatio =
cameraConfig.detect.width / cameraConfig.detect.height;
const availableAspectRatio = containerWidth / containerHeight;
if (availableAspectRatio >= cameraAspectRatio) {
return {
width: containerHeight * cameraAspectRatio,
height: containerHeight,
};
}
return {
width: containerWidth,
height: containerWidth / cameraAspectRatio,
};
}, [containerWidth, containerHeight, cameraConfig]);
useEffect(() => {
setImageLoaded(false);
setPanMode(false);
}, [selectedCamera]);
const Overlay = isDesktop ? Dialog : Drawer;
@ -267,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={{
@ -281,18 +263,16 @@ export default function MotionSearchDialog({
}}
>
<div
ref={setContainerNode}
className="relative flex w-full items-center justify-center overflow-hidden rounded-lg border bg-secondary"
style={{ aspectRatio: "16 / 9" }}
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"
style={{
width: imageSize.width || "100%",
height: imageSize.height || "100%",
}}
>
<div className="relative h-full w-full">
<img
alt={t("dialog.previewAlt", {
camera: selectedCamera,
@ -320,6 +300,7 @@ export default function MotionSearchDialog({
isDrawing={isDrawingROI}
setIsDrawing={setIsDrawingROI}
isInteractive={true}
panMode={panMode}
/>
</div>
) : (
@ -341,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}
@ -361,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}
@ -429,7 +443,7 @@ export default function MotionSearchDialog({
<Slider
id="frameSkip"
min={1}
max={60}
max={120}
step={1}
value={[frameSkip]}
onValueChange={([value]) => setFrameSkip(value)}

View File

@ -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" }}
>

View File

@ -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) {

View File

@ -95,6 +95,7 @@ type RecordingViewProps = {
filter?: ReviewFilter;
updateFilter: (newFilter: ReviewFilter) => void;
refreshData?: () => void;
onMotionSearch?: (camera: string) => void;
};
export function RecordingView({
startCamera,
@ -107,6 +108,7 @@ export function RecordingView({
filter,
updateFilter,
refreshData,
onMotionSearch,
}: RecordingViewProps) {
const { t } = useTranslation(["views/events", "components/dialog"]);
const { data: config } = useSWR<FrigateConfig>("config");
@ -725,6 +727,9 @@ export function RecordingView({
setCustomShareTimestamp(initialTimestamp);
setShareTimestampOpen(true);
}}
onMotionSearchClick={
onMotionSearch ? () => onMotionSearch(mainCamera) : undefined
}
onDebugReplayClick={
isAdmin
? () => {
@ -807,6 +812,9 @@ export function RecordingView({
}
}}
onShareTimestamp={onShareReviewLink}
onMotionSearch={
onMotionSearch ? () => onMotionSearch(mainCamera) : undefined
}
onUpdateFilter={updateFilter}
setRange={setExportRange}
setMode={setExportMode}

View File

@ -902,7 +902,7 @@ function StreamUrlEntry({
return (
<div className="pb-4">
<div className="flex h-7 flex-row items-center justify-start gap-2 text-sm text-primary-variant">
{t("go2rtcStreams.streamNumber", { index: urlIndex + 1 })}
{t("go2rtcStreams.sourceNumber", { index: urlIndex + 1 })}
{canRemove && (
<Button
variant="ghost"

View File

@ -484,7 +484,7 @@ export default function TriggerView({
) : (
<>
<div className="mb-5 flex flex-row items-center justify-between gap-2">
<div className="flex flex-col items-start">
<div className="flex max-w-5xl flex-col items-start">
<Heading as="h4" className="mb-1">
{t("triggers.management.title")}
</Heading>