Compare commits

...
Author SHA1 Message Date
Nicolas MowenandGitHub 3f0ebb3577 Add ability to hide cameras from review UI (#23387)
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 field to control if cameras show in review

* i18n

* Add config to UI
2026-06-02 16:11:42 -05:00
c25a522fcc docs: fix spelling mistakes in documentation (#23380)
* docs: fix spelling mistakes in documentation

* docs: fix typos and revert incorrect dfine to define rename

* docs: fix typo in installation.md

---------

Co-authored-by: TheInfamousToTo <TheInfamousToTo@users.noreply.github.com>
2026-06-02 05:49:42 -06:00
Josh HawkinsandGitHub db9e64c598 replace motion activity resample apply/agg lambdas with vectorized max() and first() (#23383)
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
2026-06-01 15:51:43 -06:00
Josh HawkinsandGitHub 570e21340a Miscellaneous fixes (#23373)
* republish MQTT switch states when a profile is activated or deactivated

* fix object mask default name when created from Explore tracking details

* tweak annotation offset max in UI

* optimize recordings/unavailable gap detection and drop empty motion activity buckets

* add tests
2026-06-01 13:55:52 -06:00
Josh HawkinsandGitHub 8073174c20 Refactor motion search (#23378)
* refactor motion search

* cleanup dead code and tests

* tweaks

* fix multi-day seeking

* start playback a few seconds before the change so the motion is in view
2026-06-01 12:08:46 -05:00
Josh HawkinsandGitHub 47a06c8b30 Tweaks (#23367)
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 HawkinsandGitHub ae60197cb0 Support onvif PasswordText cameras in the add camera wizard (#23365)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (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
CI / Jetson Jetpack 6 (push) Waiting to run
* 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 HawkinsandGitHub 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 HawkinsandGitHub 08be019bed Miscellaneous fixes (#23358)
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
2dd05ca984 Allow rtsps:// in camera wizard URL validation (#23352)
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
CI / AMD64 Build (push) Waiting to run
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
Josh HawkinsandGitHub 6fdd65ddb5 UI tweaks (#23346)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* remove redundant per-view toasters in settings

* add variants to standardize dialog footer button layouts

* remove text-md

this class name compiles to nothing in tailwind. we used to add it to prevent iOS from zooming when focusing on an input, but that is now solved via the viewport meta in index.html

* make wizard footers consistent with dialog footers

* consistent destructive button style

remove text-white from individual buttons and add it to the variant
2026-05-29 16:00:30 -06:00
Josh HawkinsandGitHub 4b6fa49449 Miscellaneous fixes (#23335)
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
* stabilize chart options to stop ApexCharts updateOptions running on every stats tick

* constrain height of export dialog

* stop audio maintainer when deleting a camera

* run face register and recognize API handlers in threadpool
2026-05-29 06:53:17 -06:00
Josh HawkinsandGitHub bc65713ae4 Clone camera settings (#23339)
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 clone dialog

* i18n

* tweaks

* add to camera management pane

* add e2e test

* optional disable portal prop

* radio and checkbox tweaks

* tweak i18n

* add select all/select none

* fixes

* reset form only on open transition

* unselect all targets for existing camera

* fix test

* reorder sections for save and collapse to single put for new camera

* change source and allow cloning to multiple cameras

* tweak language

* fix overflowing text in save all popover

* tweaks

* fix per label object masks

* use grid for source and target

* language tweak
2026-05-28 17:44:06 -06:00
145 changed files with 5286 additions and 1416 deletions
+6 -1
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.
@@ -174,7 +179,7 @@ The FeatureList on the [ONVIF Conformant Products Database](https://www.onvif.or
| Hikvision DS-2DE3A404IWG-E/W | ✅ | ✅ | |
| Reolink | ✅ | ❌ | |
| Speco O8P32X | ✅ | ❌ | |
| Sunba 405-D20X | ✅ | ❌ | Incomplete ONVIF support reported on original, and 4k models. All models are suspected incompatable. |
| Sunba 405-D20X | ✅ | ❌ | Incomplete ONVIF support reported on original, and 4k models. All models are suspected incompatible. |
| Tapo | ✅ | ❌ | Many models supported, ONVIF Service Port: 2020 |
| Uniview IPC672LR-AX4DUPK | ✅ | ❌ | Firmware says FOV relative movement is supported, but camera doesn't actually move when sending ONVIF commands |
| Uniview IPC6612SR-X33-VG | ✅ | ✅ | Leave `calibrate_on_startup` as `False`. A user has reported that zooming with `absolute` is working. |
+1 -1
View File
@@ -660,7 +660,7 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl
#### RF-DETR
[RF-DETR](https://github.com/roboflow/rf-detr) is a DETR based model. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-rf-detr-model) for more informatoin on downloading the RF-DETR model for use in Frigate.
[RF-DETR](https://github.com/roboflow/rf-detr) is a DETR based model. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-rf-detr-model) for more information on downloading the RF-DETR model for use in Frigate.
:::warning
-16
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
+39 -10
View File
@@ -153,20 +153,49 @@ 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.
1. Pick the camera and time range to scan. In the date pickers, days that have recordings available are underlined.
2. Draw a polygon on the camera frame to define the region of interest.
3. Adjust the search parameters if needed:
| Field | Description |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Sensitivity Threshold** | Per-pixel luminance change required to count as motion inside the ROI. Behaves like Frigate's motion detection `threshold` setting. |
| **Minimum Change Area** | Minimum percentage of the region of interest that must change for a frame to be considered significant. Raise it to ignore small movements (leaves, distant motion); lower it when the object you care about only covers a small slice of the ROI. |
| **Frame Skip** | Number of frames to skip between samples — at a camera recording 20 fps, a skip value of 20 takes motion samples roughly once per second. Higher values scan much faster and are usually the right choice; lower it only when you need to catch the exact appearance or disappearance of a fast-moving object. |
| **Maximum Results** | Maximum number of matching timestamps to return. |
| **Parallel mode** | Process multiple recording segments in parallel. Speeds up large time ranges at the cost of higher CPU usage. |
| Field | Description |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Sensitivity Threshold** | Per-pixel luminance change required to count as motion inside the ROI. Behaves like Frigate's motion detection `threshold` setting. |
| **Minimum Change Area** | Minimum size of a single moving region, as a percentage of the ROI, for a frame to count as significant. Raise it to ignore small movements (leaves, distant motion); lower it when your subject covers only a small slice of the ROI. Every result shows the percentage it scored, so you can use those values to tune this. |
| **Maximum Results** | Maximum number of matching timestamps to return. The search stops once it reaches this many results, so a lower value finishes sooner while a higher value scans further into the range. |
| **Parallel mode** | Decode multiple recording ranges at the same time. Speeds up large time ranges at the cost of higher decoding and CPU usage. |
Motion Search samples each recording's keyframes automatically, so there is no frame-rate or sampling setting to tune.
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.
The results panel shows the time range being scanned, a live progress bar with the timestamp currently being analyzed, and the running result count. A collapsible **Search Metrics** section reports how many segments were scanned and processed, how many were skipped because no motion was recorded in the ROI (using the stored motion heatmap), how many frames were decoded, and the total search time. Skipping segments with no recorded motion in the selected ROI 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.
#### Examples
These show how to choose the ROI and **Minimum Change Area** for two common goals. Minimum Change Area is the size of a single moving region as a percentage of the ROI you draw, so the right value depends on how much of the ROI your subject — and its movement between samples — covers.
Because samples are a second or more apart, a moving subject usually appears in two places at once in the comparison, so even ordinary motion often scores tens of percent and a low threshold lets in almost everything. The most reliable approach is to **run a search, look at the percentage each result scored, and set Minimum Change Area just below the values for the events you care about.** The default is 20%; the suggestions below are starting points.
- **When did this item first appear (or disappear)?** A package was dropped off, a car parked, or a trash can was moved, and you want the exact moment. Draw a **tight ROI** around the spot the item occupies and **raise Minimum Change Area** (start around 4060%). Because the item fills most of a tight ROI, its arrival or removal is a large change, while smaller nearby motion (shadows, a passing pedestrian) stays below the threshold. The **earliest result** is when it appeared; if you only care about that moment, a low Maximum Results finishes faster. If you get no hits, the ROI is probably looser than the item — lower the threshold or tighten the ROI.
- **What's been getting into the garden?** Something has been trampling a flower bed overnight and no object was ever tracked. Draw a **looser ROI** covering the whole bed and use a **lower Minimum Change Area than the case above** — start near the 20% default and lower it (toward 510%) only if a small or distant subject is missed, since it covers just a slice of a large region. Expect more results to scan through — step through the timestamps and jump to each to see what triggered it. If wind-blown plants add noise, raise Minimum Change Area or the Sensitivity Threshold.
#### 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.
- Narrow the time range to the window you care about, so there is less footage to examine.
- Lower **Maximum Results** when you only need the first few hits. Because the search stops once it reaches that many results, a smaller value lets a busy range finish early instead of scanning the whole window.
- Use Parallel mode to shorten wall-clock time on multi-core systems, at the cost of higher decoding and CPU usage while it runs.
+2 -2
View File
@@ -749,7 +749,7 @@ Failure to remap port 5000 on the host will result in the WebUI and all API endp
:::
Docker containers on macOS can be orchestrated by either [Docker Desktop](https://docs.docker.com/desktop/setup/install/mac-install/) or [OrbStack](https://orbstack.dev) (native swift app). The difference in inference speeds is negligable, however CPU, power consumption and container start times will be lower on OrbStack because it is a native Swift application.
Docker containers on macOS can be orchestrated by either [Docker Desktop](https://docs.docker.com/desktop/setup/install/mac-install/) or [OrbStack](https://orbstack.dev) (native Swift app). The difference in inference speeds is negligible, however CPU, power consumption and container start times will be lower on OrbStack because it is a native Swift application.
To allow Frigate to use the Apple Silicon Neural Engine / Processing Unit (NPU) the host must be running [Apple Silicon Detector](../configuration/object_detectors.md#apple-silicon-detector) on the host (outside Docker)
@@ -768,7 +768,7 @@ services:
- /path/to/your/recordings:/recordings
ports:
- "8971:8971"
# If exposing on macOS map to a diffent host port like 5001 or any orher port with no conflicts
# If exposing on macOS map to a different host port like 5001 or any other port with no conflicts
# - "5001:5000" # Internal unauthenticated access. Expose carefully.
- "8554:8554" # RTSP feeds
extra_hosts:
+10 -7
View File
@@ -7288,13 +7288,6 @@ components:
title: Min Area
description: Minimum change area as a percentage of the ROI
default: 5
frame_skip:
type: integer
maximum: 30
minimum: 1
title: Frame Skip
description: "Process every Nth frame (1=all frames, 5=every 5th frame)"
default: 5
parallel:
type: boolean
title: Parallel
@@ -7380,6 +7373,16 @@ components:
anyOf:
- $ref: "#/components/schemas/MotionSearchMetricsResponse"
- type: "null"
scanning_timestamp:
anyOf:
- type: number
- type: "null"
title: Scanning Timestamp
progress:
anyOf:
- type: number
- type: "null"
title: Progress
type: object
required:
- success
+76 -42
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 []:
+4 -4
View File
@@ -280,7 +280,7 @@ async def create_face(request: Request, name: str):
success response with details about the registration, or an error if face recognition
is not enabled or the image cannot be processed.""",
)
async def register_face(request: Request, name: str, file: UploadFile):
def register_face(request: Request, name: str, file: UploadFile):
if not request.app.frigate_config.face_recognition.enabled:
return JSONResponse(
status_code=400,
@@ -288,7 +288,7 @@ async def register_face(request: Request, name: str, file: UploadFile):
)
context: EmbeddingsContext = request.app.embeddings
result = None if context is None else context.register_face(name, await file.read())
result = None if context is None else context.register_face(name, file.file.read())
if not isinstance(result, dict):
return JSONResponse(
@@ -313,7 +313,7 @@ async def register_face(request: Request, name: str, file: UploadFile):
registered faces in the system. Returns the recognized face name and confidence score,
or an error if face recognition is not enabled or the image cannot be processed.""",
)
async def recognize_face(request: Request, file: UploadFile):
def recognize_face(request: Request, file: UploadFile):
if not request.app.frigate_config.face_recognition.enabled:
return JSONResponse(
status_code=400,
@@ -321,7 +321,7 @@ async def recognize_face(request: Request, file: UploadFile):
)
context: EmbeddingsContext = request.app.embeddings
result = context.recognize_face(await file.read())
result = context.recognize_face(file.file.read())
if not isinstance(result, dict):
return JSONResponse(
+5 -7
View File
@@ -41,12 +41,6 @@ class MotionSearchRequest(BaseModel):
le=100.0,
description="Minimum change area as a percentage of the ROI",
)
frame_skip: int = Field(
default=5,
ge=1,
le=30,
description="Process every Nth frame (1=all frames, 5=every 5th frame)",
)
parallel: bool = Field(
default=False,
description="Enable parallel scanning across segments",
@@ -97,6 +91,8 @@ class MotionSearchStatusResponse(BaseModel):
total_frames_processed: Optional[int] = None
error_message: Optional[str] = None
metrics: Optional[MotionSearchMetricsResponse] = None
scanning_timestamp: Optional[float] = None
progress: Optional[float] = None
@router.post(
@@ -151,7 +147,6 @@ async def start_motion_search(
polygon_points=body.polygon_points,
threshold=body.threshold,
min_area=body.min_area,
frame_skip=body.frame_skip,
parallel=body.parallel,
max_results=body.max_results,
)
@@ -231,6 +226,9 @@ async def get_motion_search_status_endpoint(
if job.metrics:
response_content["metrics"] = job.metrics.to_dict()
response_content["scanning_timestamp"] = job.scanning_timestamp
response_content["progress"] = job.progress
return JSONResponse(content=response_content)
+20 -6
View File
@@ -299,22 +299,36 @@ async def no_recordings(
.iterator()
)
# Convert recordings to list of (start, end) tuples
# Convert recordings to list of (start, end) tuples, ordered by start_time
recordings = [(r["start_time"], r["end_time"]) for r in data]
# Merge overlapping/adjacent recordings into covered intervals. The query
# orders by start_time, so a single pass merges them
covered: list[tuple[float, float]] = []
for rec_start, rec_end in recordings:
if covered and rec_start <= covered[-1][1]:
covered[-1] = (covered[-1][0], max(covered[-1][1], rec_end))
else:
covered.append((rec_start, rec_end))
# Iterate through time segments and check if each has any recording
no_recording_segments = []
current = after
current_gap_start = None
idx = 0
covered_count = len(covered)
while current < before:
segment_end = min(current + scale, before)
# Check if this segment overlaps with any recording
has_recording = any(
rec_start < segment_end and rec_end > current
for rec_start, rec_end in recordings
)
# Advance past covered intervals that end before this segment begins;
# they cannot overlap this or any later segment.
while idx < covered_count and covered[idx][1] <= current:
idx += 1
# A covered interval overlaps the segment when it starts before the
# segment ends (its end is already known to be > current).
has_recording = idx < covered_count and covered[idx][0] < segment_end
if not has_recording:
# This segment has no recordings
+14 -10
View File
@@ -605,9 +605,10 @@ def motion_activity(
if not filtered:
return JSONResponse(content=[])
camera_list = list(filtered)
clauses.append((Recordings.camera << camera_list))
else:
clauses.append((Recordings.camera << allowed_cameras))
camera_list = list(allowed_cameras)
clauses.append((Recordings.camera << camera_list))
data: list[Recordings] = (
Recordings.select(
@@ -635,14 +636,12 @@ def motion_activity(
df.set_index(["start_time"], inplace=True)
# normalize data
motion = (
df["motion"]
.resample(f"{scale}s")
.apply(lambda x: max(x, key=abs, default=0.0))
.fillna(0.0)
.to_frame()
)
cameras = df["camera"].resample(f"{scale}s").agg(lambda x: ",".join(set(x)))
motion = df["motion"].resample(f"{scale}s").max().fillna(0.0).to_frame()
if len(camera_list) == 1:
cameras = df["camera"].resample(f"{scale}s").first().fillna("")
else:
cameras = df["camera"].resample(f"{scale}s").agg(lambda x: ",".join(set(x)))
df = motion.join(cameras)
length = df.shape[0]
@@ -658,6 +657,11 @@ def motion_activity(
else:
df.iloc[i : i + chunk, 0] = 0.0
# Drop resample gap-fill buckets. The resample above emits a row for every
# {scale}s bucket spanning the range, and buckets with no recording get a
# motion of 0 (from fillna) and an empty camera (from joining an empty set).
df = df[df["camera"] != ""]
# change types for output
df.index = df.index.astype(int) // (10**9)
normalized = df.reset_index().to_dict("records")
+11 -2
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()
+1 -1
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
+5
View File
@@ -16,3 +16,8 @@ class CameraUiConfig(FrigateBaseModel):
title="Show in UI",
description="Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again.",
)
review: bool = Field(
default=True,
title="Show in review",
description="Toggle whether this camera is visible in review (the review page and its camera filter, motion review, and the history view).",
)
+49 -1
View File
@@ -5,7 +5,7 @@ import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from typing import Any, Callable, Optional
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
@@ -34,6 +34,45 @@ PROFILE_SECTION_UPDATES: dict[str, CameraConfigUpdateEnum] = {
"zones": CameraConfigUpdateEnum.zones,
}
# Retained MQTT switch topics per profile section, with a payload getter.
# Republished on profile change so MQTT/HA don't show a stale toggle.
SECTION_STATE_TOPICS: dict[str, list[tuple[str, Callable[[Any], Any]]]] = {
"audio": [("audio", lambda c: "ON" if c.audio.enabled else "OFF")],
"birdseye": [
("birdseye", lambda c: "ON" if c.birdseye.enabled else "OFF"),
(
"birdseye_mode",
lambda c: c.birdseye.mode.value.upper() if c.birdseye.enabled else "OFF",
),
],
"detect": [("detect", lambda c: "ON" if c.detect.enabled else "OFF")],
"motion": [
("motion", lambda c: "ON" if c.motion.enabled else "OFF"),
("improve_contrast", lambda c: "ON" if c.motion.improve_contrast else "OFF"),
("motion_threshold", lambda c: c.motion.threshold),
("motion_contour_area", lambda c: c.motion.contour_area),
],
"notifications": [
("notifications", lambda c: "ON" if c.notifications.enabled else "OFF"),
],
"objects": [
("object_descriptions", lambda c: "ON" if c.objects.genai.enabled else "OFF"),
],
"record": [("recordings", lambda c: "ON" if c.record.enabled else "OFF")],
"review": [
("review_alerts", lambda c: "ON" if c.review.alerts.enabled else "OFF"),
(
"review_detections",
lambda c: "ON" if c.review.detections.enabled else "OFF",
),
(
"review_descriptions",
lambda c: "ON" if c.review.genai.enabled else "OFF",
),
],
"snapshots": [("snapshots", lambda c: "ON" if c.snapshots.enabled else "OFF")],
}
PERSISTENCE_FILE = Path(CONFIG_DIR) / ".profiles"
@@ -310,6 +349,15 @@ class ProfileManager:
settings,
)
# republish MQTT switch states
if self.dispatcher is not None:
for suffix, get_payload in SECTION_STATE_TOPICS.get(section, ()):
self.dispatcher.publish(
f"{cam_name}/{suffix}/state",
get_payload(cam_config),
retain=True,
)
def _persist_active_profile(self, profile_name: Optional[str]) -> None:
"""Persist the active profile state to disk as JSON."""
try:
+1 -1
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=",",
+1 -18
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",
+43 -9
View File
@@ -94,9 +94,21 @@ class AudioProcessor(FrigateProcess):
self.camera_metrics = camera_metrics
self.config = config
def __stop_audio_thread(self, camera: str) -> None:
thread = self.audio_threads.pop(camera, None)
if thread is None:
return
thread.stop()
thread.join(10)
if thread.is_alive():
self.logger.warning(f"Audio maintainer thread for {camera} is still alive")
else:
self.logger.info(f"Audio maintainer stopped for {camera}")
def run(self) -> None:
self.pre_run_setup(self.config.logger)
audio_threads: dict[str, AudioEventMaintainer] = {}
self.audio_threads: dict[str, AudioEventMaintainer] = {}
threading.current_thread().name = "process:audio_manager"
@@ -120,12 +132,13 @@ class AudioProcessor(FrigateProcess):
CameraConfigUpdateEnum.add,
CameraConfigUpdateEnum.audio,
CameraConfigUpdateEnum.ffmpeg,
CameraConfigUpdateEnum.remove,
],
)
def spawn_if_needed(camera: CameraConfig) -> None:
name = camera.name
if name is None or name in audio_threads:
if name is None or name in self.audio_threads:
return
if not camera.enabled or not camera.audio.enabled:
return
@@ -139,7 +152,7 @@ class AudioProcessor(FrigateProcess):
self.transcription_model_runner,
self.stop_event, # type: ignore[arg-type]
)
audio_threads[name] = thread
self.audio_threads[name] = thread
thread.start()
self.logger.info(f"Audio maintainer started for {name}")
@@ -148,21 +161,31 @@ class AudioProcessor(FrigateProcess):
self.logger.info(f"Audio processor started (pid: {self.pid})")
# poll for newly added cameras or cameras flipped to audio.enabled at runtime
# poll for newly added/removed cameras or cameras flipped to
# audio.enabled at runtime
while not self.stop_event.wait(timeout=1.0):
config_subscriber.check_for_updates()
updated_topics = config_subscriber.check_for_updates()
# stop maintainers for removed cameras so their ffmpeg process is
# torn down and they stop touching camera_metrics (which the camera
# maintainer has already popped for the removed camera)
for removed_camera in updated_topics.get(
CameraConfigUpdateEnum.remove.name, []
):
self.__stop_audio_thread(removed_camera)
for camera in self.config.cameras.values():
spawn_if_needed(camera)
config_subscriber.stop()
for thread in audio_threads.values():
for thread in self.audio_threads.values():
thread.join(1)
if thread.is_alive():
self.logger.info(f"Waiting for thread {thread.name:s} to exit")
thread.join(10)
for thread in audio_threads.values():
for thread in self.audio_threads.values():
if thread.is_alive():
self.logger.warning(f"Thread {thread.name} is still alive")
@@ -184,6 +207,9 @@ class AudioEventMaintainer(threading.Thread):
self.camera_config = camera
self.camera_metrics = camera_metrics
self.stop_event = stop_event
# per-camera stop signal so a single maintainer can be torn down at
# runtime (e.g. on camera removal) without stopping the whole process
self.camera_stop_event = threading.Event()
self.detector = AudioTfl(stop_event, self.camera_config.audio.num_threads)
self.shape = (int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE)),)
self.chunk_size = int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE * 2))
@@ -233,7 +259,11 @@ class AudioEventMaintainer(threading.Thread):
self.was_audio_enabled = camera.audio.enabled
def detect_audio(self, audio: np.ndarray) -> None:
if not self.camera_config.audio.enabled or self.stop_event.is_set():
if (
not self.camera_config.audio.enabled
or self.stop_event.is_set()
or self.camera_stop_event.is_set()
):
return
audio_as_float: np.ndarray = audio.astype(np.float32)
@@ -352,11 +382,15 @@ class AudioEventMaintainer(threading.Thread):
self.logger.error(f"Error reading audio data from ffmpeg process: {e}")
log_and_restart()
def stop(self) -> None:
"""Signal this maintainer to exit its run loop and clean up."""
self.camera_stop_event.set()
def run(self) -> None:
if self.camera_config.enabled:
self.start_or_restart_ffmpeg()
while not self.stop_event.is_set():
while not self.stop_event.is_set() and not self.camera_stop_event.is_set():
# check if there is an updated config
self.config_subscriber.check_for_updates()
+413 -329
View File
@@ -3,6 +3,8 @@
import logging
import os
import threading
import time
from collections.abc import Callable, Generator, Iterable
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
from dataclasses import asdict, dataclass, field
from datetime import datetime
@@ -19,6 +21,18 @@ from frigate.jobs.manager import (
get_job_by_id,
set_current_job,
)
from frigate.jobs.motion_search_batch import (
build_segment_time_map,
coalesce_runs,
stream_time_to_absolute,
)
from frigate.jobs.motion_search_decode import (
iter_vod_frames,
keyframe_sampling_eligible,
probe_video_dimensions,
probe_vod_keyframe_pts,
resolve_motion_decode_args,
)
from frigate.models import Recordings
from frigate.types import JobStatusTypesEnum
@@ -26,6 +40,18 @@ logger = logging.getLogger(__name__)
# Constants
HEATMAP_GRID_SIZE = 16
# Max wall-clock span of one VOD run request (seconds). Bounds per-request size
# and gives streaming/cancel/early-exit granularity.
MAX_RUN_SECONDS = 600.0
# Treat segments within this many seconds end-to-start as time-contiguous.
RUN_GAP_EPSILON = 1.0
# Longest-side pixels for the ROI downscale before motion detection.
SCALE_TARGET = 400
# Minimum wall seconds between intra-run progress broadcasts.
PROGRESS_BROADCAST_INTERVAL = 1.0
# Output frame rate for the fixed-cadence fallback used on long-GOP cameras
# (where keyframe sampling is too sparse). Keyframe cameras ignore this.
FALLBACK_SAMPLE_FPS = 2.0
@dataclass
@@ -69,13 +95,16 @@ class MotionSearchJob(Job):
polygon_points: list[list[float]] = field(default_factory=list)
threshold: int = 30
min_area: float = 5.0
frame_skip: int = 5
parallel: bool = False
max_results: int = 25
# Track progress
total_frames_processed: int = 0
# Live progress (ride the existing to_dict() websocket broadcast)
scanning_timestamp: Optional[float] = None
progress: float = 0.0
# Metrics for observability
metrics: Optional[MotionSearchMetrics] = None
@@ -100,6 +129,113 @@ def create_polygon_mask(
return mask
def compute_roi_crop_and_scale(
polygon_points: list[list[float]],
frame_width: int,
frame_height: int,
scale_target: int,
) -> tuple[tuple[int, int, int, int], tuple[int, int]]:
"""Compute the ROI crop box and never-upscale scaled dimensions.
Returns ((crop_w, crop_h, crop_x, crop_y), (scaled_w, scaled_h)) in pixels.
The crop is the polygon's bounding box in frame pixels; the scaled size fits
the crop's longest side to ``scale_target`` without ever enlarging it.
"""
xs = [p[0] for p in polygon_points]
ys = [p[1] for p in polygon_points]
# nv12 (4:2:0) hwdownload requires even crop offsets and even crop/scale
# dimensions; otherwise ffmpeg rounds the chroma planes and the raw byte
# stream stops matching the expected frame size. Force even values, and the
# mask is built from these same values so the two stay aligned.
crop_x = int(min(xs) * frame_width)
crop_y = int(min(ys) * frame_height)
crop_x -= crop_x % 2
crop_y -= crop_y % 2
crop_w = max(2, int(max(xs) * frame_width) - crop_x)
crop_h = max(2, int(max(ys) * frame_height) - crop_y)
crop_w -= crop_w % 2
crop_h -= crop_h % 2
longest = max(crop_w, crop_h)
factor = min(1.0, scale_target / longest)
scaled_w = max(2, round(crop_w * factor))
scaled_h = max(2, round(crop_h * factor))
scaled_w -= scaled_w % 2
scaled_h -= scaled_h % 2
return (crop_w, crop_h, crop_x, crop_y), (scaled_w, scaled_h)
def build_scaled_roi_mask(
polygon_points: list[list[float]],
frame_width: int,
frame_height: int,
crop: tuple[int, int, int, int],
scaled: tuple[int, int],
) -> np.ndarray:
"""Rasterize the polygon mask at the scaled ROI size.
Builds the full-resolution mask, crops it to the ROI box, and nearest-
neighbor resizes it to the scaled dimensions so it lines up exactly with the
frames ffmpeg crops and scales.
"""
crop_w, crop_h, crop_x, crop_y = crop
scaled_w, scaled_h = scaled
full_mask = create_polygon_mask(polygon_points, frame_width, frame_height)
cropped = full_mask[crop_y : crop_y + crop_h, crop_x : crop_x + crop_w]
return cv2.resize(cropped, (scaled_w, scaled_h), interpolation=cv2.INTER_NEAREST)
def detect_motion_scaled(
frames: Iterable[tuple[int, np.ndarray]],
mask: np.ndarray,
threshold: int,
min_area: float,
timestamp_fn: Callable[[int], float],
) -> list[MotionSearchResult]:
"""Detect motion across pre-cropped, pre-scaled gray frames.
``frames`` yields (absolute_frame_index, gray_roi_frame); ``mask`` is the
scaled ROI mask. ``min_area`` is a percentage of the masked ROI. Mirrors the
full-res detection math (absdiff -> blur -> threshold -> dilate -> contours)
on the already-reduced frames.
"""
results: list[MotionSearchResult] = []
mask_area = np.count_nonzero(mask)
if mask_area == 0:
return results
min_area_pixels = int((min_area / 100.0) * mask_area)
prev: np.ndarray | None = None
for frame_idx, gray in frames:
masked = cv2.bitwise_and(gray, gray, mask=mask)
if prev is not None:
diff = cv2.absdiff(prev, masked)
diff_blurred = cv2.GaussianBlur(diff, (3, 3), 0)
_, thresh = cv2.threshold(diff_blurred, threshold, 255, cv2.THRESH_BINARY)
thresh_dilated = cv2.dilate(thresh, None, iterations=1) # type: ignore[call-overload]
thresh_masked = cv2.bitwise_and(thresh_dilated, thresh_dilated, mask=mask)
change_pixels = cv2.countNonZero(thresh_masked)
if change_pixels > min_area_pixels:
contours, _ = cv2.findContours(
thresh_masked, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
total_change_area = sum(
cv2.contourArea(c)
for c in contours
if cv2.contourArea(c) >= min_area_pixels
)
if total_change_area > 0:
change_percentage = (total_change_area / mask_area) * 100
results.append(
MotionSearchResult(
timestamp=timestamp_fn(frame_idx),
change_percentage=round(change_percentage, 2),
)
)
prev = masked
return results
def compute_roi_bbox_normalized(
polygon_points: list[list[float]],
) -> tuple[float, float, float, float]:
@@ -184,6 +320,22 @@ def segment_passes_heatmap_gate(
return heatmap_overlaps_roi(heatmap, roi_bbox)
def resolve_internal_port(config: FrigateConfig) -> int:
"""Return the unauthenticated internal nginx port for VOD requests."""
listen = config.networking.listen.internal
if isinstance(listen, str):
return int(listen.split(":")[-1])
return int(listen)
def build_vod_url(internal_port: int, camera: str, start: float, end: float) -> str:
"""Build the internal VOD HLS URL for a camera time range."""
return (
f"http://127.0.0.1:{internal_port}/vod/{camera}"
f"/start/{start}/end/{end}/index.m3u8"
)
class MotionSearchRunner(threading.Thread):
"""Thread-based runner for motion search jobs with parallel verification."""
@@ -206,6 +358,23 @@ class MotionSearchRunner(threading.Thread):
cpu_count = os.cpu_count() or 1
self.max_workers = min(4, cpu_count)
# Resolved once per job in _execute_search
self.ffmpeg_path: str = "ffmpeg"
self.ffprobe_path: str = "ffprobe"
self.decode_args: list[str] = []
# Keyframe sampling decision, decided once per job from the first run's
# GOP. The fallback cadence is a fixed rate (see FALLBACK_SAMPLE_FPS).
self.use_keyframe: bool = True
self.fps_rate: float = FALLBACK_SAMPLE_FPS
# ROI crop/scale + scaled mask, computed once from the VOD-stream
# dimensions (which can differ from the detect resolution).
self.crop: tuple[int, int, int, int] = (0, 0, 0, 0)
self.scaled: tuple[int, int] = (0, 0)
self.scaled_mask: np.ndarray = np.zeros((0, 0), dtype=np.uint8)
self.channels: int = 1
self.internal_port: int = 5000
self._last_progress_broadcast: float = 0.0
def run(self) -> None:
"""Execute the motion search job."""
try:
@@ -281,6 +450,9 @@ class MotionSearchRunner(threading.Thread):
if frame_width is None or frame_height is None:
raise ValueError(f"Camera {camera_name} detect dimensions not configured")
self.ffmpeg_path = camera_config.ffmpeg.ffmpeg_path
self.ffprobe_path = camera_config.ffmpeg.ffprobe_path
# Create polygon mask
polygon_mask = create_polygon_mask(
self.job.polygon_points, frame_width, frame_height
@@ -384,205 +556,274 @@ class MotionSearchRunner(threading.Thread):
self.metrics.heatmap_roi_skip_segments,
)
if self.job.parallel:
return self._search_motion_parallel(filtered_recordings, polygon_mask)
# Resolve decode backend (allowlisted hwaccel or software), coalesce the
# gate-passing segments into time-contiguous runs, and probe the first
# run's VOD stream once for dimensions + keyframe layout. VOD output is
# what we decode, so crop/scale/mask are computed against it.
self.internal_port = resolve_internal_port(self.config)
self.decode_args = resolve_motion_decode_args(camera_config)
ffprobe_path = self.ffprobe_path
return self._search_motion_sequential(filtered_recordings, polygon_mask)
runs = coalesce_runs(filtered_recordings, MAX_RUN_SECONDS, RUN_GAP_EPSILON)
if not runs:
return []
def _search_motion_parallel(
self,
recordings: list[Recordings],
polygon_mask: np.ndarray,
) -> list[MotionSearchResult]:
"""Search for motion in parallel across segments, streaming results."""
all_results: list[MotionSearchResult] = []
total_frames = 0
next_recording_idx_to_merge = 0
first_run = runs[0]
first_url = build_vod_url(
self.internal_port,
camera_name,
float(first_run[0].start_time),
float(first_run[-1].end_time),
)
dims = probe_video_dimensions(ffprobe_path, first_url)
if dims is None:
raise ValueError(f"Could not probe VOD dimensions for camera {camera_name}")
rec_width, rec_height, _rec_fps = dims
self.crop, self.scaled = compute_roi_crop_and_scale(
self.job.polygon_points, rec_width, rec_height, SCALE_TARGET
)
self.scaled_mask = build_scaled_roi_mask(
self.job.polygon_points, rec_width, rec_height, self.crop, self.scaled
)
self.channels = 1 # always gray output
# Decide keyframe vs fixed-cadence sampling once from the first run's GOP
# (keyframe structure is a per-camera constant).
first_pts = probe_vod_keyframe_pts(ffprobe_path, first_url)
self.use_keyframe = keyframe_sampling_eligible(first_pts)
logger.debug(
"Motion search job %s: starting motion search with %d workers "
"across %d segments",
"Motion search job %s: %d runs, sampling=%s, hwaccel=%s, vod=%dx%d",
self.job.id,
self.max_workers,
len(recordings),
len(runs),
"keyframe" if self.use_keyframe else "cadence",
bool(self.decode_args),
rec_width,
rec_height,
)
# Initialize partial results on the job so they stream to the frontend
return self._search_runs(runs)
def _emit_progress(self, abs_ts: float) -> None:
"""Throttled intra-run progress broadcast (scanning cursor)."""
now = time.monotonic()
if now - self._last_progress_broadcast < PROGRESS_BROADCAST_INTERVAL:
return
self._last_progress_broadcast = now
self.job.scanning_timestamp = abs_ts
self._broadcast_status()
def _detect_with_progress(
self,
indexed_frames: list[tuple[int, np.ndarray]],
timestamp_fn: Callable[[int], float],
) -> list[MotionSearchResult]:
"""Run detection while firing throttled progress as frames are scanned."""
def _gen() -> Generator[tuple[int, np.ndarray], None, None]:
for i, frame in indexed_frames:
if not self._should_stop():
self._emit_progress(timestamp_fn(i))
yield i, frame
return detect_motion_scaled(
_gen(),
self.scaled_mask,
self.job.threshold,
self.job.min_area,
timestamp_fn,
)
def _process_run(
self, run: list[Recordings]
) -> tuple[list[MotionSearchResult], int]:
"""Decode one run's VOD stream and detect motion.
Keyframe mode compares every decoded keyframe (free recall, since they
are all decoded anyway) paired with its probed PTS; if the decoded and
probed counts disagree (the decoder ignored ``-skip_frame nokey`` or the
stream is corrupt) this run re-runs in the fixed-cadence fallback.
Returns ``(results, frame_count)``.
"""
run_start: float = run[0].start_time # type: ignore[assignment]
run_end: float = run[-1].end_time # type: ignore[assignment]
vod_url = build_vod_url(self.internal_port, self.job.camera, run_start, run_end)
time_map = build_segment_time_map(run)
if self.use_keyframe:
kf_pts = probe_vod_keyframe_pts(self.ffprobe_path, vod_url)
frames = list(
iter_vod_frames(
self.ffmpeg_path,
vod_url,
self.scaled[0],
self.scaled[1],
self.channels,
self.decode_args,
self.crop,
self.scaled,
True,
self._should_stop,
skip_nonkey=True,
fps_rate=None,
)
)
if kf_pts and len(frames) == len(kf_pts):
abs_times = [stream_time_to_absolute(time_map, p) for p in kf_pts]
indexed = list(enumerate(frames))
def _ts_kf(i: int) -> float:
return abs_times[i]
results = self._detect_with_progress(indexed, _ts_kf)
return results, len(frames)
logger.debug(
"Keyframe count mismatch (%d decoded vs %d probed), using cadence",
len(frames),
len(kf_pts),
)
return self._process_run_cadence(vod_url, time_map)
def _process_run_cadence(
self, vod_url: str, time_map: list[tuple[float, float, float]]
) -> tuple[list[MotionSearchResult], int]:
"""Fixed-cadence fallback: fps-filtered VOD decode, evenly spaced times."""
frames = list(
iter_vod_frames(
self.ffmpeg_path,
vod_url,
self.scaled[0],
self.scaled[1],
self.channels,
self.decode_args,
self.crop,
self.scaled,
True,
self._should_stop,
skip_nonkey=False,
fps_rate=self.fps_rate,
)
)
indexed = list(enumerate(frames))
def _ts_fps(i: int) -> float:
return stream_time_to_absolute(time_map, i / self.fps_rate)
results = self._detect_with_progress(indexed, _ts_fps)
return results, len(frames)
def _merge_run(
self,
run: list[Recordings],
run_results: list[MotionSearchResult],
frames: int,
state: dict[str, Any],
) -> bool:
"""Fold one run's output into the running results; stream + dedup.
Returns True once ``max_results`` deduped hits have accumulated.
"""
state["completed_runs"] += 1
state["all_results"].extend(run_results)
state["total_frames"] += frames
self.job.total_frames_processed = state["total_frames"]
self.metrics.frames_decoded = state["total_frames"]
self.metrics.segments_processed += len(run)
self.job.progress = state["completed_runs"] / state["total_runs"]
state["all_results"].sort(key=lambda r: r.timestamp)
deduped = self._deduplicate_results(state["all_results"])[
: self.job.max_results
]
self.job.results = {
"results": [r.to_dict() for r in deduped],
"total_frames_processed": state["total_frames"],
}
self._broadcast_status()
return len(deduped) >= self.job.max_results
def _search_runs(self, runs: list[list[Recordings]]) -> list[MotionSearchResult]:
"""Decode runs (parallel pool when enabled), merge in order, stream."""
state: dict[str, Any] = {
"all_results": [],
"total_frames": 0,
"completed_runs": 0,
"total_runs": len(runs),
}
self.job.results = {"results": [], "total_frames_processed": 0}
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
futures: dict[Future, int] = {}
completed_segments: dict[int, tuple[list[MotionSearchResult], int]] = {}
logger.debug(
"Motion search job %s: searching %d runs (parallel=%s, workers=%d)",
self.job.id,
len(runs),
self.job.parallel,
self.max_workers,
)
for idx, recording in enumerate(recordings):
if self._should_stop():
break
if self.job.parallel and len(runs) > 1:
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
futures: dict[Future, int] = {}
for idx, run in enumerate(runs):
if self._should_stop():
break
futures[executor.submit(self._process_run, run)] = idx
rec_start: float = recording.start_time # type: ignore[assignment]
rec_end: float = recording.end_time # type: ignore[assignment]
future = executor.submit(
self._process_recording_for_motion,
str(recording.path),
rec_start,
rec_end,
self.job.start_time_range,
self.job.end_time_range,
polygon_mask,
self.job.threshold,
self.job.min_area,
self.job.frame_skip,
)
futures[future] = idx
completed: dict[int, tuple[list[MotionSearchResult], int]] = {}
next_idx = 0
for future in as_completed(futures):
if self._should_stop():
break
run_idx = futures[future]
try:
completed[run_idx] = future.result()
except Exception as e:
self.metrics.segments_with_errors += 1
logger.warning("Error processing run %d: %s", run_idx, e)
completed[run_idx] = ([], 0)
for future in as_completed(futures):
if self._should_stop():
# Cancel remaining futures
for f in futures:
f.cancel()
break
recording_idx = futures[future]
recording = recordings[recording_idx]
try:
results, frames = future.result()
self.metrics.segments_processed += 1
completed_segments[recording_idx] = (results, frames)
while next_recording_idx_to_merge in completed_segments:
segment_results, segment_frames = completed_segments.pop(
next_recording_idx_to_merge
)
all_results.extend(segment_results)
total_frames += segment_frames
self.job.total_frames_processed = total_frames
self.metrics.frames_decoded = total_frames
if segment_results:
deduped = self._deduplicate_results(all_results)
self.job.results = {
"results": [
r.to_dict() for r in deduped[: self.job.max_results]
],
"total_frames_processed": total_frames,
}
self._broadcast_status()
if segment_results and len(deduped) >= self.job.max_results:
while next_idx in completed:
run_results, frames = completed.pop(next_idx)
if self._merge_run(runs[next_idx], run_results, frames, state):
self.internal_stop_event.set()
for pending_future in futures:
pending_future.cancel()
for pending in futures:
pending.cancel()
break
next_recording_idx_to_merge += 1
next_idx += 1
if self.internal_stop_event.is_set():
break
else:
for run in runs:
if self._should_stop():
break
try:
run_results, frames = self._process_run(run)
except Exception as e:
self.metrics.segments_processed += 1
self.metrics.segments_with_errors += 1
self.metrics.segments_processed += len(run)
self._broadcast_status()
logger.warning(
"Error processing segment %s: %s",
recording.path,
e,
)
self.job.total_frames_processed = total_frames
self.metrics.frames_decoded = total_frames
logger.debug(
"Motion search job %s: motion search complete, "
"found %d raw results, decoded %d frames, %d segment errors",
self.job.id,
len(all_results),
total_frames,
self.metrics.segments_with_errors,
)
# Sort and deduplicate results
all_results.sort(key=lambda x: x.timestamp)
return self._deduplicate_results(all_results)[: self.job.max_results]
def _search_motion_sequential(
self,
recordings: list[Recordings],
polygon_mask: np.ndarray,
) -> list[MotionSearchResult]:
"""Search for motion sequentially across segments, streaming results."""
all_results: list[MotionSearchResult] = []
total_frames = 0
logger.debug(
"Motion search job %s: starting sequential motion search across %d segments",
self.job.id,
len(recordings),
)
self.job.results = {"results": [], "total_frames_processed": 0}
for recording in recordings:
if self.cancel_event.is_set():
break
try:
rec_start: float = recording.start_time # type: ignore[assignment]
rec_end: float = recording.end_time # type: ignore[assignment]
results, frames = self._process_recording_for_motion(
str(recording.path),
rec_start,
rec_end,
self.job.start_time_range,
self.job.end_time_range,
polygon_mask,
self.job.threshold,
self.job.min_area,
self.job.frame_skip,
)
all_results.extend(results)
total_frames += frames
self.job.total_frames_processed = total_frames
self.metrics.frames_decoded = total_frames
self.metrics.segments_processed += 1
if results:
all_results.sort(key=lambda x: x.timestamp)
deduped = self._deduplicate_results(all_results)[
: self.job.max_results
]
self.job.results = {
"results": [r.to_dict() for r in deduped],
"total_frames_processed": total_frames,
}
self._broadcast_status()
if results and len(deduped) >= self.job.max_results:
logger.warning("Error processing run: %s", e)
continue
if self._merge_run(run, run_results, frames, state):
break
except Exception as e:
self.metrics.segments_processed += 1
self.metrics.segments_with_errors += 1
self._broadcast_status()
logger.warning("Error processing segment %s: %s", recording.path, e)
self.job.total_frames_processed = total_frames
self.metrics.frames_decoded = total_frames
all_results: list[MotionSearchResult] = state["all_results"]
self.job.total_frames_processed = state["total_frames"]
self.metrics.frames_decoded = state["total_frames"]
self.job.progress = 1.0
logger.debug(
"Motion search job %s: sequential motion search complete, "
"found %d raw results, decoded %d frames, %d segment errors",
"Motion search job %s: complete, %d raw results, %d frames, %d errors",
self.job.id,
len(all_results),
total_frames,
state["total_frames"],
self.metrics.segments_with_errors,
)
all_results.sort(key=lambda x: x.timestamp)
all_results.sort(key=lambda r: r.timestamp)
return self._deduplicate_results(all_results)[: self.job.max_results]
def _deduplicate_results(
@@ -602,160 +843,6 @@ class MotionSearchRunner(threading.Thread):
return deduplicated
def _process_recording_for_motion(
self,
recording_path: str,
recording_start: float,
recording_end: float,
search_start: float,
search_end: float,
polygon_mask: np.ndarray,
threshold: int,
min_area: float,
frame_skip: int,
) -> tuple[list[MotionSearchResult], int]:
"""Process a single recording file for motion detection.
This method is designed to be called from a thread pool.
Args:
min_area: Minimum change area as a percentage of the ROI (0-100).
"""
results: list[MotionSearchResult] = []
frames_processed = 0
if not os.path.exists(recording_path):
logger.warning("Recording file not found: %s", recording_path)
return results, frames_processed
cap = cv2.VideoCapture(recording_path)
if not cap.isOpened():
logger.error("Could not open recording: %s", recording_path)
return results, frames_processed
try:
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
recording_duration = recording_end - recording_start
# Calculate frame range
start_offset = max(0, search_start - recording_start)
end_offset = min(recording_duration, search_end - recording_start)
start_frame = int(start_offset * fps)
end_frame = int(end_offset * fps)
start_frame = max(0, min(start_frame, total_frames - 1))
end_frame = max(0, min(end_frame, total_frames))
if start_frame >= end_frame:
return results, frames_processed
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
# Get ROI bounding box
roi_bbox = cv2.boundingRect(polygon_mask)
roi_x, roi_y, roi_w, roi_h = roi_bbox
prev_frame_gray = None
frame_step = max(frame_skip, 1)
frame_idx = start_frame
while frame_idx < end_frame:
if self._should_stop():
break
ret, frame = cap.read()
if not ret:
frame_idx += 1
continue
if (frame_idx - start_frame) % frame_step != 0:
frame_idx += 1
continue
frames_processed += 1
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# Handle frame dimension changes
if gray.shape != polygon_mask.shape:
resized_mask = cv2.resize(
polygon_mask,
(gray.shape[1], gray.shape[0]),
interpolation=cv2.INTER_NEAREST,
)
current_bbox = cv2.boundingRect(resized_mask)
else:
resized_mask = polygon_mask
current_bbox = roi_bbox
roi_x, roi_y, roi_w, roi_h = current_bbox
cropped_gray = gray[roi_y : roi_y + roi_h, roi_x : roi_x + roi_w]
cropped_mask = resized_mask[
roi_y : roi_y + roi_h, roi_x : roi_x + roi_w
]
cropped_mask_area = np.count_nonzero(cropped_mask)
if cropped_mask_area == 0:
frame_idx += 1
continue
# Convert percentage to pixel count for this ROI
min_area_pixels = int((min_area / 100.0) * cropped_mask_area)
masked_gray = cv2.bitwise_and(
cropped_gray, cropped_gray, mask=cropped_mask
)
if prev_frame_gray is not None:
diff = cv2.absdiff(prev_frame_gray, masked_gray) # type: ignore[unreachable]
diff_blurred = cv2.GaussianBlur(diff, (3, 3), 0)
_, thresh = cv2.threshold(
diff_blurred, threshold, 255, cv2.THRESH_BINARY
)
thresh_dilated = cv2.dilate(thresh, None, iterations=1)
thresh_masked = cv2.bitwise_and(
thresh_dilated, thresh_dilated, mask=cropped_mask
)
change_pixels = cv2.countNonZero(thresh_masked)
if change_pixels > min_area_pixels:
contours, _ = cv2.findContours(
thresh_masked, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
total_change_area = sum(
cv2.contourArea(c)
for c in contours
if cv2.contourArea(c) >= min_area_pixels
)
if total_change_area > 0:
frame_time_offset = (frame_idx - start_frame) / fps
timestamp = (
recording_start + start_offset + frame_time_offset
)
change_percentage = (
total_change_area / cropped_mask_area
) * 100
results.append(
MotionSearchResult(
timestamp=timestamp,
change_percentage=round(change_percentage, 2),
)
)
prev_frame_gray = masked_gray
frame_idx += 1
finally:
cap.release()
logger.debug(
"Motion search segment complete: %s, %d frames processed, %d results found",
recording_path,
frames_processed,
len(results),
)
return results, frames_processed
# Module-level state for managing per-camera jobs
_motion_search_jobs: dict[str, tuple[MotionSearchJob, threading.Event]] = {}
@@ -779,7 +866,6 @@ def start_motion_search_job(
polygon_points: list[list[float]],
threshold: int = 30,
min_area: float = 5.0,
frame_skip: int = 5,
parallel: bool = False,
max_results: int = 25,
) -> str:
@@ -794,7 +880,6 @@ def start_motion_search_job(
polygon_points=polygon_points,
threshold=threshold,
min_area=min_area,
frame_skip=frame_skip,
parallel=parallel,
max_results=max_results,
)
@@ -812,14 +897,13 @@ def start_motion_search_job(
logger.debug(
"Started motion search job %s for camera %s: "
"time_range=%.1f-%.1f, threshold=%d, min_area=%.1f%%, "
"frame_skip=%d, parallel=%s, max_results=%d, polygon_points=%d vertices",
"parallel=%s, max_results=%d, polygon_points=%d vertices",
job.id,
camera_name,
start_time,
end_time,
threshold,
min_area,
frame_skip,
parallel,
max_results,
len(polygon_points),
+75
View File
@@ -0,0 +1,75 @@
"""Pure helpers for VOD-batched motion search.
Coalescing gate-passing segments into time-contiguous runs, mapping a frame's
VOD stream time back to an absolute timestamp, and thinning sample times to a
target interval. No I/O or ffmpeg here so the tricky math stays unit-testable.
"""
from bisect import bisect_right
from typing import Any
def coalesce_runs(
segments: list[Any], max_seconds: float, epsilon: float
) -> list[list[Any]]:
"""Group gate-passing segments into time-contiguous runs.
A run extends while each segment's ``start_time`` is within ``epsilon`` of
the previous segment's ``end_time`` (no recording gap) and the run's total
span stays at or below ``max_seconds``. A gap or the cap starts a new run.
Each segment must expose ``start_time`` / ``end_time``.
"""
runs: list[list[Any]] = []
current: list[Any] = []
for seg in segments:
if not current:
current = [seg]
continue
prev_end = float(current[-1].end_time)
run_start = float(current[0].start_time)
contiguous = abs(float(seg.start_time) - prev_end) <= epsilon
within_cap = (float(seg.end_time) - run_start) <= max_seconds
if contiguous and within_cap:
current.append(seg)
else:
runs.append(current)
current = [seg]
if current:
runs.append(current)
return runs
def build_segment_time_map(
run: list[Any],
) -> list[tuple[float, float, float]]:
"""Build a (stream_offset, abs_start, duration) row per segment in a run.
``stream_offset`` is the segment's start in continuous VOD stream time (the
cumulative sum of preceding segment durations); ``abs_start`` is its absolute
``start_time``. Built from each segment's own duration; for a gap-free run
this makes stream time equal ``run_start + offset``.
"""
rows: list[tuple[float, float, float]] = []
offset = 0.0
for seg in run:
duration = float(seg.end_time) - float(seg.start_time)
rows.append((offset, float(seg.start_time), duration))
offset += duration
return rows
def stream_time_to_absolute(
time_map: list[tuple[float, float, float]], stream_time: float
) -> float:
"""Map a VOD stream time to an absolute timestamp via the run's table.
Binary-searches the segment whose stream range contains ``stream_time`` and
returns ``abs_start + (stream_time - stream_offset)``. Times past the last
segment map into the last segment (clamped at the run edge).
"""
offsets = [row[0] for row in time_map]
idx = bisect_right(offsets, stream_time) - 1
if idx < 0:
idx = 0
stream_offset, abs_start, _duration = time_map[idx]
return abs_start + (stream_time - stream_offset)
+382
View File
@@ -0,0 +1,382 @@
"""Hardware-accelerated ffmpeg decode for motion search.
Decodes a recording run's VOD/HLS stream with an ffmpeg subprocess, optionally
selecting only keyframes, and streams raw frames over a pipe for the motion
math. Output is the requested ``pix_fmt`` (gray or ``bgr24``) with optional
crop/scale applied in the filter graph so downstream pixels are unchanged.
"""
import json
import logging
import subprocess as sp
import tempfile
from collections.abc import Callable, Generator
from typing import IO
import numpy as np
from frigate.config import CameraConfig
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_decode
from frigate.util.services import auto_detect_hwaccel
logger = logging.getLogger(__name__)
# Output-format surfaces that download cleanly to nv12 via the fixed
# ``hwdownload,format=nv12`` step the decode path appends. Other surfaces
# (drm_prime from rkmpp, vulkan, amf) need a different download step, so motion
# search decodes them in software to keep results byte-identical rather than risk
# a wrong-but-valid-sized frame the zero-frame fallback gate would not catch.
_NV12_OUTPUT_FORMATS = frozenset({"vaapi", "cuda", "qsv"})
def _hwaccel_output_format(decode_args: list[str]) -> str | None:
"""Return the ``-hwaccel_output_format`` value in ffmpeg args, or None."""
try:
idx = decode_args.index("-hwaccel_output_format")
except ValueError:
return None
return decode_args[idx + 1] if idx + 1 < len(decode_args) else None
def resolve_motion_decode_args(camera_config: CameraConfig) -> list[str]:
"""Resolve the ffmpeg hwaccel decode args for a camera's recordings.
``auto`` is resolved via ``auto_detect_hwaccel`` and the preset is expanded
by ``parse_preset_hardware_acceleration_decode`` (the same table the live
pipeline uses). Acceleration is kept only when the decoded surface downloads
cleanly to nv12 -- decided by reading ``-hwaccel_output_format`` back from the
resolved args rather than a separate preset allowlist that could drift from
``PRESETS_HW_ACCEL_DECODE``. Anything else (custom args, a software-only
preset, or an nv12-incompatible surface) returns an empty list, meaning
software decode, so results stay byte-identical.
"""
raw = camera_config.ffmpeg.hwaccel_args
preset = auto_detect_hwaccel() if raw == "auto" else raw
# Custom args (a list) decode in software so results stay byte-identical.
if not isinstance(preset, str):
return []
decode_args = parse_preset_hardware_acceleration_decode(
preset,
camera_config.detect.fps,
camera_config.detect.width or 0,
camera_config.detect.height or 0,
camera_config.ffmpeg.gpu,
)
if not decode_args:
return []
if _hwaccel_output_format(decode_args) not in _NV12_OUTPUT_FORMATS:
return []
return decode_args
def _read_exact(stream: IO[bytes], size: int) -> bytes | None:
"""Read exactly ``size`` bytes from a pipe, or None at clean EOF.
Pipe reads can return fewer bytes than requested, so loop until the frame
is complete. A short read at the start of a frame means end-of-stream.
"""
buf = bytearray()
while len(buf) < size:
chunk = stream.read(size - len(buf))
if not chunk:
return None
buf.extend(chunk)
return bytes(buf)
def _terminate(proc: sp.Popen[bytes]) -> None:
"""Stop an ffmpeg decode process promptly."""
# Close the read end first so a blocked ffmpeg write unblocks (ffmpeg then
# sees a broken pipe), then signal it. The resulting ffmpeg write error is
# harmless and goes to the captured stderr.
if proc.stdout is not None:
try:
proc.stdout.close()
except OSError:
pass
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except sp.TimeoutExpired:
proc.kill()
proc.wait()
KEYFRAME_MAX_GAP_SECONDS = 2.0
def keyframe_sampling_eligible(
keyframe_pts: list[float], max_gap: float = KEYFRAME_MAX_GAP_SECONDS
) -> bool:
"""True if keyframes are dense and regular enough for keyframe-only sampling.
Requires at least two keyframes and no gap longer than ``max_gap`` seconds, so
a multi-second motion event necessarily spans a sampled keyframe.
"""
if len(keyframe_pts) < 2:
return False
gaps = [b - a for a, b in zip(keyframe_pts, keyframe_pts[1:])]
return max(gaps) <= max_gap
VOD_PROTOCOL_ARGS = ["-protocol_whitelist", "pipe,file,http,tcp"]
def build_vod_decode_command(
ffmpeg_path: str,
vod_url: str,
decode_args: list[str],
crop: tuple[int, int, int, int] | None,
scale: tuple[int, int] | None,
gray: bool,
*,
skip_nonkey: bool,
fps_rate: float | None,
) -> list[str]:
"""Build the ffmpeg argv to decode a VOD HLS URL.
``skip_nonkey`` adds ``-skip_frame nokey`` (keyframe-only). ``fps_rate`` adds
an ``fps`` filter for the fixed-cadence fallback. They are mutually
exclusive: keyframe mode passes ``skip_nonkey=True``/``fps_rate=None``; the
fallback passes ``skip_nonkey=False`` with a rate.
"""
filters: list[str] = []
# With hwaccel the decoded frames are GPU surfaces; pull them back to system
# memory before the CPU fps/crop/scale filters and the rawvideo encoder.
if decode_args:
filters.append("hwdownload")
filters.append("format=nv12")
if fps_rate is not None:
filters.append(f"fps={fps_rate}")
if crop is not None:
cw, ch, cx, cy = crop
filters.append(f"crop={cw}:{ch}:{cx}:{cy}")
if scale is not None:
sw, sh = scale
filters.append(f"scale={sw}:{sh}")
pix_fmt = "gray" if gray else "bgr24"
cmd = [ffmpeg_path, "-hide_banner", "-loglevel", "error"]
if skip_nonkey:
cmd += ["-skip_frame", "nokey"]
cmd += [*decode_args, *VOD_PROTOCOL_ARGS, "-i", vod_url, "-an"]
if filters:
cmd += ["-vf", ",".join(filters)]
cmd += ["-vsync", "0", "-f", "rawvideo", "-pix_fmt", pix_fmt, "pipe:"]
return cmd
def _run_vod_decode(
ffmpeg_path: str,
vod_url: str,
out_width: int,
out_height: int,
channels: int,
decode_args: list[str],
crop: tuple[int, int, int, int] | None,
scale: tuple[int, int] | None,
gray: bool,
should_stop: Callable[[], bool],
*,
skip_nonkey: bool,
fps_rate: float | None,
software_retry: bool,
) -> Generator[np.ndarray, None, None]:
"""Run one VOD decode, yielding raw frames; retry in software if empty."""
cmd = build_vod_decode_command(
ffmpeg_path,
vod_url,
decode_args,
crop,
scale,
gray,
skip_nonkey=skip_nonkey,
fps_rate=fps_rate,
)
frame_size = out_width * out_height * channels
stderr_file = tempfile.SpooledTemporaryFile(max_size=65536)
proc = sp.Popen(cmd, stdout=sp.PIPE, stderr=stderr_file)
assert proc.stdout is not None
count = 0
try:
while True:
if should_stop():
break
buf = _read_exact(proc.stdout, frame_size)
if buf is None:
break
if channels == 1:
frame = np.frombuffer(buf, dtype=np.uint8).reshape(
(out_height, out_width)
)
else:
frame = np.frombuffer(buf, dtype=np.uint8).reshape(
(out_height, out_width, channels)
)
count += 1
yield frame
finally:
_terminate(proc)
stderr_file.close()
if count == 0 and software_retry and not should_stop():
logger.warning("Hardware VOD decode produced no frames, retrying in software")
yield from _run_vod_decode(
ffmpeg_path,
vod_url,
out_width,
out_height,
channels,
[],
crop,
scale,
gray,
should_stop,
skip_nonkey=skip_nonkey,
fps_rate=fps_rate,
software_retry=False,
)
def iter_vod_frames(
ffmpeg_path: str,
vod_url: str,
out_width: int,
out_height: int,
channels: int,
decode_args: list[str],
crop: tuple[int, int, int, int] | None,
scale: tuple[int, int] | None,
gray: bool,
should_stop: Callable[[], bool],
*,
skip_nonkey: bool,
fps_rate: float | None,
) -> Generator[np.ndarray, None, None]:
"""Decode a VOD HLS URL and yield raw frames in order.
Pair keyframe-mode output with probed keyframe PTS; pair fallback output with
a fixed cadence. Falls back once to software decode if a hwaccel decode yields
no frames.
"""
yield from _run_vod_decode(
ffmpeg_path,
vod_url,
out_width,
out_height,
channels,
decode_args,
crop,
scale,
gray,
should_stop,
skip_nonkey=skip_nonkey,
fps_rate=fps_rate,
software_retry=bool(decode_args),
)
def probe_vod_keyframe_pts(ffprobe_path: str, vod_url: str) -> list[float]:
"""Return keyframe presentation timestamps (VOD stream time) in order.
Reads packet flags via ffprobe over the VOD URL (no decode). Returns [] on
any failure so the caller can fall back.
"""
cmd = [
ffprobe_path,
"-v",
"error",
*VOD_PROTOCOL_ARGS,
"-i",
vod_url,
"-select_streams",
"v:0",
"-show_packets",
"-show_entries",
"packet=pts_time,flags",
"-of",
"json",
]
try:
completed = sp.run(cmd, capture_output=True, text=True, timeout=120)
except (OSError, sp.SubprocessError):
logger.warning("ffprobe failed for VOD keyframe probe")
return []
if completed.returncode != 0 or not completed.stdout:
return []
try:
packets = json.loads(completed.stdout).get("packets", [])
except json.JSONDecodeError:
return []
pts: list[float] = []
for pkt in packets:
flags = pkt.get("flags", "")
pts_time = pkt.get("pts_time")
if flags.startswith("K") and pts_time is not None:
try:
pts.append(float(pts_time))
except ValueError:
continue
return sorted(pts)
def probe_video_dimensions(
ffprobe_path: str, recording_path: str
) -> tuple[int, int, float] | None:
"""Return (width, height, fps) for a recording's video stream, or None.
Reads stream metadata via ffprobe (no decode). The record stream resolution
can differ from the camera's detect resolution, so this is probed once per
job against a real segment.
"""
cmd = [
ffprobe_path,
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height,avg_frame_rate",
"-of",
"json",
recording_path,
]
try:
completed = sp.run(cmd, capture_output=True, text=True, timeout=30)
except (OSError, sp.SubprocessError):
return None
if completed.returncode != 0 or not completed.stdout:
return None
try:
streams = json.loads(completed.stdout).get("streams", [])
except json.JSONDecodeError:
return None
if not streams:
return None
stream = streams[0]
width = int(stream.get("width", 0) or 0)
height = int(stream.get("height", 0) or 0)
rate = stream.get("avg_frame_rate", "0/0") or "0/0"
try:
num, _, den = rate.partition("/")
fps = float(num) / float(den) if float(den) != 0 else 0.0
except (ValueError, ZeroDivisionError):
fps = 0.0
if width <= 0 or height <= 0:
return None
return width, height, fps
+72
View File
@@ -403,3 +403,75 @@ class TestHttpMedia(BaseTestHttp):
assert len(summary) == 1
assert "2024-03-10" in summary
assert summary["2024-03-10"] is True
def test_recordings_unavailable_reports_gap_between_recordings(self):
"""A gap between two recordings is reported as an unavailable segment."""
with AuthTestClient(self.app) as client:
# Two recordings with a 20s gap (1010-1030) between them.
Recordings.insert(
id="rec_a",
path="/media/recordings/a.mp4",
camera="front_door",
start_time=1000,
end_time=1010,
duration=10,
motion=0,
).execute()
Recordings.insert(
id="rec_b",
path="/media/recordings/b.mp4",
camera="front_door",
start_time=1030,
end_time=1040,
duration=10,
motion=0,
).execute()
response = client.get(
"/recordings/unavailable",
params={
"after": 1000,
"before": 1040,
"scale": 5,
"cameras": "front_door",
},
)
assert response.status_code == 200
assert response.json() == [{"start_time": 1010, "end_time": 1030}]
def test_recordings_unavailable_merges_overlapping_recordings(self):
"""Overlapping recordings are merged so no false gap is reported."""
with AuthTestClient(self.app) as client:
# Overlapping recordings spanning the whole requested range.
Recordings.insert(
id="rec_a",
path="/media/recordings/a.mp4",
camera="front_door",
start_time=1000,
end_time=1020,
duration=20,
motion=0,
).execute()
Recordings.insert(
id="rec_b",
path="/media/recordings/b.mp4",
camera="front_door",
start_time=1010,
end_time=1030,
duration=20,
motion=0,
).execute()
response = client.get(
"/recordings/unavailable",
params={
"after": 1000,
"before": 1030,
"scale": 5,
"cameras": "front_door",
},
)
assert response.status_code == 200
assert response.json() == []
+4 -7
View File
@@ -610,19 +610,16 @@ class TestHttpReview(BaseTestHttp):
response = client.get("/review/activity/motion", params=params)
assert response.status_code == 200
response_json = response.json()
assert len(response_json) == 61
# Only buckets with an actual recording are returned. Empty
# gap-fill buckets between the two recordings are dropped.
assert len(response_json) == 2
self.assertDictEqual(
{"motion": 50.5, "camera": "front_door", "start_time": now + 1},
response_json[0],
)
for item in response_json[1:-1]:
self.assertDictEqual(
{"motion": 0.0, "camera": "", "start_time": item["start_time"]},
item,
)
self.assertDictEqual(
{"motion": 100.0, "camera": "front_door", "start_time": one_m + 1},
response_json[len(response_json) - 1],
response_json[1],
)
####################################################################################################################
+58
View File
@@ -0,0 +1,58 @@
"""Tests for motion search batch helpers (runs + timestamp mapping)."""
import unittest
from dataclasses import dataclass
from frigate.jobs.motion_search_batch import (
build_segment_time_map,
coalesce_runs,
stream_time_to_absolute,
)
@dataclass
class _Seg:
path: str
start_time: float
end_time: float
def _run_seconds(run):
return float(run[-1].end_time) - float(run[0].start_time)
class TestCoalesceRuns(unittest.TestCase):
def test_contiguous_segments_form_one_run(self):
segs = [_Seg("a", 0.0, 10.0), _Seg("b", 10.0, 20.0), _Seg("c", 20.0, 30.0)]
runs = coalesce_runs(segs, max_seconds=600.0, epsilon=0.5)
self.assertEqual(len(runs), 1)
self.assertEqual(len(runs[0]), 3)
def test_time_gap_splits_runs(self):
# b ends 20, c starts 25 -> 5s gap > epsilon -> two runs.
segs = [_Seg("a", 0.0, 10.0), _Seg("b", 10.0, 20.0), _Seg("c", 25.0, 35.0)]
runs = coalesce_runs(segs, max_seconds=600.0, epsilon=0.5)
self.assertEqual([len(r) for r in runs], [2, 1])
def test_max_duration_caps_a_run(self):
# Five contiguous 10s segments, cap 25s.
segs = [_Seg(str(i), i * 10.0, i * 10.0 + 10.0) for i in range(5)]
runs = coalesce_runs(segs, max_seconds=25.0, epsilon=0.5)
self.assertTrue(all(_run_seconds(r) <= 30.0 for r in runs))
self.assertEqual(sum(len(r) for r in runs), 5)
def test_empty(self):
self.assertEqual(coalesce_runs([], max_seconds=600.0, epsilon=0.5), [])
class TestTimestampMapping(unittest.TestCase):
def test_gapfree_run_maps_to_start_plus_pts(self):
run = [_Seg("a", 1000.0, 1010.0), _Seg("b", 1010.0, 1020.0)]
time_map = build_segment_time_map(run)
self.assertAlmostEqual(stream_time_to_absolute(time_map, 3.0), 1003.0)
self.assertAlmostEqual(stream_time_to_absolute(time_map, 12.0), 1012.0)
def test_past_end_clamps(self):
run = [_Seg("a", 1000.0, 1010.0)]
time_map = build_segment_time_map(run)
self.assertAlmostEqual(stream_time_to_absolute(time_map, 9.9), 1009.9)
+190
View File
@@ -0,0 +1,190 @@
"""Tests for the motion search hardware-accelerated decode helpers."""
import unittest
from types import SimpleNamespace
from unittest import mock
from frigate.jobs.motion_search_decode import (
KEYFRAME_MAX_GAP_SECONDS,
build_vod_decode_command,
keyframe_sampling_eligible,
probe_video_dimensions,
probe_vod_keyframe_pts,
resolve_motion_decode_args,
)
def _fake_camera_config(
hwaccel_args, gpu=0, fps=5, width=1280, height=720, ffmpeg_path="ffmpeg"
):
return SimpleNamespace(
ffmpeg=SimpleNamespace(
hwaccel_args=hwaccel_args, gpu=gpu, ffmpeg_path=ffmpeg_path
),
detect=SimpleNamespace(fps=fps, width=width, height=height),
)
class TestResolveMotionDecodeArgs(unittest.TestCase):
def test_vaapi_preset_is_accelerated(self):
args = resolve_motion_decode_args(_fake_camera_config("preset-vaapi"))
self.assertIn("-hwaccel", args)
self.assertIn("vaapi", args)
def test_non_nv12_preset_falls_back_to_software(self):
# rkmpp produces drm_prime surfaces that do not download to nv12, so it
# must resolve to software decode (empty args) rather than risk corrupt
# frames.
self.assertEqual(
resolve_motion_decode_args(_fake_camera_config("preset-rkmpp")), []
)
def test_custom_args_fall_back_to_software(self):
# Arbitrary custom hwaccel args (a list, not a preset) decode in software
# to preserve byte-identical results.
self.assertEqual(
resolve_motion_decode_args(_fake_camera_config(["-hwaccel", "vulkan"])),
[],
)
def test_nvidia_codec_preset_is_accelerated(self):
# Codec-specific nvidia presets resolve to the same cuda decode args as
# the bare preset, so eligibility is derived from -hwaccel_output_format
# rather than a hardcoded list that omitted these aliases.
args = resolve_motion_decode_args(_fake_camera_config("preset-nvidia-h264"))
self.assertIn("-hwaccel_output_format", args)
self.assertIn("cuda", args)
def test_software_only_preset_falls_back_to_software(self):
# A preset with no -hwaccel_output_format (decoder-based, no GPU surface)
# cannot use the nv12 download step, so it decodes in software.
self.assertEqual(
resolve_motion_decode_args(_fake_camera_config("preset-rpi-64-h264")), []
)
class TestKeyframeEligibility(unittest.TestCase):
def test_regular_short_gop_is_eligible(self):
pts = [0.0, 0.5, 1.0, 1.5, 2.0] # 0.5s gaps
self.assertTrue(keyframe_sampling_eligible(pts))
def test_long_gop_is_ineligible(self):
pts = [0.0, 5.0, 10.0] # 5s gaps
self.assertFalse(keyframe_sampling_eligible(pts))
def test_irregular_gop_ineligible_when_a_gap_is_long(self):
pts = [0.0, 0.5, 1.0, 8.0] # one 7s gap
self.assertFalse(keyframe_sampling_eligible(pts))
def test_too_few_keyframes_ineligible(self):
self.assertFalse(keyframe_sampling_eligible([1.0]))
self.assertFalse(keyframe_sampling_eligible([]))
def test_default_max_gap_constant(self):
self.assertEqual(KEYFRAME_MAX_GAP_SECONDS, 2.0)
class TestVodDecodeCommand(unittest.TestCase):
URL = "http://127.0.0.1:5000/vod/cam/start/1/end/2/index.m3u8"
def test_keyframe_command_shape(self):
cmd = build_vod_decode_command(
"ffmpeg",
self.URL,
decode_args=[],
crop=(100, 80, 10, 20),
scale=(50, 40),
gray=True,
skip_nonkey=True,
fps_rate=None,
)
joined = " ".join(cmd)
self.assertIn("-skip_frame nokey", joined)
self.assertIn("-protocol_whitelist pipe,file,http,tcp", joined)
self.assertIn(f"-i {self.URL}", joined)
self.assertIn("crop=100:80:10:20", joined)
self.assertIn("scale=50:40", joined)
self.assertIn("-pix_fmt gray", joined)
self.assertNotIn("fps=", joined)
def test_fps_command_uses_fps_filter_not_skip_frame(self):
cmd = build_vod_decode_command(
"ffmpeg",
self.URL,
decode_args=[],
crop=None,
scale=None,
gray=False,
skip_nonkey=False,
fps_rate=2.0,
)
joined = " ".join(cmd)
self.assertNotIn("skip_frame", joined)
self.assertIn("fps=2.0", joined)
self.assertIn("-pix_fmt bgr24", joined)
def test_hwaccel_inserts_hwdownload(self):
cmd = build_vod_decode_command(
"ffmpeg",
self.URL,
decode_args=["-hwaccel", "vaapi"],
crop=None,
scale=None,
gray=True,
skip_nonkey=True,
fps_rate=None,
)
joined = " ".join(cmd)
self.assertIn("hwdownload", joined)
self.assertIn("format=nv12", joined)
class TestProbeVodKeyframePts(unittest.TestCase):
def test_parses_keyframe_packets(self):
sample = (
'{"packets":['
'{"pts_time":"0.000000","flags":"K__"},'
'{"pts_time":"1.000000","flags":"___"},'
'{"pts_time":"2.000000","flags":"K__"}]}'
)
completed = mock.Mock(stdout=sample, returncode=0)
with mock.patch(
"frigate.jobs.motion_search_decode.sp.run", return_value=completed
):
pts = probe_vod_keyframe_pts("ffprobe", "http://x/index.m3u8")
self.assertEqual(pts, [0.0, 2.0])
def test_returns_empty_on_failure(self):
with mock.patch(
"frigate.jobs.motion_search_decode.sp.run",
side_effect=OSError("boom"),
):
self.assertEqual(probe_vod_keyframe_pts("ffprobe", "http://x"), [])
class TestProbeVideoDimensions(unittest.TestCase):
def test_parses_dimensions_and_fps(self):
sample = (
'{"streams":[{"width":1920,"height":1080,"avg_frame_rate":"30000/1001"}]}'
)
completed = mock.Mock(stdout=sample, returncode=0)
with mock.patch(
"frigate.jobs.motion_search_decode.sp.run", return_value=completed
):
dims = probe_video_dimensions("ffprobe", "/tmp/a.mp4")
assert dims is not None
width, height, fps = dims
self.assertEqual((width, height), (1920, 1080))
self.assertAlmostEqual(fps, 29.97, places=2)
def test_returns_none_on_zero_dimensions(self):
sample = '{"streams":[{"width":0,"height":0,"avg_frame_rate":"0/0"}]}'
completed = mock.Mock(stdout=sample, returncode=0)
with mock.patch(
"frigate.jobs.motion_search_decode.sp.run", return_value=completed
):
self.assertIsNone(probe_video_dimensions("ffprobe", "/tmp/a.mp4"))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,87 @@
"""Tests for motion search spatial (crop/scale/mask) helpers."""
import unittest
import numpy as np
from frigate.jobs.motion_search import (
build_scaled_roi_mask,
compute_roi_crop_and_scale,
detect_motion_scaled,
)
class TestComputeRoiCropAndScale(unittest.TestCase):
def test_crop_box_in_record_pixels(self):
# ROI covering x [0.25, 0.75], y [0.5, 1.0] of a 1000x600 frame.
polygon = [[0.25, 0.5], [0.75, 0.5], [0.75, 1.0], [0.25, 1.0]]
crop, scaled = compute_roi_crop_and_scale(polygon, 1000, 600, scale_target=125)
cw, ch, cx, cy = crop
self.assertEqual((cx, cy), (250, 300))
self.assertEqual((cw, ch), (500, 300))
# longest side 500 -> factor 0.25 -> (125, 75), rounded down to even.
self.assertEqual(scaled, (124, 74))
def test_never_upscales(self):
polygon = [[0.0, 0.0], [0.1, 0.0], [0.1, 0.1], [0.0, 0.1]]
crop, scaled = compute_roi_crop_and_scale(polygon, 200, 200, scale_target=400)
cw, ch, _, _ = crop
# crop is 20x20; target 400 would upscale, so scaled == crop size.
self.assertEqual(scaled, (cw, ch))
def test_scaled_dims_are_at_least_one(self):
polygon = [[0.0, 0.0], [0.02, 0.0], [0.02, 0.02], [0.0, 0.02]]
crop, scaled = compute_roi_crop_and_scale(polygon, 50, 50, scale_target=1)
self.assertGreaterEqual(scaled[0], 1)
self.assertGreaterEqual(scaled[1], 1)
def test_all_dims_are_even_for_nv12(self):
# Odd-aligned ROI on an odd-ish frame must still yield even crop/scale so
# the nv12 hwdownload byte stream matches the expected frame size.
polygon = [[0.123, 0.321], [0.777, 0.321], [0.777, 0.901], [0.123, 0.901]]
crop, scaled = compute_roi_crop_and_scale(polygon, 1377, 911, scale_target=257)
for value in (*crop, *scaled):
self.assertEqual(value % 2, 0, f"{value} is not even")
class TestBuildScaledRoiMask(unittest.TestCase):
def test_mask_matches_scaled_dims_and_has_coverage(self):
polygon = [[0.25, 0.5], [0.75, 0.5], [0.75, 1.0], [0.25, 1.0]]
crop, scaled = compute_roi_crop_and_scale(polygon, 1000, 600, scale_target=125)
mask = build_scaled_roi_mask(polygon, 1000, 600, crop, scaled)
self.assertEqual(mask.shape, (scaled[1], scaled[0]))
self.assertEqual(mask.dtype, np.uint8)
# A full rectangle ROI fills its whole crop -> mask is all 255.
self.assertGreater(np.count_nonzero(mask), 0)
self.assertEqual(np.count_nonzero(mask), mask.size)
class TestDetectMotionScaled(unittest.TestCase):
def _ts(self, idx):
return float(idx)
def test_finds_change_between_frames(self):
mask = np.full((60, 80), 255, dtype=np.uint8)
f0 = np.zeros((60, 80), dtype=np.uint8)
f1 = np.zeros((60, 80), dtype=np.uint8)
f1[10:50, 20:60] = 255 # big bright block appears
frames = [(0, f0), (30, f1)]
results = detect_motion_scaled(
frames, mask, threshold=30, min_area=1.0, timestamp_fn=self._ts
)
self.assertEqual(len(results), 1)
self.assertEqual(results[0].timestamp, 30.0)
self.assertGreater(results[0].change_percentage, 0.0)
def test_no_change_yields_nothing(self):
mask = np.full((60, 80), 255, dtype=np.uint8)
f0 = np.zeros((60, 80), dtype=np.uint8)
f1 = np.zeros((60, 80), dtype=np.uint8)
results = detect_motion_scaled(
[(0, f0), (30, f1)], mask, threshold=30, min_area=1.0, timestamp_fn=self._ts
)
self.assertEqual(results, [])
if __name__ == "__main__":
unittest.main()
+124
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()
+31
View File
@@ -1,5 +1,6 @@
"""Tests for the profiles system."""
import copy
import json
import os
import unittest
@@ -746,6 +747,36 @@ class TestProfileManager(unittest.TestCase):
manager.activate_profile(None)
dispatcher.clear_runtime_state.assert_called_once_with()
@patch.object(ProfileManager, "_persist_active_profile")
def test_profile_change_republishes_switch_states(self, mock_persist):
"""Profile changes republish MQTT switch states so HA stays in sync.
Regression: activating/deactivating a profile updated the in-memory
config (and Frigate's behavior) but left the retained MQTT state
topics stale, so external integrations like Home Assistant kept
showing the pre-profile toggle position.
"""
config_data = copy.deepcopy(self.config_data)
config_data["cameras"]["front"]["profiles"]["disarmed"]["review"] = {
"alerts": {"enabled": False},
}
config = FrigateConfig(**config_data)
dispatcher = MagicMock()
manager = ProfileManager(config, self.mock_updater, dispatcher)
# Activating disarmed turns alerts off -> MQTT state must follow
manager.activate_profile("disarmed")
dispatcher.publish.assert_any_call(
"front/review_alerts/state", "OFF", retain=True
)
# Deactivating restores the base (alerts on) -> MQTT state must follow
dispatcher.publish.reset_mock()
manager.activate_profile(None)
dispatcher.publish.assert_any_call(
"front/review_alerts/state", "ON", retain=True
)
@patch.object(ProfileManager, "_persist_active_profile")
def test_startup_replay_does_not_clear_runtime_state(self, mock_persist):
"""Startup callers pass clear_runtime_overrides=False to preserve state."""
+10
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
+181
View File
@@ -0,0 +1,181 @@
/**
* Camera clone dialog E2E tests.
*
* Covers the design invariants that don't depend on per-camera resolution
* differences in the mock fixture:
* 1. Dialog opens from the "Clone settings" button below Add/Delete.
* 2. A source camera must be chosen inside the dialog before cloning.
* 3. "Stream URLs and roles" is forced on and disabled for new-camera target.
* 4. Cloning to a new camera issues a single add PUT and shows a restart prompt.
* 5. The existing-camera target selects multiple destinations via a switch
* popover (with an "All cameras" toggle and source exclusion); the closed
* trigger summarizes the selection by name or as "All cameras".
*
* The spatial-mismatch warning path is exercised in unit-level review and via
* manual QA the shared mock fixture ships every camera at 1280×720. The
* existing-camera PUT fan-out is likewise not asserted here: the mock cameras
* are identical apart from stream URLs (which existing-camera clones never
* copy) and the schema mock is empty, so a clone onto them produces no diff
* and no PUT. That path is covered by unit-level review and manual QA.
*/
import { test, expect } from "../fixtures/frigate-test";
async function openCloneDialog(frigateApp: {
page: import("@playwright/test").Page;
}) {
await frigateApp.page
.getByRole("button", { name: /^Clone settings$/i })
.click();
await expect(frigateApp.page.getByRole("dialog")).toBeVisible();
}
async function selectSource(
frigateApp: { page: import("@playwright/test").Page },
source: string,
) {
await frigateApp.page.getByRole("dialog").getByRole("combobox").click();
await frigateApp.page
.getByRole("option", { name: source, exact: true })
.click();
}
test.describe("Camera clone dialog @medium @mobile", () => {
test.beforeEach(async ({ frigateApp }) => {
await frigateApp.goto("/settings?page=cameraManagement");
await expect(
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
).toBeVisible();
});
test("opens the dialog from the Clone settings button", async ({
frigateApp,
}) => {
await openCloneDialog(frigateApp);
await expect(
frigateApp.page.getByRole("dialog").getByText(/Clone camera settings/i),
).toBeVisible();
// The Clone button is disabled until a source (and target) is chosen.
await expect(
frigateApp.page.getByRole("button", { name: /^Clone$/i }),
).toBeDisabled();
});
test("forces Stream URLs and roles on for new-camera target", async ({
frigateApp,
}) => {
await openCloneDialog(frigateApp);
await selectSource(frigateApp, "Front Door");
// The "New camera" radio is selected by default; the Streams group renders
// the ffmpeg_live checkbox as forced-checked and disabled.
const streamsLabel = frigateApp.page
.locator("label")
.filter({ hasText: /Stream URLs and roles/i });
await expect(streamsLabel).toBeVisible();
const streamsCheckbox = streamsLabel.getByRole("checkbox");
await expect(streamsCheckbox).toBeChecked();
await expect(streamsCheckbox).toBeDisabled();
});
test("issues a single add PUT and shows restart toast for new-camera target", async ({
frigateApp,
}) => {
const requests: { body: unknown }[] = [];
await frigateApp.page.route("**/api/config/set", async (route) => {
const body = route.request().postDataJSON();
requests.push({ body });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: true, require_restart: false }),
});
});
await frigateApp.goto("/settings?page=cameraManagement");
await expect(
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
).toBeVisible();
await openCloneDialog(frigateApp);
await selectSource(frigateApp, "Front Door");
const nameInput = frigateApp.page.getByPlaceholder(
/e\.g\., back_door or Back Door/i,
);
await nameInput.fill("clone_target_one");
// With a source picked and a valid name, changeCount > 0 enables Clone.
await expect(
frigateApp.page.getByRole("button", { name: /^Clone$/i }),
).toBeEnabled({ timeout: 5_000 });
await frigateApp.page.getByRole("button", { name: /^Clone$/i }).click();
// New-camera clones bundle into a single atomic add PUT (avoids
// per-section validation ordering issues).
await expect.poll(() => requests.length, { timeout: 10_000 }).toBe(1);
const firstBody = requests[0].body as {
requires_restart?: number;
update_topic?: string;
};
expect(firstBody.update_topic).toMatch(
/config\/cameras\/clone_target_one\/add/,
);
expect(firstBody.requires_restart).toBe(1);
// The toast offers a Restart action because new-camera always needs restart.
// .first() avoids strict-mode rejection when both the toast action and the
// RestartDialog trigger render concurrently.
await expect(
frigateApp.page.getByRole("button", { name: /Restart/i }).first(),
).toBeVisible({ timeout: 8_000 });
});
test("selects multiple existing destination cameras via a switch popover", async ({
frigateApp,
}) => {
await openCloneDialog(frigateApp);
await selectSource(frigateApp, "Front Door");
await frigateApp.page
.getByRole("radio", { name: /Existing cameras/i })
.click();
const dialog = frigateApp.page.getByRole("dialog");
// The destination trigger starts with the empty-selection placeholder.
await dialog
.getByRole("button", { name: /Select at least one camera/i })
.click();
// The chosen source is excluded from the destination switch list.
await expect(
dialog.getByRole("switch", { name: /Backyard/i }),
).toBeVisible();
await expect(dialog.getByRole("switch", { name: /Garage/i })).toBeVisible();
await expect(
dialog.getByRole("switch", { name: /^Front Door$/i }),
).toHaveCount(0);
// Selecting a single camera summarizes by name once the popover closes.
await dialog.getByRole("switch", { name: /Backyard/i }).click();
await frigateApp.page.keyboard.press("Escape");
await expect(
dialog.getByRole("button", { name: /^Backyard$/i }),
).toBeVisible();
// Reopen and select everything; the trigger collapses to "All cameras".
await dialog.getByRole("button", { name: /^Backyard$/i }).click();
await dialog.getByRole("switch", { name: /^All cameras$/i }).click();
await frigateApp.page.keyboard.press("Escape");
await expect(
dialog.getByRole("button", { name: /^All cameras$/i }),
).toBeVisible();
});
});
+5 -1
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)."
@@ -862,6 +862,10 @@
"dashboard": {
"label": "Show in UI",
"description": "Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again."
},
"review": {
"label": "Show in review",
"description": "Toggle whether this camera is visible in review (the review page and its camera filter, motion review, and the history view)."
}
},
"webui_url": {
+5 -9
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."
@@ -1554,6 +1546,10 @@
"dashboard": {
"label": "Show in UI",
"description": "Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again."
},
"review": {
"label": "Show in review",
"description": "Toggle whether this camera is visible in review (the review page and its camera filter, motion review, and the history view)."
}
},
"onvif": {
+1 -1
View File
@@ -67,7 +67,7 @@
"needsReview": "Needs review",
"securityConcern": "Security concern",
"motionSearch": {
"menuItem": "Motion search",
"menuItem": "Motion Search",
"openMenu": "Camera options"
},
"motionPreviews": {
@@ -8,6 +8,7 @@
"searchCancelled": "Search cancelled",
"cancelSearch": "Cancel",
"searching": "Search in progress.",
"scanning": "Scanning {{time}}",
"searchComplete": "Search complete",
"noResultsYet": "Run a search to find motion changes in the selected region",
"noChangesFound": "No pixel changes detected in the selected region",
@@ -24,7 +25,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": {
@@ -40,13 +43,11 @@
"settings": {
"title": "Search Settings",
"parallelMode": "Parallel mode",
"parallelModeDesc": "Scan multiple recording segments at the same time (faster, but significantly more CPU intensive)",
"parallelModeDesc": "Scan multiple recording ranges at the same time (faster; uses more decoding resources)",
"threshold": "Sensitivity Threshold",
"thresholdDesc": "Lower values detect smaller changes (1-255)",
"minArea": "Minimum Change Area",
"minAreaDesc": "Minimum percentage of the region of interest that must change to be considered significant",
"frameSkip": "Frame Skip",
"frameSkipDesc": "Process every Nth frame. Set this to your camera's frame rate to process one frame per second (e.g. 5 for a 5 FPS camera, 30 for a 30 FPS camera). Higher values will be faster, but may miss short motion events.",
"minAreaDesc": "Minimum size of a single moving region, as a percentage of the region of interest",
"maxResults": "Maximum Results",
"maxResultsDesc": "Stop after this many matching timestamps"
},
@@ -70,6 +71,8 @@
"framesDecoded": "Frames decoded",
"wallTime": "Search time",
"segmentErrors": "Segment errors",
"seconds": "{{seconds}}s"
"seconds": "{{seconds}}s",
"minutesSeconds": "{{minutes}}m {{seconds}}s",
"scanSummary": "{{segments}} segments · {{time}}"
}
}
+110 -11
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": {
@@ -492,12 +492,16 @@
"details": {
"edit": "Edit camera details",
"title": "Edit Camera Details",
"description": "Update the display name and external URL used for this camera throughout the Frigate UI.",
"description": "Update the display name, external URL, and visibility used for this camera throughout the Frigate UI.",
"friendlyNameLabel": "Display Name",
"friendlyNameHelp": "Friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.",
"webuiUrlLabel": "Camera Web UI URL",
"webuiUrlHelp": "URL to visit the camera's web UI directly from the Debug view. Leave blank to disable the link.",
"webuiUrlInvalid": "Must be a valid URL (e.g., https://example.com)."
"webuiUrlInvalid": "Must be a valid URL (e.g., https://example.com).",
"dashboardLabel": "Show on Live dashboard",
"dashboardHelp": "Show this camera on the Live dashboard.",
"reviewLabel": "Show in Review",
"reviewHelp": "Show this camera in Review, including the camera filter, motion review, and the history view."
}
},
"cameraConfig": {
@@ -544,6 +548,92 @@
"normal": "Normal",
"dedicatedLpr": "Dedicated LPR",
"saveSuccess": "Updated camera type for {{cameraName}}. Restart Frigate to apply the changes."
},
"clone": {
"sectionTitle": "Clone settings",
"sectionDescription": "Copy configuration from one camera to another camera or a new one.",
"button": "Clone settings",
"title": "Clone camera settings",
"description": "Copy a camera's configuration to one or more other cameras or a new camera. Identity (name, friendly name, web UI URL, display order) is never copied.",
"source": {
"label": "Source camera",
"placeholder": "Select a source camera",
"required": "Select a source camera"
},
"target": {
"legend": "Target",
"newRadio": "New camera",
"newNameLabel": "Camera name",
"newNamePlaceholder": "e.g., back_door or Back Door",
"newNameRequired": "Camera name is required",
"newNameInvalid": "Invalid camera name",
"newNameCollision": "A camera with this name already exists",
"newStreamsForced": "Streams are always copied for a new camera.",
"existingCamerasRadio": "Existing cameras",
"allCameras": "All cameras",
"existingPlaceholder": "Select at least one camera",
"existingDisabled": "No other cameras to copy to"
},
"categories": {
"legend": "Settings to clone",
"description": "Choose which settings to copy from the source camera.",
"selectAll": "Select all",
"selectNone": "Select none",
"resetDefaults": "Reset to defaults",
"general": "General",
"spatial": "Spatial settings",
"streams": "Streams",
"spatialWarningTitle": "Resolution mismatch",
"spatialWarning": "Source camera {{srcCamera}} detect resolution ({{srcWidth}}×{{srcHeight}}) differs from: {{cameras}}. Polygons may not align on those cameras. These defaults are off; enable to copy as-is.",
"restartHint": "Restart required",
"items": {
"record": "Recording",
"snapshots": "Snapshots",
"review": "Review",
"motion": "Motion detection",
"objects": "Objects",
"audio": "Audio detection",
"audio_transcription": "Audio transcription",
"notifications": "Notifications",
"birdseye": "Birdseye",
"mqtt": "MQTT",
"timestamp_style": "Timestamp style",
"onvif": "ONVIF",
"lpr": "License plate recognition",
"face_recognition": "Face recognition",
"semantic_search": "Semantic search",
"genai": "Generative AI",
"type": "Camera type (normal / dedicated LPR)",
"profiles": "Profiles",
"detect": "Detect dimensions",
"zones": "Zones",
"motion_mask": "Motion masks",
"object_masks": "Object masks",
"ffmpeg_live": "Stream URLs and roles"
}
},
"footer": {
"changeCount_zero": "No changes selected",
"changeCount_one": "{{count}} change will be applied",
"changeCount_other": "{{count}} changes will be applied",
"restartNeeded": "Restart will be required for some changes.",
"liveOnly": "All changes will apply live without a restart.",
"submit": "Clone",
"submitting": "Cloning…"
},
"toast": {
"success": "Settings copied to {{cameraName}}",
"successWithRestart": "Settings copied to {{cameraName}}. Restart Frigate to apply all changes.",
"successMulti_one": "Settings copied to {{count}} camera",
"successMulti_other": "Settings copied to {{count}} cameras",
"successMultiWithRestart_one": "Settings copied to {{count}} camera. Restart Frigate to apply all changes.",
"successMultiWithRestart_other": "Settings copied to {{count}} cameras. Restart Frigate to apply all changes.",
"partialFailure": "{{successCount}} sections applied; '{{failedSection}}' failed: {{errorMessage}}",
"partialFailureMulti": "Copied to {{successCount}} camera(s); failed for {{failed}}: {{errorMessage}}",
"newCameraPartialFailure": "Camera {{cameraName}} was created but some settings failed to copy: {{errorMessage}}",
"sourceMissing": "Source camera no longer exists",
"submitError": "Failed to clone camera: {{errorMessage}}"
}
}
},
"cameraReview": {
@@ -1068,7 +1158,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",
@@ -1588,6 +1679,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": {
@@ -1677,7 +1779,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",
@@ -1754,12 +1856,6 @@
"12hour": "12 hour",
"24hour": "24 hour"
},
"TimeOrDateStyle": {
"full": "Full",
"long": "Long",
"medium": "Medium",
"short": "Short"
},
"unitSystem": {
"metric": "Metric",
"imperial": "Imperial"
@@ -1842,6 +1938,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."
}
}
}
+2 -2
View File
@@ -107,7 +107,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
<FormLabel>{t("form.user")}</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
autoFocus
autoCapitalize="off"
autoCorrect="off"
@@ -125,7 +125,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
<FormLabel>{t("form.password")}</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
type="password"
{...field}
/>
@@ -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>
+1 -2
View File
@@ -257,7 +257,7 @@ export function ExportCard({
{editName && (
<>
<Input
className="text-md mt-3"
className="mt-3"
type="search"
placeholder={editName?.original}
value={
@@ -275,7 +275,6 @@ export function ExportCard({
<DialogFooter>
<Button
aria-label={t("editExport.saveExport")}
size="sm"
variant="select"
disabled={(editName?.update?.length ?? 0) == 0}
onClick={() => submitRename()}
@@ -14,7 +14,7 @@ type SettingsGroupCardProps = {
export function SettingsGroupCard({ title, children }: SettingsGroupCardProps) {
return (
<div className="space-y-4 rounded-lg border border-border/70 bg-card/30 p-4">
<div className="text-md border-b border-border/60 pb-4 font-semibold text-primary-variant">
<div className="border-b border-border/60 pb-4 font-semibold text-primary-variant">
{title}
</div>
{children}
+2 -2
View File
@@ -48,7 +48,7 @@ export default function ChatSettings({
<div className="my-3 space-y-5 py-3 md:mt-0 md:py-0">
<div className="space-y-3">
<div className="space-y-0.5">
<div className="text-md">{t("settings.show_stats.title")}</div>
<div>{t("settings.show_stats.title")}</div>
<div className="text-xs text-muted-foreground">
{t("settings.show_stats.desc")}
</div>
@@ -77,7 +77,7 @@ export default function ChatSettings({
<DropdownMenuSeparator />
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<Label htmlFor="auto-scroll" className="text-md cursor-pointer">
<Label htmlFor="auto-scroll" className="cursor-pointer">
{t("settings.auto_scroll.title")}
</Label>
<div className="text-xs text-muted-foreground">
@@ -485,7 +485,7 @@ export default function ClassificationModelEditDialog({
<FormControl>
<div className="flex items-center gap-2">
<Input
className="text-md h-8"
className="h-8"
placeholder={t(
"wizard.step1.classPlaceholder",
)}
@@ -214,7 +214,7 @@ export default function Step1NameAndDefine({
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
className="h-8"
placeholder={t("wizard.step1.namePlaceholder")}
{...field}
/>
@@ -457,7 +457,7 @@ export default function Step1NameAndDefine({
<FormControl>
<div className="flex items-center gap-2">
<Input
className="text-md h-8"
className="h-8"
placeholder={t("wizard.step1.classPlaceholder")}
{...field}
/>
@@ -489,7 +489,7 @@ export default function Step1NameAndDefine({
</form>
</Form>
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
<Button type="button" onClick={onCancel} className="sm:flex-1">
{t("button.cancel", { ns: "common" })}
</Button>
@@ -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">
@@ -458,7 +460,7 @@ export default function Step2StateArea({
</div>
</div>
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
@@ -1,4 +1,4 @@
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { useState, useEffect, useCallback, useMemo } from "react";
import ActivityIndicator from "@/components/indicators/activity-indicator";
@@ -540,7 +540,7 @@ export default function Step3ChooseExamples({
</AlertDialogCancel>
<AlertDialogAction
onClick={doRefresh}
className="bg-destructive text-white hover:bg-destructive/90"
className={cn(buttonVariants({ variant: "destructive" }))}
>
{t("button.continue", { ns: "common" })}
</AlertDialogAction>
@@ -693,7 +693,7 @@ export default function Step3ChooseExamples({
)}
{!isTraining && (
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
<Button type="button" onClick={handleBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
@@ -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",
},
},
@@ -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" },
},
@@ -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" },
},
@@ -10,7 +10,6 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Toaster } from "@/components/ui/sonner";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { FrigateConfig } from "@/types/frigateConfig";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -49,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";
@@ -438,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">
@@ -466,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"
@@ -491,7 +507,6 @@ export default function NotificationsSettingsExtras({
return (
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto px-2 md:order-none">
<div className={cn("w-full max-w-5xl space-y-6")}>
{isAdmin && (
@@ -521,7 +536,7 @@ export default function NotificationsSettingsExtras({
<FormControl>
<Input
id="notification-email"
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72"
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72"
placeholder={t(
"notification.email.placeholder",
)}
@@ -788,7 +803,7 @@ export function CameraNotificationSwitch({
)}
<div className="flex flex-col">
<CameraNameLabel
className="text-md cursor-pointer text-primary smart-capitalize"
className="cursor-pointer text-primary smart-capitalize"
htmlFor="camera"
camera={camera}
/>
@@ -32,7 +32,7 @@ import { ProfileOverridesBadge } from "./ProfileOverridesBadge";
import { useSectionSchema } from "@/hooks/use-config-schema";
import type { FrigateConfig } from "@/types/frigateConfig";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import Heading from "@/components/ui/heading";
import get from "lodash/get";
@@ -1236,7 +1236,7 @@ export function ConfigSection({
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={() => {
onDeleteProfileSection?.();
setIsDeleteProfileDialogOpen(false);
@@ -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>,
@@ -371,7 +371,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
key={group.groupKey}
className="space-y-4 rounded-lg border border-border/70 bg-card/30 p-4"
>
<div className="text-md border-b border-border/60 pb-4 font-semibold text-primary-variant">
<div className="border-b border-border/60 pb-4 font-semibold text-primary-variant">
{group.label}
</div>
<div className="space-y-6">
@@ -79,7 +79,7 @@ export function ArrayAsTextWidget(props: WidgetProps) {
return (
<Textarea
id={id}
className={cn("text-md", fieldClassName)}
className={cn(fieldClassName)}
value={text}
disabled={disabled || readonly}
rows={(options.rows as number) || 3}
@@ -124,7 +124,7 @@ export function CameraPathWidget(props: WidgetProps) {
<div className={cn("relative", fieldClassName)}>
<Input
id={id}
className={cn("text-md", canToggle ? "pr-10" : undefined)}
className={cn(canToggle ? "pr-10" : undefined)}
type="text"
value={displayValue}
disabled={disabled || readonly}
@@ -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;
@@ -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>
);
}
@@ -26,7 +26,7 @@ export function TextWidget(props: WidgetProps) {
return (
<Input
id={id}
className={cn("text-md", fieldClassName)}
className={cn(fieldClassName)}
type="text"
value={value ?? ""}
disabled={disabled || readonly}
@@ -26,7 +26,7 @@ export function TextareaWidget(props: WidgetProps) {
return (
<Textarea
id={id}
className={cn("text-md", fieldClassName)}
className={cn(fieldClassName)}
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || (options.placeholder as string) || ""}
@@ -15,6 +15,7 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
@@ -847,7 +848,7 @@ export function CameraGroupEdit({
<FormLabel>{t("group.name.label")}</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder={t("group.name.placeholder")}
{...field}
/>
@@ -973,10 +974,9 @@ export function CameraGroupEdit({
<Separator className="my-2 flex bg-secondary" />
<div className="flex flex-row gap-2 py-5 md:pb-0">
<DialogFooter className="py-5 md:pb-0">
<Button
type="button"
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
>
@@ -985,7 +985,6 @@ export function CameraGroupEdit({
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
aria-label={t("button.save", { ns: "common" })}
type="submit"
>
@@ -998,7 +997,7 @@ export function CameraGroupEdit({
t("button.save", { ns: "common" })
)}
</Button>
</div>
</DialogFooter>
</form>
</Form>
);
@@ -40,7 +40,7 @@ export function LogSettingsButton({
<div className={cn("my-3 space-y-3 py-3 md:mt-0 md:py-0")}>
<div className="space-y-4">
<div className="space-y-0.5">
<div className="text-md">{t("filter")}</div>
<div>{t("filter")}</div>
<div className="space-y-1 text-xs text-muted-foreground">
{t("logSettings.filterBySeverity")}
</div>
@@ -53,7 +53,7 @@ export function LogSettingsButton({
<DropdownMenuSeparator />
<div className="space-y-4">
<div className="space-y-0.5">
<div className="text-md">{t("logSettings.loading.title")}</div>
<div>{t("logSettings.loading.title")}</div>
<div className="mt-2.5 flex flex-col gap-2.5">
<div className="space-y-1 text-xs text-muted-foreground">
{t("logSettings.loading.desc")}
@@ -144,11 +144,13 @@ export default function ReviewFilterGroup({
const filterValues = useMemo(
() => ({
cameras: allowedCameras.sort(
(a, b) =>
(config?.cameras[a]?.ui?.order ?? 0) -
(config?.cameras[b]?.ui?.order ?? 0),
),
cameras: allowedCameras
.filter((cam) => config?.cameras[cam]?.ui?.review !== false)
.sort(
(a, b) =>
(config?.cameras[a]?.ui?.order ?? 0) -
(config?.cameras[b]?.ui?.order ?? 0),
),
labels: Object.values(allLabels || {}),
zones: Object.values(allZones || {}),
}),
+32 -18
View File
@@ -56,18 +56,25 @@ export function CameraLineGraph({
});
}, [t, timeFormat]);
const updateTimesRef = useRef(updateTimes);
useEffect(() => {
updateTimesRef.current = updateTimes;
}, [updateTimes]);
const formatTime = useCallback(
(val: unknown) => {
return formatUnixTimestampToDateTime(
updateTimes[Math.round(val as number)],
{
timezone: config?.ui.timezone,
date_format: format,
locale,
},
);
const times = updateTimesRef.current;
const ts = times[Math.round(val as number)];
if (isNaN(ts)) {
return "";
}
return formatUnixTimestampToDateTime(ts, {
timezone: config?.ui.timezone,
date_format: format,
locale,
});
},
[config?.ui.timezone, format, locale, updateTimes],
[config?.ui.timezone, format, locale],
);
const options = useMemo(() => {
@@ -211,18 +218,25 @@ export function EventsPerSecondsLineGraph({
});
}, [t, timeFormat]);
const updateTimesRef = useRef(updateTimes);
useEffect(() => {
updateTimesRef.current = updateTimes;
}, [updateTimes]);
const formatTime = useCallback(
(val: unknown) => {
return formatUnixTimestampToDateTime(
updateTimes[Math.round(val as number) - 1],
{
timezone: config?.ui.timezone,
date_format: format,
locale,
},
);
const times = updateTimesRef.current;
const ts = times[Math.round(val as number) - 1];
if (isNaN(ts)) {
return "";
}
return formatUnixTimestampToDateTime(ts, {
timezone: config?.ui.timezone,
date_format: format,
locale,
});
},
[config?.ui.timezone, format, locale, updateTimes],
[config?.ui.timezone, format, locale],
);
const options = useMemo(() => {
+16 -9
View File
@@ -61,6 +61,11 @@ export function ThresholdBarGraph({
});
}, [t, timeFormat]);
const updateTimesRef = useRef(updateTimes);
useEffect(() => {
updateTimesRef.current = updateTimes;
}, [updateTimes]);
const formatTime = useCallback(
(val: unknown) => {
const dateIndex = Math.round(val as number);
@@ -69,16 +74,18 @@ export function ThresholdBarGraph({
if (dateIndex < 0) {
timeOffset = 5 * Math.abs(dateIndex);
}
return formatUnixTimestampToDateTime(
updateTimes[Math.max(1, dateIndex) - 1] - timeOffset,
{
timezone: config?.ui.timezone,
date_format: format,
locale,
},
);
const times = updateTimesRef.current;
const ts = times[Math.max(1, dateIndex) - 1] - timeOffset;
if (isNaN(ts)) {
return "";
}
return formatUnixTimestampToDateTime(ts, {
timezone: config?.ui.timezone,
date_format: format,
locale,
});
},
[config?.ui.timezone, format, locale, updateTimes],
[config?.ui.timezone, format, locale],
);
const options = useMemo(() => {
+1 -1
View File
@@ -119,7 +119,7 @@ export default function IconPicker({
placeholder={t("iconPicker.search.placeholder", {
ns: "components/icons",
})}
className="text-md mb-3 md:text-sm"
className="mb-3 md:text-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
+1 -1
View File
@@ -696,7 +696,7 @@ export default function InputWithTags({
onFocus={handleInputFocus}
onBlur={handleInputBlur}
onKeyDown={handleInputKeyDown}
className="text-md h-9 pr-32"
className="h-9 pr-32"
placeholder={t("placeholder.search")}
/>
<div className="absolute right-3 top-0 flex h-full flex-row items-center justify-center gap-5">
+1 -6
View File
@@ -112,11 +112,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
</span>
</div>
<FormControl>
<Input
className="text-md"
placeholder={placeholderName}
{...field}
/>
<Input placeholder={placeholderName} {...field} />
</FormControl>
{nameDescription && (
<FormDescription>{nameDescription}</FormDescription>
@@ -134,7 +130,6 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
<FormLabel>{idLabel ?? t("label.ID")}</FormLabel>
<FormControl>
<Input
className="text-md"
placeholder={placeholderId}
disabled={idDisabled}
{...field}
@@ -69,7 +69,6 @@ export function SaveSearchDialog({
</DialogHeader>
<Input
value={searchName}
className="text-md"
onChange={(e) => setSearchName(e.target.value)}
placeholder={t("search.saveSearch.placeholder")}
/>
@@ -88,7 +87,6 @@ export function SaveSearchDialog({
<Button
onClick={handleSave}
variant="select"
className="mb-2 md:mb-0"
aria-label={t("search.saveSearch.button.save.label")}
>
{t("button.save", { ns: "common" })}
+1 -1
View File
@@ -77,7 +77,7 @@ export default function TextEntry({
<FormControl>
<Input
{...field}
className="text-md w-full"
className="w-full"
placeholder={placeholder}
type="text"
/>
+1 -1
View File
@@ -276,7 +276,7 @@ export default function LiveContextMenu({
<ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent>
<div className="flex flex-col items-start gap-1 py-1 pl-2">
<div className="text-md text-primary-variant smart-capitalize">
<div className="text-primary-variant smart-capitalize">
<CameraNameLabel camera={camera} />
</div>
{preferredLiveMode == "jsmpeg" && isRestreamed && (
+13 -1
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" })}
+24 -30
View File
@@ -213,36 +213,30 @@ export default function CreateRoleDialog({
<FormMessage />
</div>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter className="pt-2">
<Button
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</DialogFooter>
</form>
</Form>
@@ -433,36 +433,30 @@ export default function CreateTriggerDialog({
)}
/>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter className="pt-2">
<Button
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</DialogFooter>
</form>
</Form>
+24 -30
View File
@@ -411,36 +411,30 @@ export default function CreateUserDialog({
)}
/>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter className="pt-2">
<Button
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</DialogFooter>
</form>
</Form>
@@ -144,7 +144,7 @@ export function CustomTimeSelector({
/>
<SelectSeparator className="bg-secondary" />
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={startClock}
@@ -210,7 +210,7 @@ export function CustomTimeSelector({
/>
<SelectSeparator className="bg-secondary" />
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="endTime"
type="time"
value={endClock}
@@ -113,19 +113,14 @@ export function DebugReplayContent({
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
<DialogFooter
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-2"}
>
<DialogFooter className="mt-3 sm:mt-0">
<Button
className={isDesktop ? "" : "w-full"}
aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
onClick={onCancel}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
className={isDesktop ? "" : "w-full"}
variant="select"
disabled={isStarting}
onClick={() => {
+25 -32
View File
@@ -70,38 +70,31 @@ export default function DeleteRoleDialog({
</div>
</div>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
disabled={isLoading}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
className="flex flex-1"
aria-label={t("button.delete", { ns: "common" })}
variant="destructive"
disabled={isLoading}
onClick={handleDelete}
type="button"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("roles.dialog.deleteRole.deleting")}</span>
</div>
) : (
t("button.delete", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
aria-label={t("button.delete", { ns: "common" })}
variant="destructive"
disabled={isLoading}
onClick={handleDelete}
type="button"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("roles.dialog.deleteRole.deleting")}</span>
</div>
) : (
t("button.delete", { ns: "common" })
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -43,36 +43,30 @@ export default function DeleteTriggerDialog({
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-3 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
disabled={isLoading}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="destructive"
aria-label={t("button.delete", { ns: "common" })}
className="flex flex-1 text-white"
onClick={onDelete}
disabled={isLoading}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.delete", { ns: "common" })}</span>
</div>
) : (
t("button.delete", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
disabled={isLoading}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="destructive"
aria-label={t("button.delete", { ns: "common" })}
onClick={onDelete}
disabled={isLoading}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.delete", { ns: "common" })}</span>
</div>
) : (
t("button.delete", { ns: "common" })
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+15 -21
View File
@@ -46,27 +46,21 @@ export default function DeleteUserDialog({
</p>
</div>
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="destructive"
aria-label={t("button.delete", { ns: "common" })}
className="flex flex-1 text-white"
onClick={onDelete}
>
{t("button.delete", { ns: "common" })}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="destructive"
aria-label={t("button.delete", { ns: "common" })}
onClick={onDelete}
>
{t("button.delete", { ns: "common" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -105,13 +105,15 @@ export default function EditRoleCamerasDialog({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-5 pt-4"
className="space-y-5 pt-2"
>
<div className="space-y-2">
<FormLabel>{t("roles.dialog.form.cameras.title")}</FormLabel>
<FormDescription className="text-xs text-muted-foreground">
{t("roles.dialog.form.cameras.desc")}
</FormDescription>
<div className="space-y-3">
<div>
<FormLabel>{t("roles.dialog.form.cameras.title")}</FormLabel>
<FormDescription className="text-xs text-muted-foreground">
{t("roles.dialog.form.cameras.desc")}
</FormDescription>
</div>
<div className="scrollbar-container max-h-[40dvh] space-y-2 overflow-y-auto">
{cameras.map((camera) => (
<FormField
@@ -159,36 +161,30 @@ export default function EditRoleCamerasDialog({
<FormMessage />
</div>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</DialogFooter>
</form>
</Form>
+2 -15
View File
@@ -287,7 +287,7 @@ export default function ExportDialog({
<Content
className={
isDesktop
? "sm:rounded-lg md:rounded-2xl"
? "scrollbar-container max-h-[90dvh] overflow-y-auto sm:rounded-lg md:rounded-2xl"
: "mx-4 rounded-lg px-4 pb-4 md:rounded-2xl"
}
>
@@ -794,7 +794,6 @@ export function ExportContent({
)}
<Input
className="text-md"
type="search"
placeholder={t("export.name.placeholder")}
value={name}
@@ -835,13 +834,11 @@ export function ExportContent({
{selectedCaseId === "new" && (
<div className="space-y-2 pt-1">
<Input
className="text-md"
placeholder={t("export.case.newCaseNamePlaceholder")}
value={singleNewCaseName}
onChange={(e) => setSingleNewCaseName(e.target.value)}
/>
<Textarea
className="text-md"
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
value={singleNewCaseDescription}
onChange={(e) =>
@@ -988,7 +985,6 @@ export function ExportContent({
{t("export.multiCamera.nameLabel")}
</Label>
<Input
className="text-md"
type="search"
placeholder={t("export.multiCamera.namePlaceholder")}
value={name}
@@ -1028,13 +1024,11 @@ export function ExportContent({
{batchCaseSelection === "new" && (
<div className="space-y-2 pt-1">
<Input
className="text-md"
placeholder={t("export.case.newCaseNamePlaceholder")}
value={newCaseName}
onChange={(event) => setNewCaseName(event.target.value)}
/>
<Textarea
className="text-md"
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
value={newCaseDescription}
onChange={(event) =>
@@ -1049,20 +1043,15 @@ export function ExportContent({
</Tabs>
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
<DialogFooter
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-2"}
>
<DialogFooter className="mt-3 sm:mt-0">
<Button
className={isDesktop ? "" : "w-full"}
aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
onClick={onCancel}
>
{t("button.cancel", { ns: "common" })}
</Button>
{activeTab === "export" ? (
<Button
className={isDesktop ? "" : "w-full"}
aria-label={t("export.selectOrExport")}
variant="select"
disabled={isStartingExport}
@@ -1086,12 +1075,10 @@ export function ExportContent({
</Button>
) : (
<Button
className={isDesktop ? "" : "w-full"}
aria-label={t("export.multiCamera.exportButton", {
count: selectedCameraCount,
})}
variant="select"
size="sm"
disabled={!canStartBatchExport}
onClick={() => void startBatchExport()}
>
+1 -1
View File
@@ -85,7 +85,7 @@ export default function ImagePicker({
<Input
type="text"
placeholder={t("imagePicker.search.placeholder")}
className="text-md mb-3 md:text-sm"
className="mb-3 md:text-sm"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
@@ -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"
@@ -290,7 +290,6 @@ export default function MultiExportDialog({
const newCaseInputs = (
<div className="space-y-2 pt-1">
<Input
className="text-md"
placeholder={t("export.case.newCaseNamePlaceholder")}
value={newCaseName}
onChange={(event) => setNewCaseName(event.target.value)}
@@ -298,7 +297,6 @@ export default function MultiExportDialog({
autoFocus={isDesktop}
/>
<Textarea
className="text-md"
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
value={newCaseDescription}
onChange={(event) => setNewCaseDescription(event.target.value)}
@@ -344,11 +342,7 @@ export default function MultiExportDialog({
const footer = (
<>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isExporting}
>
<Button onClick={() => handleOpenChange(false)} disabled={isExporting}>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
@@ -380,7 +374,7 @@ export default function MultiExportDialog({
</DialogDescription>
</DialogHeader>
{body}
<DialogFooter className="gap-2">{footer}</DialogFooter>
<DialogFooter>{footer}</DialogFooter>
</DialogContent>
</Dialog>
);
@@ -399,7 +393,7 @@ export default function MultiExportDialog({
</DrawerDescription>
</DrawerHeader>
{body}
<div className="mt-4 flex flex-col-reverse gap-2">{footer}</div>
<DialogFooter className="mt-4">{footer}</DialogFooter>
</DrawerContent>
</Drawer>
);
@@ -13,6 +13,13 @@ import { useTimezone } from "@/hooks/use-date-utils";
type WeekStartsOnType = 0 | 1 | 2 | 3 | 4 | 5 | 6;
function formatCalendarDay(day: Date): string {
const y = day.getFullYear();
const m = String(day.getMonth() + 1).padStart(2, "0");
const d = String(day.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
type ReviewActivityCalendarProps = {
reviewSummary?: ReviewSummary;
recordingsSummary?: RecordingsSummary;
@@ -62,17 +69,10 @@ export default function ReviewActivityCalendar({
}
}
const formatDay = (day: Date) => {
const y = day.getFullYear();
const m = String(day.getMonth() + 1).padStart(2, "0");
const d = String(day.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
};
return {
recordings: (day: Date) => recordingsSet.has(formatDay(day)),
alerts: (day: Date) => alertsSet.has(formatDay(day)),
detections: (day: Date) => detectionsSet.has(formatDay(day)),
recordings: (day: Date) => recordingsSet.has(formatCalendarDay(day)),
alerts: (day: Date) => alertsSet.has(formatCalendarDay(day)),
detections: (day: Date) => detectionsSet.has(formatCalendarDay(day)),
};
}, [reviewSummary, recordingsSummary]);
@@ -156,14 +156,32 @@ type TimezoneAwareCalendarProps = {
timezone?: string;
selectedDay?: Date;
onSelect: (day?: Date) => void;
recordingsSummary?: RecordingsSummary;
};
export function TimezoneAwareCalendar({
timezone,
selectedDay,
onSelect,
recordingsSummary,
}: TimezoneAwareCalendarProps) {
const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
// When a recordings summary is supplied, underline days that have footage
const recordingsModifier = useMemo(() => {
if (!recordingsSummary) {
return undefined;
}
const recordingsSet = new Set<string>();
for (const date of Object.keys(recordingsSummary)) {
if (date !== LAST_24_HOURS_KEY) {
recordingsSet.add(date);
}
}
return {
recordings: (day: Date) => recordingsSet.has(formatCalendarDay(day)),
};
}, [recordingsSummary]);
const timezoneOffset = useMemo(
() =>
timezone ? Math.round(getUTCOffset(new Date(), timezone)) : undefined,
@@ -217,6 +235,10 @@ export function TimezoneAwareCalendar({
onSelect={onSelect}
defaultMonth={selectedDay ?? new Date()}
weekStartsOn={(weekStartsOn ?? 0) as WeekStartsOnType}
modifiers={recordingsModifier}
components={
recordingsModifier ? { DayButton: ReviewActivityDay } : undefined
}
/>
);
}
+17 -24
View File
@@ -117,30 +117,23 @@ export default function RoleChangeDialog({
</Select>
</div>
<DialogFooter className="flex gap-3 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
className="flex flex-1"
onClick={() => onSave(selectedRole)}
type="button"
disabled={selectedRole === currentRole}
>
{t("button.save", { ns: "common" })}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
onClick={() => onSave(selectedRole)}
type="button"
disabled={selectedRole === currentRole}
>
{t("button.save", { ns: "common" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -450,36 +450,30 @@ export default function SetPasswordDialog({
</div>
)}
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
disabled={isLoading}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
className="flex flex-1"
type="submit"
disabled={isLoading || !form.formState.isValid}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
disabled={isLoading}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
type="submit"
disabled={isLoading || !form.formState.isValid}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</DialogFooter>
</form>
</Form>
@@ -196,21 +196,16 @@ export function ShareTimestampContent({
{isDesktop && <Separator className="my-4 bg-secondary" />}
<DialogFooter
className={cn("mt-4", !isDesktop && "flex flex-col-reverse gap-2")}
>
<DialogFooter className="mt-3 sm:mt-0">
{onCancel && (
<Button
className={cn(!isDesktop && "w-full")}
aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
onClick={onCancel}
>
{t("button.cancel", { ns: "common" })}
</Button>
)}
<Button
className={cn(!isDesktop && "w-full")}
variant="select"
onClick={() => onShareTimestamp(Math.floor(selectedTimestamp))}
>
@@ -338,7 +333,7 @@ function CustomTimestampSelector({
/>
<div className="my-3 h-px w-full bg-secondary" />
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="shareTimestamp"
type="time"
value={clock}
@@ -145,7 +145,7 @@ export function AnnotationSettingsPane({
return (
<div className="p-4">
<div className="text-md mb-2">
<div className="mb-2">
{t("trackingDetails.annotationSettings.title")}
</div>
@@ -131,7 +131,7 @@ export default function CreateFaceWizardDialog({
forbiddenPattern={/#/}
forbiddenErrorMessage={t("description.nameCannotContainHash")}
>
<div className="flex justify-end py-2">
<div className="flex flex-col-reverse gap-2 py-2 sm:flex-row sm:justify-end">
<Button variant="select" type="submit">
{t("button.next", { ns: "common" })}
</Button>
@@ -144,7 +144,7 @@ export default function CreateFaceWizardDialog({
{t("steps.description.uploadFace", { name })}
</div>
<ImageEntry onSave={onUploadImage}>
<div className="flex justify-end py-2">
<div className="flex flex-col-reverse gap-2 py-2 sm:flex-row sm:justify-end">
<Button variant="select" type="submit">
{t("button.next", { ns: "common" })}
</Button>
@@ -173,7 +173,7 @@ export default function CreateFaceWizardDialog({
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
<div className="flex justify-end">
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
variant="select"
onClick={() => {
@@ -22,6 +22,7 @@ type SaveAllPreviewPopoverProps = {
className?: string;
align?: "start" | "center" | "end";
side?: "top" | "bottom" | "left" | "right";
disablePortal?: boolean;
};
export default function SaveAllPreviewPopover({
@@ -29,6 +30,7 @@ export default function SaveAllPreviewPopover({
className,
align = "end",
side = "bottom",
disablePortal = false,
}: SaveAllPreviewPopoverProps) {
const { t } = useTranslation(["views/settings", "common"]);
const [open, setOpen] = useState(false);
@@ -67,6 +69,7 @@ export default function SaveAllPreviewPopover({
<PopoverContent
align={align}
side={side}
disablePortal={disablePortal}
className="w-[90vw] max-w-sm border bg-background p-4 shadow-lg"
onOpenAutoFocus={(event) => event.preventDefault()}
>
@@ -108,13 +111,13 @@ export default function SaveAllPreviewPopover({
}`}
className="rounded-md border border-secondary bg-background_alt p-2"
>
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs">
<span className="text-muted-foreground">
{t("saveAllPreview.scope.label", {
ns: "views/settings",
})}
</span>
<span className="truncate">{scopeLabel}</span>
<span className="min-w-0 truncate">{scopeLabel}</span>
{item.profileName && (
<>
<span className="text-muted-foreground">
@@ -122,7 +125,7 @@ export default function SaveAllPreviewPopover({
ns: "views/settings",
})}
</span>
<span className="truncate font-medium">
<span className="min-w-0 truncate font-medium">
{item.profileName}
</span>
</>
@@ -132,7 +135,7 @@ export default function SaveAllPreviewPopover({
ns: "views/settings",
})}
</span>
<span className="break-all font-mono">
<span className="min-w-0 break-all font-mono">
{item.fieldPath}
</span>
<span className="text-muted-foreground">
@@ -140,7 +143,7 @@ export default function SaveAllPreviewPopover({
ns: "views/settings",
})}
</span>
<span className="whitespace-pre-wrap break-words font-mono">
<span className="min-w-0 whitespace-pre-wrap break-all font-mono">
{formatValue(item.value)}
</span>
</div>
@@ -1569,7 +1569,7 @@ function ObjectDetailsTab({
{t("button.yes", { ns: "common" })}
</Button>
<Button
className="flex-1 text-white"
className="flex-1"
aria-label={t("button.no", { ns: "common" })}
variant="destructive"
onClick={() => {
@@ -1706,7 +1706,7 @@ function ObjectDetailsTab({
) : (
<div className="flex flex-col gap-2">
<Textarea
className="text-md h-32 md:text-sm"
className="h-32 md:text-sm"
placeholder={t("details.description.placeholder")}
value={desc}
onChange={(e) => setDesc(e.target.value)}
@@ -821,7 +821,7 @@ export function TrackingDetails({
</div>
<div className="flex items-center gap-2">
<span className="capitalize">{label}</span>
<div className="md:text-md flex items-center text-xs text-secondary-foreground">
<div className="flex items-center text-xs text-secondary-foreground">
{formattedStart ?? ""}
{event.end_time != null ? (
<> - {formattedEnd}</>
@@ -1072,7 +1072,7 @@ function LifecycleIconRow({
<div className="ml-2 flex w-full min-w-0 flex-1">
<div className="flex flex-col">
<div className="text-md flex items-start break-words text-left">
<div className="flex items-start break-words text-left">
{getLifecycleItemDescription(item)}
</div>
{/* Only show Score/Ratio/Area for object events, not for audio (heard) or manual API (external) events */}
@@ -121,28 +121,22 @@ export default function DeleteCameraDialog({
))}
</SelectContent>
</Select>
<DialogFooter className="flex gap-3 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
onClick={handleClose}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="destructive"
aria-label={t("button.delete", { ns: "common" })}
className="flex flex-1 text-white"
onClick={handleDelete}
disabled={!selectedCamera}
>
{t("button.delete", { ns: "common" })}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={handleClose}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="destructive"
aria-label={t("button.delete", { ns: "common" })}
onClick={handleDelete}
disabled={!selectedCamera}
>
{t("button.delete", { ns: "common" })}
</Button>
</DialogFooter>
</>
) : (
@@ -173,39 +167,31 @@ export default function DeleteCameraDialog({
{t("cameraManagement.deleteCameraDialog.deleteExports")}
</Label>
</div>
<DialogFooter className="flex gap-3 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.back", { ns: "common" })}
onClick={handleBack}
type="button"
disabled={isDeleting}
>
{t("button.back", { ns: "common" })}
</Button>
<Button
variant="destructive"
className="flex flex-1 text-white"
onClick={handleConfirmDelete}
disabled={isDeleting}
>
{isDeleting ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>
{t(
"cameraManagement.deleteCameraDialog.confirmButton",
)}
</span>
</div>
) : (
t("cameraManagement.deleteCameraDialog.confirmButton")
)}
</Button>
</div>
</div>
<DialogFooter>
<Button
aria-label={t("button.back", { ns: "common" })}
onClick={handleBack}
type="button"
disabled={isDeleting}
>
{t("button.back", { ns: "common" })}
</Button>
<Button
variant="destructive"
onClick={handleConfirmDelete}
disabled={isDeleting}
>
{isDeleting ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>
{t("cameraManagement.deleteCameraDialog.confirmButton")}
</span>
</div>
) : (
t("cameraManagement.deleteCameraDialog.confirmButton")
)}
</Button>
</DialogFooter>
</>
)}
@@ -173,7 +173,7 @@ export function FrigatePlusDialog({
{t("button.yes", { ns: "common" })}
</Button>
<Button
className="flex-1 text-white"
className="flex-1"
aria-label={t("button.no", { ns: "common" })}
variant="destructive"
onClick={() => {
@@ -7,9 +7,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
import FilterSwitch from "@/components/filter/FilterSwitch";
@@ -77,7 +75,7 @@ export default function MultiSelectDialog({
/>
))}
</div>
<DialogFooter className={cn("pt-4", isMobile && "gap-2")}>
<DialogFooter className="pt-4">
<Button type="button" onClick={() => setOpen(false)}>
{t("button.cancel")}
</Button>
@@ -144,18 +144,13 @@ export default function OptionAndInputDialog({
<label className="text-sm font-medium text-secondary-foreground">
{nameLabel}
</label>
<Input
className="text-md"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-secondary-foreground">
{descriptionLabel}
</label>
<Textarea
className="text-md"
value={descriptionValue}
onChange={(e) => setDescriptionValue(e.target.value)}
rows={2}
@@ -164,10 +159,9 @@ export default function OptionAndInputDialog({
</div>
)}
<DialogFooter className={cn("pt-2", isMobile && "gap-2")}>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={isLoading}
onClick={() => {
setOpen(false);
@@ -349,7 +349,7 @@ function TimeRangeFilterContent({
</PopoverTrigger>
<PopoverContent className="flex flex-row items-center justify-center">
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={selectedAfterHour}
@@ -389,7 +389,7 @@ function TimeRangeFilterContent({
</PopoverTrigger>
<PopoverContent className="flex flex-col items-center">
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={
@@ -9,8 +9,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
type TextEntryDialogProps = {
@@ -63,7 +61,7 @@ export default function TextEntryDialog({
forbiddenPattern={forbiddenPattern}
forbiddenErrorMessage={forbiddenErrorMessage}
>
<DialogFooter className={cn("pt-4", isMobile && "gap-2")}>
<DialogFooter>
<Button
type="button"
disabled={isSaving}
+1 -1
View File
@@ -443,7 +443,7 @@ export default function LivePlayer({
<div className="absolute inset-0 rounded-lg bg-black/50 md:rounded-2xl" />
<div className="absolute inset-0 left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 rounded-lg bg-background/50 p-3 text-center">
<div className="text-md">{t("streamOffline.title")}</div>
<div>{t("streamOffline.title")}</div>
<TbExclamationCircle className="size-6" />
{!isCompact && (
<p className="text-center text-sm">
File diff suppressed because it is too large Load Diff
@@ -22,7 +22,6 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
import axios from "axios";
import { toast } from "sonner";
import { Toaster } from "../ui/sonner";
import ActivityIndicator from "../indicators/activity-indicator";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
@@ -327,7 +326,6 @@ export default function MotionMaskEditPane({
return (
<>
<Toaster position="top-center" closeButton={true} />
<Heading as="h3" className="my-2">
{polygon.name.length
? t("masksAndZones.motionMasks.edit")

Some files were not shown because too many files have changed in this diff Show More