mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-25 22:01:51 +03:00
Compare commits
21 Commits
fa45bc17c1
...
cd65fefda8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd65fefda8 | ||
|
|
7e83d5de90 | ||
|
|
a08e2d7529 | ||
|
|
3f0ebb3577 | ||
|
|
c25a522fcc | ||
|
|
db9e64c598 | ||
|
|
570e21340a | ||
|
|
8073174c20 | ||
|
|
47a06c8b30 | ||
|
|
ae60197cb0 | ||
|
|
407817a3b1 | ||
|
|
08be019bed | ||
|
|
2dd05ca984 | ||
|
|
6fdd65ddb5 | ||
|
|
4b6fa49449 | ||
|
|
bc65713ae4 | ||
|
|
50f17e6852 | ||
|
|
e9ef4f978a | ||
|
|
2858662be9 | ||
|
|
88f944fe81 | ||
|
|
8ea5eb6bd1 |
@ -265,8 +265,8 @@ ENV PATH="/usr/local/go2rtc/bin:/usr/local/tempio/bin:/usr/local/nginx/sbin:${PA
|
||||
RUN --mount=type=bind,source=docker/main/install_deps.sh,target=/deps/install_deps.sh \
|
||||
/deps/install_deps.sh
|
||||
|
||||
ENV DEFAULT_FFMPEG_VERSION="7.0"
|
||||
ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:5.0"
|
||||
ENV DEFAULT_FFMPEG_VERSION="8.0"
|
||||
ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:7.0:5.0"
|
||||
|
||||
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
|
||||
&& sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \
|
||||
|
||||
@ -52,9 +52,13 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
|
||||
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
|
||||
rm -rf ffmpeg.tar.xz
|
||||
mkdir -p /usr/lib/ffmpeg/7.0
|
||||
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-03-19-13-03/ffmpeg-n7.1.3-43-g5a1f107b4c-linux64-gpl-7.1.tar.xz"
|
||||
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linux64-gpl-7.0.tar.xz"
|
||||
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
|
||||
rm -rf ffmpeg.tar.xz
|
||||
mkdir -p /usr/lib/ffmpeg/8.0
|
||||
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linux64-gpl-8.1.tar.xz"
|
||||
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/8.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
|
||||
rm -rf ffmpeg.tar.xz
|
||||
fi
|
||||
|
||||
# ffmpeg -> arm64
|
||||
@ -64,9 +68,13 @@ if [[ "${TARGETARCH}" == "arm64" ]]; then
|
||||
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
|
||||
rm -f ffmpeg.tar.xz
|
||||
mkdir -p /usr/lib/ffmpeg/7.0
|
||||
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-03-19-13-03/ffmpeg-n7.1.3-43-g5a1f107b4c-linuxarm64-gpl-7.1.tar.xz"
|
||||
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linuxarm64-gpl-7.0.tar.xz"
|
||||
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
|
||||
rm -f ffmpeg.tar.xz
|
||||
mkdir -p /usr/lib/ffmpeg/8.0
|
||||
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linuxarm64-gpl-8.1.tar.xz"
|
||||
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/8.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
|
||||
rm -f ffmpeg.tar.xz
|
||||
fi
|
||||
|
||||
# arch specific packages
|
||||
|
||||
@ -5,11 +5,7 @@ from typing import Any
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
sys.path.insert(0, "/opt/frigate")
|
||||
from frigate.const import (
|
||||
DEFAULT_FFMPEG_VERSION,
|
||||
INCLUDED_FFMPEG_VERSIONS,
|
||||
)
|
||||
from frigate.util.config import find_config_file
|
||||
from frigate.util.config import find_config_file, resolve_ffmpeg_path
|
||||
|
||||
sys.path.remove("/opt/frigate")
|
||||
|
||||
@ -29,9 +25,4 @@ except FileNotFoundError:
|
||||
config: dict[str, Any] = {}
|
||||
|
||||
path = config.get("ffmpeg", {}).get("path", "default")
|
||||
if path == "default":
|
||||
print(f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg")
|
||||
elif path in INCLUDED_FFMPEG_VERSIONS:
|
||||
print(f"/usr/lib/ffmpeg/{path}/bin/ffmpeg")
|
||||
else:
|
||||
print(f"{path}/bin/ffmpeg")
|
||||
print(resolve_ffmpeg_path(path, "ffmpeg"))
|
||||
|
||||
@ -11,12 +11,10 @@ sys.path.insert(0, "/opt/frigate")
|
||||
from frigate.config.env import substitute_frigate_vars
|
||||
from frigate.const import (
|
||||
BIRDSEYE_PIPE,
|
||||
DEFAULT_FFMPEG_VERSION,
|
||||
INCLUDED_FFMPEG_VERSIONS,
|
||||
LIBAVFORMAT_VERSION_MAJOR,
|
||||
)
|
||||
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
|
||||
from frigate.util.config import find_config_file
|
||||
from frigate.util.config import find_config_file, resolve_ffmpeg_path
|
||||
from frigate.util.services import is_restricted_go2rtc_source
|
||||
|
||||
sys.path.remove("/opt/frigate")
|
||||
@ -81,12 +79,7 @@ if go2rtc_config.get("rtsp", {}).get("password") is not None:
|
||||
|
||||
# ensure ffmpeg path is set correctly
|
||||
path = config.get("ffmpeg", {}).get("path", "default")
|
||||
if path == "default":
|
||||
ffmpeg_path = f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg"
|
||||
elif path in INCLUDED_FFMPEG_VERSIONS:
|
||||
ffmpeg_path = f"/usr/lib/ffmpeg/{path}/bin/ffmpeg"
|
||||
else:
|
||||
ffmpeg_path = f"{path}/bin/ffmpeg"
|
||||
ffmpeg_path = resolve_ffmpeg_path(path, "ffmpeg")
|
||||
|
||||
if go2rtc_config.get("ffmpeg") is None:
|
||||
go2rtc_config["ffmpeg"] = {"bin": ffmpeg_path}
|
||||
|
||||
@ -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. |
|
||||
|
||||
@ -149,9 +149,16 @@ For more detail, see [Frigate Tip: Best Practices for Training Face and Custom C
|
||||
- **The wizard is just the starting point**: You don't need to find and label every class upfront. Missing classes will naturally appear in Recent Classifications, and those images tend to be more valuable because they represent new conditions and edge cases.
|
||||
- **Problem framing**: Keep classes visually distinct and relevant to the chosen object types.
|
||||
- **Preprocessing**: Ensure examples reflect object crops similar to Frigate's boxes; keep the subject centered.
|
||||
- **Labels**: Keep label names short and consistent; include a `none` class if you plan to ignore uncertain predictions for sub labels.
|
||||
- **Crop size**: Aim for crops of at least 100×100 pixels (a 10,000 pixel area). Crops smaller than ~80×80 get stretched 3-7× by the model's 224×224 input resize and tend to collapse into a generic "blob" region of feature space where identity becomes unreliable. If most of your detections are small because the camera is far from the subject, consider repositioning the camera for closer crops.
|
||||
- **Class balance**: Aim to keep your largest class within ~3× the count of your smallest. Beyond that, the model becomes biased toward the dominant class and tends to default borderline predictions to it (the "everything looks like Buddy" failure mode).
|
||||
- **Threshold**: Tune `threshold` per model to reduce false assignments. Start at `0.8` and adjust based on validation.
|
||||
|
||||
:::tip `none` works differently from named classes
|
||||
|
||||
Named classes work best with visually uniform examples — every Buddy photo should look like Buddy. The `none` class needs the opposite: visual diversity across sizes, framings, and qualities, because at inference it has to absorb everything that isn't one of your named classes. Don't apply the same "only keep large, well-framed images" rule to `none` that you would to a named class. Mix in small crops, partial views, and false positives deliberately - otherwise the model has no signal for "small/ambiguous thing = not one of my known classes" and will force those crops into a named class by default.
|
||||
|
||||
:::
|
||||
|
||||
## Debugging Classification Models
|
||||
|
||||
To troubleshoot issues with object classification models, enable debug logging to see detailed information about classification attempts, scores, and consensus calculations.
|
||||
|
||||
@ -88,8 +88,18 @@ Configure a "friendly name" for your stream followed by the go2rtc stream name.
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Live playback" />, then select your camera.
|
||||
- Under **Live stream names**, add entries mapping a friendly name to each go2rtc stream name (e.g., `Main Stream` mapped to `test_cam`, `Sub Stream` mapped to `test_cam_sub`).
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Live playback" /> and select your camera.
|
||||
2. Under **Live stream names**, click **Add stream** to add a new entry.
|
||||
3. In the **Stream name** field, enter a friendly name that will appear in the Live UI's stream dropdown (e.g., `Main Stream`).
|
||||
4. In the **go2rtc stream** field, open the dropdown and select the go2rtc stream this name should map to (e.g., `test_cam`). The dropdown lists every stream configured under `go2rtc.streams`. If the go2rtc stream hasn't been created yet, you can type the name and choose **Use "..."** to save a custom value.
|
||||
5. Repeat for each additional stream you want to expose (e.g., `Sub Stream` → `test_cam_sub`).
|
||||
6. Use the trash icon on a row to remove a stream, then **Save** the section.
|
||||
|
||||
:::tip
|
||||
|
||||
Configure your go2rtc streams first under <NavPath path="Settings > System > go2rtc streams" /> so the dropdown is populated with valid options.
|
||||
|
||||
:::
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -262,7 +272,7 @@ cameras:
|
||||
Each camera has three possible states, surfaced as a status selector in **Settings → Global configuration → Camera management**:
|
||||
|
||||
- **On** — streams are processed normally. Object detection, recording, and Live view are active.
|
||||
- **Off** — Frigate's ffmpeg processes are paused. Recording stops, object detection is paused, and the Live dashboard displays a blank image with a "Camera is off" message. The camera is still visible in the Live dashboard and its past review items, tracked objects, and historical footage remain accessible via the UI. This state does **not** persist across Frigate restarts; the camera returns to On after a restart.
|
||||
- **Off** — Frigate's ffmpeg processes are paused. Recording stops, object detection is paused, and the Live dashboard displays a blank image with a "Camera is off" message. The camera is still visible in the Live dashboard and its past review items, tracked objects, and historical footage remain accessible via the UI. The Off state persists across Frigate restarts via a `.runtime_state.json` file alongside `config.yml` (see [Runtime toggle persistence](#runtime-toggle-persistence)).
|
||||
- **Disabled** — the change is saved to your configuration file (`enabled: False`). The camera stops immediately, Frigate stops ffmpeg processes, and all live and historical UI elements for the camera are no longer visible but remains retained on disk. The camera is still listed in **Settings → Global configuration → Camera management** so it can be re-enabled. **A restart of Frigate is required to bring a disabled camera back to On.**
|
||||
|
||||
#### Turning a camera on or off
|
||||
@ -290,6 +300,15 @@ For both Off and Disabled cameras, go2rtc remains active but does not use system
|
||||
|
||||
If you want a camera's historical data (review items, tracked objects, footage) to stay accessible in the UI while you stop processing, set the camera to **Off**. If you want the camera fully removed from the Live dashboard, review filters, and other UI surfaces, set it to **Disabled**. The Disabled state still keeps the camera in Camera management so it can be re-enabled later; if you want to remove all traces of a camera including its configuration, delete it via Camera management instead.
|
||||
|
||||
#### Runtime toggle persistence
|
||||
|
||||
The Live view toggles for **camera on/off**, **detect**, **recordings**, **snapshots**, and **audio detection** — along with the equivalent MQTT `/set` topics — write the new state to `.runtime_state.json` next to your `config.yml`. The file is replayed on Frigate startup so your last-known toggle states survive a restart. Two interactions worth knowing:
|
||||
|
||||
- **Settings UI saves win.** When you save a field through **Settings → Global configuration**, the matching entry is cleared from `.runtime_state.json` so the new value in your config file is the durable source.
|
||||
- **Switching profiles clears all runtime overrides.** Activating or deactivating a [profile](/configuration/profiles) is treated as a deliberate state change, so the file is wiped to avoid stale overrides replaying on top of the new profile.
|
||||
|
||||
If you hand-edit `config.yml` while runtime overrides exist, the overrides will still replay on restart. Delete `.runtime_state.json` to reset to the YAML-defined defaults.
|
||||
|
||||
### Live player error messages
|
||||
|
||||
When your browser runs into problems playing back your camera streams, it will log short error messages to the browser console. They indicate playback, codec, or network issues on the client/browser side, not something server side with Frigate itself. Below are the common messages you may see and simple actions you can take to try to resolve them.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -130,6 +130,8 @@ Profiles can be activated and deactivated via the Frigate UI, [MQTT](/integratio
|
||||
|
||||
In the Frigate UI, open the Settings cog and select **Profiles** from the submenu to see all defined profiles. From there you can activate any profile or deactivate the current one. The active profile is indicated in the UI so you always know which profile is in effect.
|
||||
|
||||
Activating or deactivating a profile clears any [runtime toggle overrides](/configuration/live#runtime-toggle-persistence) so the profile's settings aren't silently undone by a stale toggle from before the switch.
|
||||
|
||||
## Example: Home / Away Setup
|
||||
|
||||
A common use case is having different detection and notification settings based on whether you are home or away. This example below is for a system with two cameras, `front_door` and `indoor_cam`.
|
||||
|
||||
@ -257,7 +257,7 @@ birdseye:
|
||||
# More information about presets at https://docs.frigate.video/configuration/ffmpeg_presets
|
||||
ffmpeg:
|
||||
# Optional: ffmpeg binary path (default: shown below)
|
||||
# can also be set to `7.0` or `5.0` to specify one of the included versions
|
||||
# can also be set to `8.0` or `5.0` to specify one of the included versions
|
||||
# or can be set to any path that holds `bin/ffmpeg` & `bin/ffprobe`
|
||||
path: "default"
|
||||
# Optional: global ffmpeg args (default: shown below)
|
||||
@ -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
|
||||
|
||||
@ -139,7 +139,7 @@ The Review page also can show periods of motion that didn't produce a tracked ob
|
||||
|
||||
The Motion Previews pane shows preview clips for periods of significant motion that did not produce a tracked object. It is useful for spotting things that motion detection picked up but object detection did not, which can help validate tuning or catch missed objects.
|
||||
|
||||
On the <NavPath path="Review > Motion" /> page, click the 3-dots menu on a camera and choose **Motion Previews**. Each card represents a continuous range of motion-only activity and plays back the recorded preview for that range. A heatmap overlay dims areas of the frame with no motion so the moving regions stand out.
|
||||
On the <NavPath path="Review > Motion" /> page, click the kebab menu on a camera and choose **Motion Previews**. Each card represents a continuous range of motion-only activity and plays back the recorded preview for that range. A heatmap overlay dims areas of the frame with no motion so the moving regions stand out.
|
||||
|
||||
The pane provides a few controls:
|
||||
|
||||
@ -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 3-dots 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 40–60%). 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 5–10%) 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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -368,7 +368,7 @@ The published value is the detected state class name (e.g., `open`, `closed`, `o
|
||||
|
||||
### `frigate/<camera_name>/enabled/set`
|
||||
|
||||
Topic to turn Frigate's processing of a camera on or off at runtime. Expected values are `ON` and `OFF`. The change is **not** persisted across Frigate restarts — the camera returns to the configured state on restart. To permanently disable a camera, use **Settings → Global configuration → Camera management** in the Frigate UI. See [Camera state](/configuration/live#camera-state) for the difference between turning a camera off and disabling it.
|
||||
Topic to turn Frigate's processing of a camera on or off at runtime. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)). To permanently change the configured value, use **Settings → Global configuration → Camera management** in the Frigate UI. See [Camera state](/configuration/live#camera-state) for the difference between turning a camera off and disabling it.
|
||||
|
||||
### `frigate/<camera_name>/enabled/state`
|
||||
|
||||
@ -376,7 +376,7 @@ Topic with current runtime state of processing for a camera. Published values ar
|
||||
|
||||
### `frigate/<camera_name>/detect/set`
|
||||
|
||||
Topic to turn object detection for a camera on and off. Expected values are `ON` and `OFF`.
|
||||
Topic to turn object detection for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
|
||||
|
||||
### `frigate/<camera_name>/detect/state`
|
||||
|
||||
@ -384,7 +384,7 @@ Topic with current state of object detection for a camera. Published values are
|
||||
|
||||
### `frigate/<camera_name>/audio/set`
|
||||
|
||||
Topic to turn audio detection for a camera on and off. Expected values are `ON` and `OFF`.
|
||||
Topic to turn audio detection for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
|
||||
|
||||
### `frigate/<camera_name>/audio/state`
|
||||
|
||||
@ -392,7 +392,7 @@ Topic with current state of audio detection for a camera. Published values are `
|
||||
|
||||
### `frigate/<camera_name>/recordings/set`
|
||||
|
||||
Topic to turn recordings for a camera on and off. Expected values are `ON` and `OFF`.
|
||||
Topic to turn recordings for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
|
||||
|
||||
### `frigate/<camera_name>/recordings/state`
|
||||
|
||||
@ -400,7 +400,7 @@ Topic with current state of recordings for a camera. Published values are `ON` a
|
||||
|
||||
### `frigate/<camera_name>/snapshots/set`
|
||||
|
||||
Topic to turn snapshots for a camera on and off. Expected values are `ON` and `OFF`.
|
||||
Topic to turn snapshots for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
|
||||
|
||||
### `frigate/<camera_name>/snapshots/state`
|
||||
|
||||
|
||||
@ -3,6 +3,8 @@ id: dummy-camera
|
||||
title: Analyzing Object Detection
|
||||
---
|
||||
|
||||
import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
Frigate provides several tools for investigating object detection and tracking behavior: reviewing recorded detections through the UI, using the built-in Debug Replay feature, and manually setting up a dummy camera for advanced scenarios.
|
||||
|
||||
## Reviewing Detections in the UI
|
||||
@ -51,12 +53,25 @@ Only one replay session can be active at a time. If a session is already running
|
||||
|
||||
:::
|
||||
|
||||
### Starting Debug Replay
|
||||
|
||||
Debug Replay can be started from several places in the UI. The starting point determines the time range that gets replayed.
|
||||
|
||||
- **History — Actions menu.** Navigate to <NavPath path="History > {camera}" />, open the **Actions** menu in the toolbar, and choose **Debug Replay**. From here you can pick a preset (**Last 1 Minute**, **Last 5 Minutes**), select a range directly on the timeline with **From Timeline**, or enter exact start and end times with **Custom**. This is the most flexible option and the best choice when you want to add padding around a detection. On mobile, the same options appear in the Actions drawer.
|
||||
- **History — Detail Stream event menu.** While viewing a review item in the Detail Stream, open the menu on a tracked object's event card and choose **Debug Replay**. The replay range is set automatically to that object's start and end times.
|
||||
- **Explore — search result menu.** From an Explore card, open the kebab menu and choose **Debug Replay**. The range is taken from the tracked object's lifecycle.
|
||||
- **Explore — Tracking Details Actions menu.** Open a tracked object's **Tracking Details** dialog, then choose **Debug Replay** from the Actions menu. Same automatic range as the search result menu.
|
||||
- **Exports — export card menu.** From <NavPath path="Exports" />, open the menu on an export and choose **Debug Replay** to loop the exported clip through the detection pipeline for the camera it was exported from.
|
||||
|
||||
The Detail Stream, Explore, and Exports entry points use the underlying recording or export's bounds with a small amount of padding. This can be convenient for quick checks, but if a detection is short or you want extra "settle" time for motion and the detector, start the replay from the History Actions menu instead and widen the range manually.
|
||||
|
||||
### Variables to consider
|
||||
|
||||
- The replay will not always produce identical results to the original run. Different frames may be selected on replay, which can change detections and tracking.
|
||||
- Motion detection depends on the exact frames used; small frame shifts can change motion regions and therefore what gets passed to the detector.
|
||||
- Object detection is not fully deterministic: models and post-processing can yield slightly different results across runs.
|
||||
- In cases where a detection is short and a replay may only be a small number of frames, it is recommended to manually add some padding before and after the detection so that the motion and object detectors have time to settle into the scene. Rather than starting Debug Replay from Explore, navigate to History for your camera, choose Debug Replay from the Actions menu, and click the "From Timeline" or "Custom" option.
|
||||
- The replay camera inherits the source camera's zones. Any automations that trigger on those zone names will fire for the replay camera as well. This can be helpful when debugging zone behavior, but may be unexpected. You can add a condition on the source camera's name in your automation if you want to exclude replay triggers.
|
||||
|
||||
Treat the replay as a close approximation rather than an exact reproduction. Run multiple loops and examine the debug overlays and logs to understand the behavior.
|
||||
|
||||
|
||||
17
docs/static/frigate-api.yaml
vendored
17
docs/static/frigate-api.yaml
vendored
@ -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
|
||||
|
||||
@ -908,6 +908,11 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# drop runtime overrides for any fields the user just rewrote in
|
||||
# yaml so a stale override doesn't silently win after restart
|
||||
if request.app.dispatcher is not None:
|
||||
request.app.dispatcher.clear_runtime_state_for_yaml_keys(updates.keys())
|
||||
|
||||
if body.requires_restart == 0 or body.update_topic:
|
||||
old_config: FrigateConfig = request.app.frigate_config
|
||||
request.app.frigate_config = config
|
||||
|
||||
@ -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 []:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
@ -36,7 +37,7 @@ from frigate.comms.event_metadata_updater import (
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||
from frigate.config.profile_manager import ProfileManager
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
from frigate.debug_replay import DebugReplayManager, debug_replay_auto_stop_watchdog
|
||||
from frigate.embeddings import EmbeddingsContext
|
||||
from frigate.genai import GenAIClientManager
|
||||
from frigate.ptz.onvif import OnvifController
|
||||
@ -116,6 +117,11 @@ def create_fastapi_app(
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
logger.info("FastAPI started")
|
||||
asyncio.create_task(
|
||||
debug_replay_auto_stop_watchdog(
|
||||
replay_manager, frigate_config, config_publisher
|
||||
)
|
||||
)
|
||||
|
||||
# Rate limiter (used for login endpoint)
|
||||
if frigate_config.auth.failed_login_rate_limit is None:
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -343,12 +343,24 @@ 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)
|
||||
self.profile_manager.activate_profile(persisted)
|
||||
# runtime overrides are layered on top via restore_runtime_state()
|
||||
self.profile_manager.activate_profile(
|
||||
persisted, clear_runtime_overrides=False
|
||||
)
|
||||
|
||||
def start_detectors(self) -> None:
|
||||
for name in self.config.cameras.keys():
|
||||
@ -612,6 +624,10 @@ class FrigateApp:
|
||||
self.start_record_cleanup()
|
||||
self.start_watchdog()
|
||||
|
||||
# restore persisted runtime overrides on top of config
|
||||
self.restore_active_profile()
|
||||
self.dispatcher.restore_runtime_state()
|
||||
|
||||
self.init_auth()
|
||||
|
||||
try:
|
||||
|
||||
@ -3,11 +3,13 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from typing import Any, Callable, Optional, cast
|
||||
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.camera.activity_manager import AudioActivityManager, CameraActivityManager
|
||||
from frigate.comms.base_communicator import Communicator
|
||||
from frigate.comms.runtime_state import RuntimeStatePersistence
|
||||
from frigate.comms.webpush import WebPushClient
|
||||
from frigate.config import BirdseyeModeEnum, FrigateConfig
|
||||
from frigate.config.camera.updater import (
|
||||
@ -67,6 +69,7 @@ class Dispatcher:
|
||||
self.embeddings_reindex: dict[str, Any] = {}
|
||||
self.birdseye_layout: dict[str, Any] = {}
|
||||
self.audio_transcription_state: str = "idle"
|
||||
self._runtime_state = RuntimeStatePersistence()
|
||||
self._camera_settings_handlers: dict[str, Callable] = {
|
||||
"audio": self._on_audio_command,
|
||||
"audio_transcription": self._on_audio_transcription_command,
|
||||
@ -397,6 +400,60 @@ class Dispatcher:
|
||||
for comm in self.comms:
|
||||
comm.stop()
|
||||
|
||||
def restore_runtime_state(self) -> None:
|
||||
"""Replay persisted runtime overrides through the camera settings handlers.
|
||||
|
||||
Called once after Frigate startup completes so processing threads can
|
||||
receive the resulting ``config_updater`` broadcasts. Unknown cameras
|
||||
and topics are skipped; handler exceptions are logged and replay
|
||||
continues for remaining entries.
|
||||
"""
|
||||
state = self._runtime_state.load()
|
||||
for camera_name, features in state.items():
|
||||
if camera_name not in self.config.cameras:
|
||||
continue
|
||||
for topic, value in features.items():
|
||||
handler = self._camera_settings_handlers.get(topic)
|
||||
if handler is None:
|
||||
continue
|
||||
payload = "ON" if value else "OFF"
|
||||
try:
|
||||
handler(camera_name, payload)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to restore runtime state %s.%s=%s",
|
||||
camera_name,
|
||||
topic,
|
||||
payload,
|
||||
)
|
||||
continue
|
||||
logger.info(
|
||||
"Restored runtime state: %s.%s=%s",
|
||||
camera_name,
|
||||
topic,
|
||||
payload,
|
||||
)
|
||||
|
||||
def clear_runtime_state_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None:
|
||||
"""Clear stored runtime overrides for YAML keys that were just rewritten.
|
||||
|
||||
Called by ``/api/config/set`` after a successful YAML save so an
|
||||
explicit settings-UI save isn't silently overridden by an older
|
||||
runtime toggle on the next restart.
|
||||
"""
|
||||
self._runtime_state.clear_for_yaml_keys(dotted_keys)
|
||||
|
||||
def clear_runtime_state(self) -> None:
|
||||
"""Wipe every stored runtime override.
|
||||
|
||||
Called when a profile is activated or deactivated. A profile switch
|
||||
changes the layer below the runtime overrides, so the stored
|
||||
"steady state" is no longer valid and must be reset; otherwise a
|
||||
subsequent restart would replay stale overrides on top of the new
|
||||
profile-derived in-memory state.
|
||||
"""
|
||||
self._runtime_state.clear_all()
|
||||
|
||||
def _on_detect_command(self, camera_name: str, payload: str) -> None:
|
||||
"""Callback for detect topic."""
|
||||
detect_settings = self.config.cameras[camera_name].detect
|
||||
@ -428,6 +485,7 @@ class Dispatcher:
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.detect, camera_name),
|
||||
detect_settings,
|
||||
)
|
||||
self._runtime_state.set(camera_name, "detect", detect_settings.enabled)
|
||||
self.publish(f"{camera_name}/detect/state", payload, retain=True)
|
||||
|
||||
def _on_enabled_command(self, camera_name: str, payload: str) -> None:
|
||||
@ -452,6 +510,7 @@ class Dispatcher:
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.enabled, camera_name),
|
||||
camera_settings.enabled,
|
||||
)
|
||||
self._runtime_state.set(camera_name, "enabled", camera_settings.enabled)
|
||||
self.publish(f"{camera_name}/enabled/state", payload, retain=True)
|
||||
|
||||
def _on_motion_command(self, camera_name: str, payload: str) -> None:
|
||||
@ -614,6 +673,7 @@ class Dispatcher:
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.audio, camera_name),
|
||||
audio_settings,
|
||||
)
|
||||
self._runtime_state.set(camera_name, "audio", audio_settings.enabled)
|
||||
self.publish(f"{camera_name}/audio/state", payload, retain=True)
|
||||
|
||||
def _on_audio_transcription_command(self, camera_name: str, payload: str) -> None:
|
||||
@ -670,6 +730,7 @@ class Dispatcher:
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.record, camera_name),
|
||||
record_settings,
|
||||
)
|
||||
self._runtime_state.set(camera_name, "recordings", record_settings.enabled)
|
||||
self.publish(f"{camera_name}/recordings/state", payload, retain=True)
|
||||
|
||||
def _on_snapshots_command(self, camera_name: str, payload: str) -> None:
|
||||
@ -689,6 +750,7 @@ class Dispatcher:
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.snapshots, camera_name),
|
||||
snapshots_settings,
|
||||
)
|
||||
self._runtime_state.set(camera_name, "snapshots", snapshots_settings.enabled)
|
||||
self.publish(f"{camera_name}/snapshots/state", payload, retain=True)
|
||||
|
||||
def _on_ptz_command(self, camera_name: str, payload: str | bytes) -> None:
|
||||
|
||||
163
frigate/comms/runtime_state.py
Normal file
163
frigate/comms/runtime_state.py
Normal file
@ -0,0 +1,163 @@
|
||||
"""Persistence layer for dispatcher runtime state overrides."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from filelock import FileLock, Timeout
|
||||
|
||||
from frigate.util.config import find_config_file
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RuntimeStatePersistence:
|
||||
"""Persist last-known runtime states for dispatcher toggles.
|
||||
|
||||
Stores boolean overrides applied to camera-level toggles by the dispatcher.
|
||||
Overrides are replayed at startup on top of the YAML-derived in-memory
|
||||
config, so changes made via MQTT or the live-view UI survive a restart.
|
||||
"""
|
||||
|
||||
# Maps dispatcher topic name -> YAML key suffix under cameras.<cam>
|
||||
TRACKED_TOPICS: dict[str, str] = {
|
||||
"enabled": "enabled",
|
||||
"detect": "detect.enabled",
|
||||
"snapshots": "snapshots.enabled",
|
||||
"recordings": "record.enabled",
|
||||
"audio": "audio.enabled",
|
||||
}
|
||||
|
||||
_SUFFIX_TO_TOPIC: dict[str, str] = {v: k for k, v in TRACKED_TOPICS.items()}
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._path = os.path.join(
|
||||
os.path.dirname(find_config_file()), ".runtime_state.json"
|
||||
)
|
||||
self._lock_path = f"{self._path}.lock"
|
||||
self._lock_timeout = 5
|
||||
|
||||
def load(self) -> dict[str, dict[str, bool]]:
|
||||
"""Return {camera: {topic: bool}} or {} if missing/corrupt."""
|
||||
try:
|
||||
with FileLock(self._lock_path, timeout=self._lock_timeout):
|
||||
data = self._read_locked()
|
||||
except Timeout:
|
||||
logger.error("Timed out acquiring runtime state lock for load")
|
||||
return {}
|
||||
cameras = data.get("cameras", {})
|
||||
if not isinstance(cameras, dict):
|
||||
return {}
|
||||
# Filter out malformed camera entries so callers can trust the shape.
|
||||
return {
|
||||
name: features
|
||||
for name, features in cameras.items()
|
||||
if isinstance(features, dict)
|
||||
}
|
||||
|
||||
def set(self, camera: str, topic: str, value: bool) -> None:
|
||||
"""Persist a single (camera, topic, value). No-op if topic untracked."""
|
||||
if topic not in self.TRACKED_TOPICS:
|
||||
return
|
||||
try:
|
||||
with FileLock(self._lock_path, timeout=self._lock_timeout):
|
||||
data = self._read_locked()
|
||||
cameras = data.setdefault("cameras", {})
|
||||
if not isinstance(cameras, dict):
|
||||
cameras = {}
|
||||
data["cameras"] = cameras
|
||||
cam = cameras.setdefault(camera, {})
|
||||
if not isinstance(cam, dict):
|
||||
cam = {}
|
||||
cameras[camera] = cam
|
||||
cam[topic] = bool(value)
|
||||
self._write_locked(data)
|
||||
except Timeout:
|
||||
logger.error("Timed out persisting runtime state for %s/%s", camera, topic)
|
||||
except OSError:
|
||||
logger.exception("Failed to persist runtime state for %s/%s", camera, topic)
|
||||
|
||||
def clear_all(self) -> None:
|
||||
"""Wipe every stored runtime override.
|
||||
|
||||
Called when the "layer below" changes in a way that invalidates all
|
||||
runtime overrides for the current session (currently: profile
|
||||
activation or deactivation).
|
||||
"""
|
||||
try:
|
||||
with FileLock(self._lock_path, timeout=self._lock_timeout):
|
||||
if not os.path.exists(self._path):
|
||||
return
|
||||
self._write_locked({"cameras": {}})
|
||||
except Timeout:
|
||||
logger.error("Timed out clearing runtime state")
|
||||
except OSError:
|
||||
logger.exception("Failed to clear runtime state")
|
||||
|
||||
def clear_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None:
|
||||
"""Remove stored entries whose YAML key was just rewritten.
|
||||
|
||||
Each dotted key must be of the form ``cameras.<camera>.<suffix>``.
|
||||
Keys that don't match a tracked topic are ignored.
|
||||
"""
|
||||
to_remove: list[tuple[str, str]] = []
|
||||
for key in dotted_keys:
|
||||
parts = key.split(".")
|
||||
if len(parts) < 3 or parts[0] != "cameras":
|
||||
continue
|
||||
camera = parts[1]
|
||||
suffix = ".".join(parts[2:])
|
||||
topic = self._SUFFIX_TO_TOPIC.get(suffix)
|
||||
if topic is not None:
|
||||
to_remove.append((camera, topic))
|
||||
|
||||
if not to_remove:
|
||||
return
|
||||
|
||||
try:
|
||||
with FileLock(self._lock_path, timeout=self._lock_timeout):
|
||||
data = self._read_locked()
|
||||
cameras = data.get("cameras")
|
||||
if not isinstance(cameras, dict):
|
||||
return
|
||||
changed = False
|
||||
for camera, topic in to_remove:
|
||||
cam = cameras.get(camera)
|
||||
if isinstance(cam, dict) and topic in cam:
|
||||
del cam[topic]
|
||||
changed = True
|
||||
if not cam:
|
||||
del cameras[camera]
|
||||
if changed:
|
||||
self._write_locked(data)
|
||||
except Timeout:
|
||||
logger.error("Timed out clearing runtime state for YAML keys")
|
||||
except OSError:
|
||||
logger.exception("Failed to clear runtime state for YAML keys")
|
||||
|
||||
def _read_locked(self) -> dict[str, Any]:
|
||||
"""Read the JSON file while the FileLock is held.
|
||||
|
||||
Returns ``{}`` on a missing or corrupt file so the caller can write a
|
||||
fresh structure on the next mutation.
|
||||
"""
|
||||
if not os.path.exists(self._path):
|
||||
return {}
|
||||
try:
|
||||
with open(self._path, "r") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.exception(
|
||||
"Failed to read runtime state file %s; starting fresh", self._path
|
||||
)
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def _write_locked(self, data: dict[str, Any]) -> None:
|
||||
"""Atomically write the JSON file while the FileLock is held."""
|
||||
tmp_path = f"{self._path}.tmp"
|
||||
with open(tmp_path, "w") as f:
|
||||
json.dump(data, f, indent=2, sort_keys=True)
|
||||
os.replace(tmp_path, self._path)
|
||||
@ -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
|
||||
|
||||
@ -3,7 +3,7 @@ from typing import Union
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from frigate.const import DEFAULT_FFMPEG_VERSION, INCLUDED_FFMPEG_VERSIONS
|
||||
from frigate.util.config import resolve_ffmpeg_path
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
from ..env import EnvString
|
||||
@ -49,7 +49,7 @@ class FfmpegConfig(FrigateBaseModel):
|
||||
path: str = Field(
|
||||
default="default",
|
||||
title="FFmpeg path",
|
||||
description='Path to the FFmpeg binary to use or a version alias ("5.0" or "7.0").',
|
||||
description='Path to the FFmpeg binary to use or a version alias ("5.0" or "8.0").',
|
||||
)
|
||||
global_args: Union[str, list[str]] = Field(
|
||||
default=FFMPEG_GLOBAL_ARGS_DEFAULT,
|
||||
@ -90,21 +90,11 @@ class FfmpegConfig(FrigateBaseModel):
|
||||
|
||||
@property
|
||||
def ffmpeg_path(self) -> str:
|
||||
if self.path == "default":
|
||||
return f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg"
|
||||
elif self.path in INCLUDED_FFMPEG_VERSIONS:
|
||||
return f"/usr/lib/ffmpeg/{self.path}/bin/ffmpeg"
|
||||
else:
|
||||
return f"{self.path}/bin/ffmpeg"
|
||||
return resolve_ffmpeg_path(self.path, "ffmpeg")
|
||||
|
||||
@property
|
||||
def ffprobe_path(self) -> str:
|
||||
if self.path == "default":
|
||||
return f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffprobe"
|
||||
elif self.path in INCLUDED_FFMPEG_VERSIONS:
|
||||
return f"/usr/lib/ffmpeg/{self.path}/bin/ffprobe"
|
||||
else:
|
||||
return f"{self.path}/bin/ffprobe"
|
||||
return resolve_ffmpeg_path(self.path, "ffprobe")
|
||||
|
||||
|
||||
class CameraRoleEnum(str, Enum):
|
||||
|
||||
@ -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).",
|
||||
)
|
||||
|
||||
@ -680,6 +680,13 @@ class FrigateConfig(FrigateBaseModel):
|
||||
if self.ffmpeg.hwaccel_args == "auto":
|
||||
self.ffmpeg.hwaccel_args = auto_detect_hwaccel()
|
||||
|
||||
# Resolve global export hwaccel_args so it matches the per-camera
|
||||
# resolution below. Without this, every camera reads as overriding
|
||||
# record.export.hwaccel_args because the global stays "auto" while
|
||||
# the camera value gets resolved to the actual args list.
|
||||
if self.record.export.hwaccel_args == "auto":
|
||||
self.record.export.hwaccel_args = self.ffmpeg.hwaccel_args
|
||||
|
||||
# Populate global audio filters from listen. Existing user-defined
|
||||
# entries for labels not in listen are preserved but unused at runtime.
|
||||
if self.audio.filters is None:
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -124,11 +163,24 @@ class ProfileManager:
|
||||
self.config.active_profile = None
|
||||
self._persist_active_profile(None)
|
||||
|
||||
def activate_profile(self, profile_name: Optional[str]) -> Optional[str]:
|
||||
# drop all runtime overrides so they don't replay stale values on restart
|
||||
if self.dispatcher is not None:
|
||||
self.dispatcher.clear_runtime_state()
|
||||
|
||||
def activate_profile(
|
||||
self,
|
||||
profile_name: Optional[str],
|
||||
clear_runtime_overrides: bool = True,
|
||||
) -> Optional[str]:
|
||||
"""Activate a profile by name, or deactivate if None.
|
||||
|
||||
Args:
|
||||
profile_name: Profile name to activate, or None to deactivate.
|
||||
clear_runtime_overrides: When True (the default, for user-initiated
|
||||
activations) drop the dispatcher's runtime override file because
|
||||
the layer below changed. Startup callers that are replaying a
|
||||
persisted profile pass False so the runtime state stays
|
||||
available for the subsequent replay step.
|
||||
|
||||
Returns:
|
||||
None on success, or an error message string on failure.
|
||||
@ -156,6 +208,11 @@ class ProfileManager:
|
||||
|
||||
self.config.active_profile = profile_name
|
||||
self._persist_active_profile(profile_name)
|
||||
|
||||
# a profile switch invalidates the steady-state runtime overrides
|
||||
if clear_runtime_overrides and self.dispatcher is not None:
|
||||
self.dispatcher.clear_runtime_state()
|
||||
|
||||
logger.info(
|
||||
"Profile %s",
|
||||
f"'{profile_name}' activated" if profile_name else "deactivated",
|
||||
@ -292,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:
|
||||
|
||||
@ -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=",",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -5,6 +5,7 @@ frigate.jobs.debug_replay. This module owns only session presence
|
||||
(active), session metadata, and post-session cleanup.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
@ -40,6 +41,9 @@ from frigate.util.config import find_config_file
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_SESSION_DURATION_SECONDS = 12 * 60 * 60
|
||||
AUTO_STOP_CHECK_INTERVAL_SECONDS = 60
|
||||
|
||||
|
||||
class DebugReplayManager:
|
||||
"""Owns the lifecycle pointers for a single debug replay session.
|
||||
@ -58,6 +62,7 @@ class DebugReplayManager:
|
||||
self.clip_path: str | None = None
|
||||
self.start_ts: float | None = None
|
||||
self.end_ts: float | None = None
|
||||
self.session_started_at: float | None = None
|
||||
self._job_state_publisher = JobStatePublisher()
|
||||
|
||||
@property
|
||||
@ -83,6 +88,7 @@ class DebugReplayManager:
|
||||
self.start_ts = start_ts
|
||||
self.end_ts = end_ts
|
||||
self.clip_path = None
|
||||
self.session_started_at = time.time()
|
||||
|
||||
def mark_session_ready(self, clip_path: str) -> None:
|
||||
"""Record the on-disk clip path after the camera has been published."""
|
||||
@ -104,6 +110,7 @@ class DebugReplayManager:
|
||||
self.clip_path = None
|
||||
self.start_ts = None
|
||||
self.end_ts = None
|
||||
self.session_started_at = None
|
||||
|
||||
def publish_camera(
|
||||
self,
|
||||
@ -351,3 +358,41 @@ def cleanup_replay_cameras() -> None:
|
||||
shutil.rmtree(REPLAY_DIR)
|
||||
except Exception as e:
|
||||
logger.error("Failed to remove replay cache directory: %s", e)
|
||||
|
||||
|
||||
async def debug_replay_auto_stop_watchdog(
|
||||
manager: DebugReplayManager,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
) -> None:
|
||||
"""Auto-stop debug replay sessions that exceed MAX_SESSION_DURATION_SECONDS.
|
||||
|
||||
Backstop against a session left running for days. The cap is intentionally
|
||||
generous so realistic tuning and overnight soak workflows aren't disrupted.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(AUTO_STOP_CHECK_INTERVAL_SECONDS)
|
||||
|
||||
started_at = manager.session_started_at
|
||||
if not manager.active or started_at is None:
|
||||
continue
|
||||
|
||||
if time.time() - started_at < MAX_SESSION_DURATION_SECONDS:
|
||||
continue
|
||||
|
||||
replay_name = manager.replay_camera_name
|
||||
await asyncio.to_thread(
|
||||
manager.stop,
|
||||
frigate_config=frigate_config,
|
||||
config_publisher=config_publisher,
|
||||
)
|
||||
logger.info(
|
||||
"Debug replay auto-stopped after exceeding max session duration of %d hours: %s",
|
||||
MAX_SESSION_DURATION_SECONDS // 3600,
|
||||
replay_name,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Error in debug replay auto-stop watchdog")
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -465,16 +465,6 @@ PRESETS_RECORD_OUTPUT = {
|
||||
"-c:a",
|
||||
"aac",
|
||||
],
|
||||
# NOTE: This preset originally used "-c:a copy" to pass through audio
|
||||
# without re-encoding. FFmpeg 7.x introduced a threaded pipeline where
|
||||
# demuxing, encoding, and muxing run in parallel via a Scheduler. This
|
||||
# broke audio streamcopy from RTSP sources: packets are demuxed correctly
|
||||
# but silently dropped before reaching the muxer (0 bytes written). The
|
||||
# issue is specific to RTSP + streamcopy; file inputs and transcoding both
|
||||
# work. Transcoding AAC audio is very lightweight (~30KiB per 10s segment)
|
||||
# and adds negligible CPU overhead, so this is an acceptable workaround.
|
||||
# The benefits of FFmpeg 7.x — particularly the removal of gamma correction
|
||||
# hacks required by earlier versions — outweigh this trade-off.
|
||||
"preset-record-generic-audio-copy": [
|
||||
"-f",
|
||||
"segment",
|
||||
@ -486,10 +476,8 @@ PRESETS_RECORD_OUTPUT = {
|
||||
"1",
|
||||
"-strftime",
|
||||
"1",
|
||||
"-c:v",
|
||||
"-c",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"aac",
|
||||
],
|
||||
"preset-record-mjpeg": [
|
||||
"-f",
|
||||
|
||||
@ -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
frigate/jobs/motion_search_batch.py
Normal file
75
frigate/jobs/motion_search_batch.py
Normal 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
frigate/jobs/motion_search_decode.py
Normal file
382
frigate/jobs/motion_search_decode.py
Normal 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
|
||||
@ -456,7 +456,7 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
diff = max(0.0, float(self.start_time) - float(preview.start_time))
|
||||
ffmpeg_cmd = [
|
||||
"/usr/lib/ffmpeg/7.0/bin/ffmpeg", # hardcode path for exports thumbnail due to missing libwebp support
|
||||
"/usr/lib/ffmpeg/8.0/bin/ffmpeg", # hardcode path for exports thumbnail due to missing libwebp support
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"warning",
|
||||
|
||||
@ -32,7 +32,7 @@ class StatsEmitter(threading.Thread):
|
||||
self.config = config
|
||||
self.stats_tracking = stats_tracking
|
||||
self.stop_event = stop_event
|
||||
self.hwaccel_errors: list[str] = []
|
||||
self.hwaccel_errors: dict[str, float] = {}
|
||||
self.stats_history: list[dict[str, Any]] = []
|
||||
|
||||
# create communication for stats
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""Utilities for stats."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
@ -34,6 +35,10 @@ from frigate.util.services import (
|
||||
)
|
||||
from frigate.version import VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HWACCEL_ERROR_COOLDOWN_SECONDS = 3600
|
||||
|
||||
|
||||
def get_latest_version(config: FrigateConfig) -> str:
|
||||
if not config.telemetry.version_check:
|
||||
@ -167,7 +172,9 @@ def get_detector_stats(
|
||||
|
||||
|
||||
def get_processing_stats(
|
||||
config: FrigateConfig, stats: dict[str, str], hwaccel_errors: list[str]
|
||||
config: FrigateConfig,
|
||||
stats: dict[str, str],
|
||||
hwaccel_errors: dict[str, float],
|
||||
) -> None:
|
||||
"""Get stats for cpu / gpu."""
|
||||
|
||||
@ -206,7 +213,9 @@ async def set_bandwidth_stats(config: FrigateConfig, all_stats: dict[str, Any])
|
||||
|
||||
|
||||
async def set_gpu_stats(
|
||||
config: FrigateConfig, all_stats: dict[str, Any], hwaccel_errors: list[str]
|
||||
config: FrigateConfig,
|
||||
all_stats: dict[str, Any],
|
||||
hwaccel_errors: dict[str, float],
|
||||
) -> None:
|
||||
"""Parse GPUs from hwaccel args and use for stats."""
|
||||
hwaccel_args = []
|
||||
@ -231,12 +240,16 @@ async def set_gpu_stats(
|
||||
|
||||
stats: dict[str, dict] = {}
|
||||
intel_gpu_collected = False
|
||||
now = time.monotonic()
|
||||
|
||||
for args in hwaccel_args:
|
||||
if args in hwaccel_errors:
|
||||
# known erroring args should automatically return as error
|
||||
stats["error-gpu"] = {"gpu": "", "mem": ""}
|
||||
elif "cuvid" in args or "nvidia" in args:
|
||||
last_error = hwaccel_errors.get(args)
|
||||
if last_error is not None:
|
||||
if now - last_error < HWACCEL_ERROR_COOLDOWN_SECONDS:
|
||||
continue
|
||||
hwaccel_errors.pop(args, None)
|
||||
|
||||
if "cuvid" in args or "nvidia" in args:
|
||||
# nvidia GPU
|
||||
nvidia_usage = get_nvidia_gpu_stats()
|
||||
|
||||
@ -253,7 +266,7 @@ async def set_gpu_stats(
|
||||
|
||||
else:
|
||||
stats["nvidia-gpu"] = {"vendor": "nvidia", "gpu": "", "mem": ""}
|
||||
hwaccel_errors.append(args)
|
||||
hwaccel_errors[args] = time.monotonic()
|
||||
elif "nvmpi" in args or "jetson" in args:
|
||||
# nvidia Jetson
|
||||
jetson_usage = get_jetson_stats()
|
||||
@ -262,7 +275,7 @@ async def set_gpu_stats(
|
||||
stats["jetson-gpu"] = {"vendor": "nvidia", **jetson_usage}
|
||||
else:
|
||||
stats["jetson-gpu"] = {"vendor": "nvidia", "gpu": "", "mem": ""}
|
||||
hwaccel_errors.append(args)
|
||||
hwaccel_errors[args] = time.monotonic()
|
||||
elif "qsv" in args or ("vaapi" in args and not is_vaapi_amd_driver()):
|
||||
if not config.telemetry.stats.intel_gpu_stats:
|
||||
continue
|
||||
@ -280,7 +293,7 @@ async def set_gpu_stats(
|
||||
stats[name] = entry
|
||||
else:
|
||||
stats["intel-gpu"] = {"vendor": "intel", "gpu": "", "mem": ""}
|
||||
hwaccel_errors.append(args)
|
||||
hwaccel_errors[args] = time.monotonic()
|
||||
elif "vaapi" in args:
|
||||
if not config.telemetry.stats.amd_gpu_stats:
|
||||
continue
|
||||
@ -292,7 +305,7 @@ async def set_gpu_stats(
|
||||
stats["amd-vaapi"] = {"vendor": "amd", **amd_usage}
|
||||
else:
|
||||
stats["amd-vaapi"] = {"vendor": "amd", "gpu": "", "mem": ""}
|
||||
hwaccel_errors.append(args)
|
||||
hwaccel_errors[args] = time.monotonic()
|
||||
elif "preset-rk" in args:
|
||||
rga_usage = get_rockchip_gpu_stats()
|
||||
|
||||
@ -328,7 +341,9 @@ async def set_npu_usages(config: FrigateConfig, all_stats: dict[str, Any]) -> No
|
||||
|
||||
|
||||
def stats_snapshot(
|
||||
config: FrigateConfig, stats_tracking: StatsTrackingTypes, hwaccel_errors: list[str]
|
||||
config: FrigateConfig,
|
||||
stats_tracking: StatsTrackingTypes,
|
||||
hwaccel_errors: dict[str, float],
|
||||
) -> dict[str, Any]:
|
||||
"""Get a snapshot of the current stats that are being tracked."""
|
||||
camera_metrics = stats_tracking["camera_metrics"]
|
||||
|
||||
@ -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() == []
|
||||
|
||||
@ -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],
|
||||
)
|
||||
|
||||
####################################################################################################################
|
||||
|
||||
217
frigate/test/test_dispatcher_runtime_state.py
Normal file
217
frigate/test/test_dispatcher_runtime_state.py
Normal file
@ -0,0 +1,217 @@
|
||||
"""Tests for Dispatcher runtime state persistence wiring."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.comms.dispatcher import Dispatcher
|
||||
from frigate.comms.runtime_state import RuntimeStatePersistence
|
||||
|
||||
|
||||
def _make_camera_mock(
|
||||
*,
|
||||
enabled: bool = True,
|
||||
enabled_in_config: bool = True,
|
||||
detect_enabled: bool = True,
|
||||
record_enabled: bool = True,
|
||||
record_enabled_in_config: bool = True,
|
||||
snapshots_enabled: bool = True,
|
||||
audio_enabled: bool = True,
|
||||
audio_enabled_in_config: bool = True,
|
||||
) -> MagicMock:
|
||||
"""Build a camera config mock with the fields the in-scope handlers read."""
|
||||
camera = MagicMock()
|
||||
camera.enabled = enabled
|
||||
camera.enabled_in_config = enabled_in_config
|
||||
camera.detect.enabled = detect_enabled
|
||||
camera.motion.enabled = True # avoid the detect→motion side-effect path
|
||||
camera.record.enabled = record_enabled
|
||||
camera.record.enabled_in_config = record_enabled_in_config
|
||||
camera.snapshots.enabled = snapshots_enabled
|
||||
camera.audio.enabled = audio_enabled
|
||||
camera.audio.enabled_in_config = audio_enabled_in_config
|
||||
return camera
|
||||
|
||||
|
||||
def _build_dispatcher(cameras: dict[str, MagicMock]) -> Dispatcher:
|
||||
"""Construct a Dispatcher with the bare-minimum mocks the tests need."""
|
||||
config = MagicMock()
|
||||
config.cameras = cameras
|
||||
config_updater = MagicMock()
|
||||
onvif = MagicMock()
|
||||
ptz_metrics: dict = {}
|
||||
communicators: list = []
|
||||
|
||||
with (
|
||||
patch("frigate.comms.dispatcher.CameraActivityManager"),
|
||||
patch("frigate.comms.dispatcher.AudioActivityManager"),
|
||||
):
|
||||
return Dispatcher(config, config_updater, onvif, ptz_metrics, communicators)
|
||||
|
||||
|
||||
class TestRestoreRuntimeState(unittest.TestCase):
|
||||
"""Verify replay routes through handlers and tolerates missing entries."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.dispatcher = _build_dispatcher(
|
||||
{
|
||||
"front_door": _make_camera_mock(),
|
||||
"back_yard": _make_camera_mock(),
|
||||
}
|
||||
)
|
||||
# Swap each in-scope handler for a MagicMock so we can assert calls
|
||||
# without exercising the handler's own logic.
|
||||
self.handler_mocks: dict[str, MagicMock] = {}
|
||||
for topic in ("enabled", "detect", "snapshots", "recordings", "audio"):
|
||||
mock = MagicMock()
|
||||
self.dispatcher._camera_settings_handlers[topic] = mock
|
||||
self.handler_mocks[topic] = mock
|
||||
|
||||
def test_replays_each_stored_entry_through_its_handler(self) -> None:
|
||||
self.dispatcher._runtime_state = MagicMock(
|
||||
spec=RuntimeStatePersistence,
|
||||
load=MagicMock(
|
||||
return_value={
|
||||
"front_door": {"detect": False, "recordings": False},
|
||||
"back_yard": {"audio": False},
|
||||
}
|
||||
),
|
||||
)
|
||||
self.dispatcher.restore_runtime_state()
|
||||
|
||||
self.handler_mocks["detect"].assert_called_once_with("front_door", "OFF")
|
||||
self.handler_mocks["recordings"].assert_called_once_with("front_door", "OFF")
|
||||
self.handler_mocks["audio"].assert_called_once_with("back_yard", "OFF")
|
||||
self.handler_mocks["enabled"].assert_not_called()
|
||||
self.handler_mocks["snapshots"].assert_not_called()
|
||||
|
||||
def test_skips_unknown_cameras(self) -> None:
|
||||
self.dispatcher._runtime_state = MagicMock(
|
||||
spec=RuntimeStatePersistence,
|
||||
load=MagicMock(return_value={"removed_cam": {"detect": False}}),
|
||||
)
|
||||
self.dispatcher.restore_runtime_state()
|
||||
for mock in self.handler_mocks.values():
|
||||
mock.assert_not_called()
|
||||
|
||||
def test_skips_unknown_topics(self) -> None:
|
||||
self.dispatcher._runtime_state = MagicMock(
|
||||
spec=RuntimeStatePersistence,
|
||||
load=MagicMock(return_value={"front_door": {"some_old_topic": True}}),
|
||||
)
|
||||
self.dispatcher.restore_runtime_state()
|
||||
for mock in self.handler_mocks.values():
|
||||
mock.assert_not_called()
|
||||
|
||||
def test_continues_after_handler_exception(self) -> None:
|
||||
self.handler_mocks["detect"].side_effect = RuntimeError("boom")
|
||||
self.dispatcher._runtime_state = MagicMock(
|
||||
spec=RuntimeStatePersistence,
|
||||
load=MagicMock(
|
||||
return_value={
|
||||
"front_door": {"detect": False, "recordings": False},
|
||||
}
|
||||
),
|
||||
)
|
||||
# Must not raise; the recordings handler must still run.
|
||||
self.dispatcher.restore_runtime_state()
|
||||
self.handler_mocks["recordings"].assert_called_once_with("front_door", "OFF")
|
||||
|
||||
def test_true_value_routes_as_on_payload(self) -> None:
|
||||
self.dispatcher._runtime_state = MagicMock(
|
||||
spec=RuntimeStatePersistence,
|
||||
load=MagicMock(return_value={"front_door": {"detect": True}}),
|
||||
)
|
||||
self.dispatcher.restore_runtime_state()
|
||||
self.handler_mocks["detect"].assert_called_once_with("front_door", "ON")
|
||||
|
||||
|
||||
class TestHandlersPersistViaSet(unittest.TestCase):
|
||||
"""Verify each in-scope handler writes to the runtime state on success."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.tmp_dir = tempfile.mkdtemp()
|
||||
self.config_path = os.path.join(self.tmp_dir, "config.yml")
|
||||
with open(self.config_path, "w") as f:
|
||||
f.write("")
|
||||
self._patcher = patch(
|
||||
"frigate.comms.runtime_state.find_config_file",
|
||||
return_value=self.config_path,
|
||||
)
|
||||
self._patcher.start()
|
||||
|
||||
# Start with everything OFF so each ON payload triggers a real change
|
||||
self.cameras = {
|
||||
"front_door": _make_camera_mock(
|
||||
enabled=False,
|
||||
detect_enabled=False,
|
||||
record_enabled=False,
|
||||
snapshots_enabled=False,
|
||||
audio_enabled=False,
|
||||
)
|
||||
}
|
||||
self.dispatcher = _build_dispatcher(self.cameras)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._patcher.stop()
|
||||
for name in os.listdir(self.tmp_dir):
|
||||
os.remove(os.path.join(self.tmp_dir, name))
|
||||
os.rmdir(self.tmp_dir)
|
||||
|
||||
def _stored_state(self) -> dict:
|
||||
return RuntimeStatePersistence().load()
|
||||
|
||||
def test_enabled_handler_persists(self) -> None:
|
||||
self.dispatcher._on_enabled_command("front_door", "ON")
|
||||
self.assertEqual(self._stored_state(), {"front_door": {"enabled": True}})
|
||||
|
||||
def test_detect_handler_persists(self) -> None:
|
||||
self.dispatcher._on_detect_command("front_door", "ON")
|
||||
self.assertEqual(self._stored_state(), {"front_door": {"detect": True}})
|
||||
|
||||
def test_recordings_handler_persists(self) -> None:
|
||||
self.dispatcher._on_recordings_command("front_door", "ON")
|
||||
self.assertEqual(self._stored_state(), {"front_door": {"recordings": True}})
|
||||
|
||||
def test_snapshots_handler_persists(self) -> None:
|
||||
self.dispatcher._on_snapshots_command("front_door", "ON")
|
||||
self.assertEqual(self._stored_state(), {"front_door": {"snapshots": True}})
|
||||
|
||||
def test_audio_handler_persists(self) -> None:
|
||||
self.dispatcher._on_audio_command("front_door", "ON")
|
||||
self.assertEqual(self._stored_state(), {"front_door": {"audio": True}})
|
||||
|
||||
def test_enabled_in_config_gate_blocks_persistence(self) -> None:
|
||||
"""An ON payload rejected by the gate must not be persisted."""
|
||||
cam = self.cameras["front_door"]
|
||||
cam.enabled_in_config = False
|
||||
cam.record.enabled_in_config = False
|
||||
cam.audio.enabled_in_config = False
|
||||
|
||||
self.dispatcher._on_enabled_command("front_door", "ON")
|
||||
self.dispatcher._on_recordings_command("front_door", "ON")
|
||||
self.dispatcher._on_audio_command("front_door", "ON")
|
||||
|
||||
self.assertEqual(self._stored_state(), {})
|
||||
|
||||
|
||||
class TestClearPassthrough(unittest.TestCase):
|
||||
"""The dispatcher's public clear methods delegate to the store."""
|
||||
|
||||
def test_clear_runtime_state_for_yaml_keys_passthrough(self) -> None:
|
||||
dispatcher = _build_dispatcher({})
|
||||
dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence)
|
||||
keys = ["cameras.front_door.detect.enabled"]
|
||||
dispatcher.clear_runtime_state_for_yaml_keys(keys)
|
||||
dispatcher._runtime_state.clear_for_yaml_keys.assert_called_once_with(keys)
|
||||
|
||||
def test_clear_runtime_state_passthrough(self) -> None:
|
||||
dispatcher = _build_dispatcher({})
|
||||
dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence)
|
||||
dispatcher.clear_runtime_state()
|
||||
dispatcher._runtime_state.clear_all.assert_called_once_with()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
58
frigate/test/test_motion_search_batch.py
Normal file
58
frigate/test/test_motion_search_batch.py
Normal 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
frigate/test/test_motion_search_decode.py
Normal file
190
frigate/test/test_motion_search_decode.py
Normal 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()
|
||||
87
frigate/test/test_motion_search_spatial.py
Normal file
87
frigate/test/test_motion_search_spatial.py
Normal file
@ -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
frigate/test/test_onvif_probe.py
Normal file
124
frigate/test/test_onvif_probe.py
Normal file
@ -0,0 +1,124 @@
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from zeep.exceptions import Fault, TransportError
|
||||
from zeep.transports import AsyncTransport
|
||||
|
||||
from frigate.api.camera import _build_digest_transport, _connect_onvif_camera
|
||||
|
||||
|
||||
def _make_camera(update_side_effect=None):
|
||||
"""Build a mock ONVIFCamera whose update_xaddrs can raise or succeed."""
|
||||
camera = MagicMock()
|
||||
camera.update_xaddrs = AsyncMock(side_effect=update_side_effect)
|
||||
return camera
|
||||
|
||||
|
||||
class TestConnectOnvifCamera(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_password_digest_succeeds_first(self):
|
||||
# Cameras that accept PasswordDigest authenticate on the first attempt
|
||||
# and should never trigger the PasswordText fallback.
|
||||
camera = _make_camera()
|
||||
|
||||
with patch("frigate.api.camera.ONVIFCamera", return_value=camera) as mock_cls:
|
||||
result = await _connect_onvif_camera(
|
||||
"cam.local", 80, "user", "pass", None, "basic"
|
||||
)
|
||||
|
||||
self.assertIs(result, camera)
|
||||
mock_cls.assert_called_once()
|
||||
self.assertTrue(mock_cls.call_args.kwargs["encrypt"])
|
||||
|
||||
async def test_falls_back_to_password_text(self):
|
||||
# A PasswordDigest rejection should retry once with PasswordText.
|
||||
camera_digest = _make_camera(update_side_effect=Fault("token rejected"))
|
||||
camera_text = _make_camera()
|
||||
|
||||
with patch(
|
||||
"frigate.api.camera.ONVIFCamera",
|
||||
side_effect=[camera_digest, camera_text],
|
||||
) as mock_cls:
|
||||
result = await _connect_onvif_camera(
|
||||
"cam.local", 80, "user", "pass", None, "basic"
|
||||
)
|
||||
|
||||
self.assertIs(result, camera_text)
|
||||
self.assertEqual(mock_cls.call_count, 2)
|
||||
self.assertTrue(mock_cls.call_args_list[0].kwargs["encrypt"])
|
||||
self.assertFalse(mock_cls.call_args_list[1].kwargs["encrypt"])
|
||||
|
||||
async def test_both_encodings_fail_raises_first_fault(self):
|
||||
# When both encodings fault, the original (PasswordDigest) fault is
|
||||
# surfaced so the caller's existing Fault handler reports it.
|
||||
first_fault = Fault("digest rejected")
|
||||
camera_digest = _make_camera(update_side_effect=first_fault)
|
||||
camera_text = _make_camera(update_side_effect=Fault("text rejected"))
|
||||
|
||||
with patch(
|
||||
"frigate.api.camera.ONVIFCamera",
|
||||
side_effect=[camera_digest, camera_text],
|
||||
) as mock_cls:
|
||||
with self.assertRaises(Fault) as ctx:
|
||||
await _connect_onvif_camera(
|
||||
"cam.local", 80, "user", "pass", None, "basic"
|
||||
)
|
||||
|
||||
self.assertIs(ctx.exception, first_fault)
|
||||
self.assertEqual(mock_cls.call_count, 2)
|
||||
|
||||
async def test_transport_error_is_not_retried(self):
|
||||
# Connection-level errors (timeout, refused, unreachable) should
|
||||
# propagate immediately without doubling latency on a second encoding.
|
||||
camera = _make_camera(update_side_effect=TransportError("unreachable"))
|
||||
|
||||
with patch("frigate.api.camera.ONVIFCamera", side_effect=[camera]) as mock_cls:
|
||||
with self.assertRaises(TransportError):
|
||||
await _connect_onvif_camera(
|
||||
"cam.local", 80, "user", "pass", None, "basic"
|
||||
)
|
||||
|
||||
mock_cls.assert_called_once()
|
||||
|
||||
async def test_digest_auth_replaces_service_transports(self):
|
||||
# auth_type "digest" wires an HTTP digest transport onto each service,
|
||||
# independently of the WS-Security encoding.
|
||||
camera = _make_camera()
|
||||
|
||||
with (
|
||||
patch("frigate.api.camera.ONVIFCamera", return_value=camera),
|
||||
patch(
|
||||
"frigate.api.camera._build_digest_transport",
|
||||
return_value="TRANSPORT",
|
||||
) as mock_transport,
|
||||
):
|
||||
result = await _connect_onvif_camera(
|
||||
"cam.local", 80, "user", "pass", None, "digest"
|
||||
)
|
||||
|
||||
self.assertIs(result, camera)
|
||||
mock_transport.assert_called_once_with("user", "pass")
|
||||
self.assertEqual(camera.devicemgmt.zeep_client.transport, "TRANSPORT")
|
||||
self.assertEqual(camera.media.zeep_client.transport, "TRANSPORT")
|
||||
self.assertEqual(camera.ptz.zeep_client.transport, "TRANSPORT")
|
||||
|
||||
async def test_basic_auth_does_not_replace_transports(self):
|
||||
# Without digest auth, no transport override is built.
|
||||
camera = _make_camera()
|
||||
|
||||
with (
|
||||
patch("frigate.api.camera.ONVIFCamera", return_value=camera),
|
||||
patch("frigate.api.camera._build_digest_transport") as mock_transport,
|
||||
):
|
||||
await _connect_onvif_camera("cam.local", 80, "user", "pass", None, "basic")
|
||||
|
||||
mock_transport.assert_not_called()
|
||||
|
||||
|
||||
class TestBuildDigestTransport(unittest.TestCase):
|
||||
def test_returns_async_transport(self):
|
||||
transport = _build_digest_transport("user", "pass")
|
||||
self.assertIsInstance(transport, AsyncTransport)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -1,5 +1,6 @@
|
||||
"""Tests for the profiles system."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
@ -727,6 +728,85 @@ class TestProfileManager(unittest.TestCase):
|
||||
# Should not raise
|
||||
json.dumps(api_base)
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_activate_profile_clears_dispatcher_runtime_state(self, mock_persist):
|
||||
"""User-initiated activation drops runtime overrides (steady-state rule)."""
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
manager.activate_profile("armed")
|
||||
dispatcher.clear_runtime_state.assert_called_once_with()
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_deactivate_profile_clears_dispatcher_runtime_state(self, mock_persist):
|
||||
"""Deactivating a profile also drops runtime overrides."""
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
manager.activate_profile("armed")
|
||||
dispatcher.clear_runtime_state.reset_mock()
|
||||
|
||||
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."""
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
manager.activate_profile("armed", clear_runtime_overrides=False)
|
||||
dispatcher.clear_runtime_state.assert_not_called()
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_update_config_clears_when_active_profile_reapplies(self, mock_persist):
|
||||
"""After /api/config/set, an active-profile re-application drops state."""
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
manager.activate_profile("armed")
|
||||
dispatcher.clear_runtime_state.reset_mock()
|
||||
|
||||
new_config = FrigateConfig(**self.config_data)
|
||||
manager.update_config(new_config)
|
||||
dispatcher.clear_runtime_state.assert_called_once_with()
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_update_config_does_not_clear_when_no_active_profile(self, mock_persist):
|
||||
"""Plain /api/config/set without a profile doesn't trigger the broad clear."""
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
# No activate_profile call — config.active_profile is None
|
||||
new_config = FrigateConfig(**self.config_data)
|
||||
manager.update_config(new_config)
|
||||
dispatcher.clear_runtime_state.assert_not_called()
|
||||
|
||||
|
||||
class TestProfilePersistence(unittest.TestCase):
|
||||
"""Test profile persistence to disk."""
|
||||
|
||||
136
frigate/test/test_runtime_state.py
Normal file
136
frigate/test/test_runtime_state.py
Normal file
@ -0,0 +1,136 @@
|
||||
"""Tests for RuntimeStatePersistence."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.comms.runtime_state import RuntimeStatePersistence
|
||||
|
||||
|
||||
class TestRuntimeStatePersistence(unittest.TestCase):
|
||||
"""Unit tests for the JSON-backed runtime state store."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.tmp_dir = tempfile.mkdtemp()
|
||||
self.config_path = os.path.join(self.tmp_dir, "config.yml")
|
||||
# Touch a placeholder config.yml so find_config_file returns a real path
|
||||
with open(self.config_path, "w") as f:
|
||||
f.write("")
|
||||
self._patcher = patch(
|
||||
"frigate.comms.runtime_state.find_config_file",
|
||||
return_value=self.config_path,
|
||||
)
|
||||
self._patcher.start()
|
||||
self.store = RuntimeStatePersistence()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._patcher.stop()
|
||||
for name in os.listdir(self.tmp_dir):
|
||||
os.remove(os.path.join(self.tmp_dir, name))
|
||||
os.rmdir(self.tmp_dir)
|
||||
|
||||
def test_load_returns_empty_when_file_missing(self) -> None:
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
def test_set_then_load_round_trip(self) -> None:
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.store.set("front_door", "recordings", True)
|
||||
self.store.set("back_yard", "audio", False)
|
||||
|
||||
result = self.store.load()
|
||||
self.assertEqual(
|
||||
result,
|
||||
{
|
||||
"front_door": {"detect": False, "recordings": True},
|
||||
"back_yard": {"audio": False},
|
||||
},
|
||||
)
|
||||
|
||||
def test_set_with_untracked_topic_is_noop(self) -> None:
|
||||
self.store.set("front_door", "ptz_autotracker", True)
|
||||
self.assertEqual(self.store.load(), {})
|
||||
# File should not even be created if no tracked entries were written
|
||||
runtime_path = os.path.join(self.tmp_dir, ".runtime_state.json")
|
||||
self.assertFalse(os.path.exists(runtime_path))
|
||||
|
||||
def test_set_overwrites_previous_value(self) -> None:
|
||||
self.store.set("front_door", "detect", True)
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.assertEqual(self.store.load(), {"front_door": {"detect": False}})
|
||||
|
||||
def test_load_returns_empty_when_file_corrupt(self) -> None:
|
||||
runtime_path = os.path.join(self.tmp_dir, ".runtime_state.json")
|
||||
with open(runtime_path, "w") as f:
|
||||
f.write("{not valid json")
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
def test_load_handles_unexpected_top_level_shape(self) -> None:
|
||||
runtime_path = os.path.join(self.tmp_dir, ".runtime_state.json")
|
||||
with open(runtime_path, "w") as f:
|
||||
json.dump(["unexpected", "list"], f)
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
def test_clear_for_yaml_keys_removes_matching_entries(self) -> None:
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.store.set("front_door", "recordings", False)
|
||||
self.store.set("back_yard", "audio", False)
|
||||
|
||||
self.store.clear_for_yaml_keys(
|
||||
[
|
||||
"cameras.front_door.detect.enabled",
|
||||
"cameras.back_yard.audio.enabled",
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.store.load(),
|
||||
{"front_door": {"recordings": False}},
|
||||
)
|
||||
|
||||
def test_clear_for_yaml_keys_collapses_empty_camera_dict(self) -> None:
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.store.clear_for_yaml_keys(["cameras.front_door.detect.enabled"])
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
def test_clear_for_yaml_keys_ignores_unrelated_keys(self) -> None:
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.store.clear_for_yaml_keys(
|
||||
[
|
||||
"ui.theme",
|
||||
"go2rtc.streams.x",
|
||||
"cameras.front_door.ffmpeg.inputs",
|
||||
"not_cameras.front_door.detect.enabled",
|
||||
]
|
||||
)
|
||||
self.assertEqual(self.store.load(), {"front_door": {"detect": False}})
|
||||
|
||||
def test_clear_for_yaml_keys_handles_empty_iterable(self) -> None:
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.store.clear_for_yaml_keys([])
|
||||
self.assertEqual(self.store.load(), {"front_door": {"detect": False}})
|
||||
|
||||
def test_camera_level_enabled_uses_top_level_yaml_key(self) -> None:
|
||||
"""`enabled` topic maps to the camera-level `cameras.<cam>.enabled` key."""
|
||||
self.store.set("front_door", "enabled", False)
|
||||
self.store.clear_for_yaml_keys(["cameras.front_door.enabled"])
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
def test_clear_all_wipes_every_entry(self) -> None:
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.store.set("front_door", "recordings", True)
|
||||
self.store.set("back_yard", "audio", False)
|
||||
|
||||
self.store.clear_all()
|
||||
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
def test_clear_all_is_safe_when_file_missing(self) -> None:
|
||||
# No prior set() calls — file does not exist
|
||||
self.store.clear_all()
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -394,7 +394,7 @@ def collect_state_classification_examples(
|
||||
|
||||
# Step 3: Extract keyframes from recordings with crops applied
|
||||
keyframes = _extract_keyframes(
|
||||
"/usr/lib/ffmpeg/7.0/bin/ffmpeg", timestamps, temp_dir, cameras
|
||||
"/usr/lib/ffmpeg/8.0/bin/ffmpeg", timestamps, temp_dir, cameras
|
||||
)
|
||||
|
||||
# Step 4: Select 24 most visually distinct images (they're already cropped)
|
||||
@ -566,7 +566,7 @@ def _extract_keyframes(
|
||||
relative_time = timestamp - recording.start_time
|
||||
|
||||
try:
|
||||
config = FfmpegConfig(path="/usr/lib/ffmpeg/7.0")
|
||||
config = FfmpegConfig(path="/usr/lib/ffmpeg/8.0")
|
||||
image_data = get_image_from_recording(
|
||||
config,
|
||||
recording.path,
|
||||
|
||||
@ -8,7 +8,13 @@ from typing import Any, Optional, Union
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from frigate.const import CONFIG_DIR, EXPORT_DIR, REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.const import (
|
||||
CONFIG_DIR,
|
||||
DEFAULT_FFMPEG_VERSION,
|
||||
EXPORT_DIR,
|
||||
INCLUDED_FFMPEG_VERSIONS,
|
||||
REDACTED_CREDENTIAL_SENTINEL,
|
||||
)
|
||||
from frigate.util.builtin import deep_merge
|
||||
from frigate.util.services import get_video_properties
|
||||
|
||||
@ -18,6 +24,26 @@ CURRENT_CONFIG_VERSION = "0.18-0"
|
||||
DEFAULT_CONFIG_FILE = os.path.join(CONFIG_DIR, "config.yml")
|
||||
|
||||
|
||||
def resolve_ffmpeg_path(path: str, binary: str = "ffmpeg") -> str:
|
||||
"""Resolve an ffmpeg version alias or custom path to a binary path.
|
||||
|
||||
A bare version alias that is no longer bundled (for example one that was
|
||||
dropped when the default version changed) falls back to the default
|
||||
bundled version so existing configs keep working across an upgrade or a
|
||||
revert. Custom install paths (anything absolute) are used as-is.
|
||||
"""
|
||||
if path == "default" or (
|
||||
not path.startswith("/") and path not in INCLUDED_FFMPEG_VERSIONS
|
||||
):
|
||||
version = DEFAULT_FFMPEG_VERSION
|
||||
elif path in INCLUDED_FFMPEG_VERSIONS:
|
||||
version = path
|
||||
else:
|
||||
return f"{path}/bin/{binary}"
|
||||
|
||||
return f"/usr/lib/ffmpeg/{version}/bin/{binary}"
|
||||
|
||||
|
||||
def redact_credential(obj: dict[str, Any], key: str) -> None:
|
||||
"""Replace obj[key] with the redaction sentinel if a value is saved, else drop.
|
||||
|
||||
@ -618,6 +644,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
|
||||
|
||||
|
||||
@ -416,6 +416,11 @@ def get_intel_gpu_stats(
|
||||
|
||||
snapshot_a = _read_intel_drm_fdinfo(target_pdev)
|
||||
if not snapshot_a:
|
||||
logger.warning(
|
||||
"Unable to collect Intel GPU stats: no DRM fdinfo entries found"
|
||||
"%s. Check that /proc is readable and the i915/xe driver is loaded",
|
||||
f" for pdev {target_pdev}" if target_pdev else "",
|
||||
)
|
||||
return None
|
||||
|
||||
start = time.monotonic()
|
||||
@ -424,6 +429,9 @@ def get_intel_gpu_stats(
|
||||
|
||||
snapshot_b = _read_intel_drm_fdinfo(target_pdev)
|
||||
if not snapshot_b or elapsed_ns <= 0:
|
||||
logger.warning(
|
||||
"Unable to collect Intel GPU stats: second DRM fdinfo sample was empty"
|
||||
)
|
||||
return None
|
||||
|
||||
def _new_engine_pct() -> dict[str, float]:
|
||||
@ -464,6 +472,10 @@ def get_intel_gpu_stats(
|
||||
pid_pct[data_b["pid"]] = pid_pct.get(data_b["pid"], 0.0) + client_total
|
||||
|
||||
if not per_pdev_engine_pct:
|
||||
logger.warning(
|
||||
"Unable to collect Intel GPU stats: no per-engine counters available "
|
||||
"(i915 requires kernel >= 5.19)"
|
||||
)
|
||||
return None
|
||||
|
||||
names = intel_gpu_name_resolver.get_names()
|
||||
|
||||
181
web/e2e/specs/clone-camera.spec.ts
Normal file
181
web/e2e/specs/clone-camera.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
18
web/package-lock.json
generated
18
web/package-lock.json
generated
@ -49,7 +49,7 @@
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"i18next": "^24.2.0",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"i18next-http-backend": "^3.0.5",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"immer": "^10.1.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
@ -7013,12 +7013,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
|
||||
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
|
||||
"integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.6.12"
|
||||
"node-fetch": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
@ -8876,12 +8876,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-http-backend": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.1.tgz",
|
||||
"integrity": "sha512-XT2lYSkbAtDE55c6m7CtKxxrsfuRQO3rUfHzj8ZyRtY9CkIX3aRGwXGTkUhpGWce+J8n7sfu3J0f2wTzo7Lw0A==",
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.5.tgz",
|
||||
"integrity": "sha512-QaWHnsxieEDcqKe+vo/RFqpiIFRi/KBqlOSPcUlvinBaISCeiTRCbtrazHAjtHtsLC66oDsROAH8frWkQzfMMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-fetch": "4.0.0"
|
||||
"cross-fetch": "4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-resources-for-ts": {
|
||||
|
||||
@ -63,7 +63,7 @@
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"i18next": "^24.2.0",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"i18next-http-backend": "^3.0.5",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"immer": "^10.1.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"en": "English (English)",
|
||||
"es": "Español (Spanish)",
|
||||
"zhCN": "简体中文 (Simplified Chinese)",
|
||||
"zhHant": "繁體中文 (Traditional Chinese)",
|
||||
"hi": "हिन्दी (Hindi)",
|
||||
"fr": "Français (French)",
|
||||
"ar": "العربية (Arabic)",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
@ -1405,6 +1496,17 @@
|
||||
"namePlaceholder": "e.g., Wife's Car",
|
||||
"platePlaceholder": "Plate number or regex"
|
||||
},
|
||||
"liveStreams": {
|
||||
"streamNameLabel": "Stream name",
|
||||
"streamNamePlaceholder": "e.g., Main HD Stream",
|
||||
"go2rtcStreamLabel": "go2rtc stream",
|
||||
"go2rtcStreamPlaceholder": "Select a go2rtc stream",
|
||||
"go2rtcStreamSearch": "Search or enter a stream name…",
|
||||
"noGo2rtcStreams": "No go2rtc streams configured",
|
||||
"availableStreams": "Available streams",
|
||||
"useCustom": "Use \"{{value}}\"",
|
||||
"addStream": "Add stream"
|
||||
},
|
||||
"timezone": {
|
||||
"defaultOption": "Use browser timezone"
|
||||
},
|
||||
@ -1577,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": {
|
||||
@ -1666,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",
|
||||
@ -1743,12 +1856,6 @@
|
||||
"12hour": "12 hour",
|
||||
"24hour": "24 hour"
|
||||
},
|
||||
"TimeOrDateStyle": {
|
||||
"full": "Full",
|
||||
"long": "Long",
|
||||
"medium": "Medium",
|
||||
"short": "Short"
|
||||
},
|
||||
"unitSystem": {
|
||||
"metric": "Metric",
|
||||
"imperial": "Imperial"
|
||||
@ -1831,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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -4,17 +4,26 @@ const live: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/live",
|
||||
restartRequired: [],
|
||||
fieldOrder: ["stream_name", "height", "quality"],
|
||||
fieldOrder: ["streams", "height", "quality"],
|
||||
fieldGroups: {},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: ["height", "quality"],
|
||||
},
|
||||
global: {
|
||||
restartRequired: ["stream_name", "height", "quality"],
|
||||
restartRequired: ["streams", "height", "quality"],
|
||||
hiddenFields: ["streams"],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: ["height", "quality"],
|
||||
uiSchema: {
|
||||
streams: {
|
||||
"ui:field": "LiveStreamsField",
|
||||
"ui:options": {
|
||||
label: false,
|
||||
suppressDescription: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
346
web/src/components/config-form/theme/fields/LiveStreamsField.tsx
Normal file
346
web/src/components/config-form/theme/fields/LiveStreamsField.tsx
Normal file
@ -0,0 +1,346 @@
|
||||
import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check, ChevronsUpDown, Plus } from "lucide-react";
|
||||
import { LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import get from "lodash/get";
|
||||
import { isSubtreeModified } from "../utils";
|
||||
|
||||
type LiveStreamsData = Record<string, string>;
|
||||
|
||||
type StreamValueComboboxProps = {
|
||||
id: string;
|
||||
value: string;
|
||||
options: string[];
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
onChange: (next: string) => void;
|
||||
};
|
||||
|
||||
function StreamValueCombobox({
|
||||
id,
|
||||
value,
|
||||
options,
|
||||
disabled,
|
||||
readonly,
|
||||
onChange,
|
||||
}: StreamValueComboboxProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
const trimmedSearch = searchValue.trim();
|
||||
const matchesOption = useMemo(
|
||||
() => options.some((o) => o.toLowerCase() === trimmedSearch.toLowerCase()),
|
||||
[options, trimmedSearch],
|
||||
);
|
||||
const showCustomOption = trimmedSearch.length > 0 && !matchesOption;
|
||||
|
||||
const commit = (next: string) => {
|
||||
onChange(next);
|
||||
setSearchValue("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const placeholder = t("configForm.liveStreams.go2rtcStreamPlaceholder", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const searchPlaceholder = t("configForm.liveStreams.go2rtcStreamSearch", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const noStreams = t("configForm.liveStreams.noGo2rtcStreams", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const availableHeading = t("configForm.liveStreams.availableStreams", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
|
||||
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(
|
||||
"w-full justify-between font-normal",
|
||||
!value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{value || placeholder}</span>
|
||||
<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={searchPlaceholder}
|
||||
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.liveStreams.useCustom", {
|
||||
ns: "views/settings",
|
||||
value: trimmedSearch,
|
||||
})}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
{options.length > 0 ? (
|
||||
<CommandGroup heading={availableHeading}>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option}
|
||||
value={option}
|
||||
onSelect={() => commit(option)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === option ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{option}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : !showCustomOption ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{noStreams}
|
||||
</div>
|
||||
) : null}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function LiveStreamsField(props: FieldProps) {
|
||||
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
|
||||
const configNamespace =
|
||||
formContext?.i18nNamespace ??
|
||||
(formContext?.level === "camera" ? "config/cameras" : "config/global");
|
||||
const { t: fallbackT } = useTranslation(["common", configNamespace]);
|
||||
const t = formContext?.t ?? fallbackT;
|
||||
|
||||
const data: LiveStreamsData = useMemo(() => {
|
||||
if (!formData || typeof formData !== "object" || Array.isArray(formData)) {
|
||||
return {};
|
||||
}
|
||||
return formData as LiveStreamsData;
|
||||
}, [formData]);
|
||||
|
||||
const entries = useMemo(() => Object.entries(data), [data]);
|
||||
|
||||
const id = idSchema?.$id ?? props.name;
|
||||
const sectionPrefix = formContext?.sectionI18nPrefix;
|
||||
|
||||
const title =
|
||||
t(`${sectionPrefix}.${id}.label`) ?? (schema as RJSFSchema).title;
|
||||
const description =
|
||||
t(`${sectionPrefix}.${id}.description`) ??
|
||||
(schema as RJSFSchema).description;
|
||||
|
||||
const go2rtcStreamNames = useMemo<string[]>(() => {
|
||||
const streams = formContext?.fullConfig?.go2rtc?.streams;
|
||||
if (!streams || typeof streams !== "object") return [];
|
||||
return Object.keys(streams).sort();
|
||||
}, [formContext?.fullConfig?.go2rtc?.streams]);
|
||||
|
||||
const emptyPath = useMemo(() => [] as FieldPathList, []);
|
||||
const fieldPath =
|
||||
(props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ??
|
||||
emptyPath;
|
||||
|
||||
const isModified = useMemo(() => {
|
||||
const baselineRoot = formContext?.baselineFormData;
|
||||
const baselineValue = baselineRoot
|
||||
? get(baselineRoot, fieldPath)
|
||||
: undefined;
|
||||
return isSubtreeModified(
|
||||
data,
|
||||
baselineValue,
|
||||
formContext?.overrides,
|
||||
fieldPath,
|
||||
formContext?.formData,
|
||||
);
|
||||
}, [fieldPath, formContext, data]);
|
||||
|
||||
const handleAddEntry = useCallback(() => {
|
||||
const next = { ...data, "": "" };
|
||||
onChange(next, fieldPath);
|
||||
}, [data, fieldPath, onChange]);
|
||||
|
||||
const handleRemoveEntry = useCallback(
|
||||
(key: string) => {
|
||||
const next = { ...data };
|
||||
delete next[key];
|
||||
onChange(next, fieldPath);
|
||||
},
|
||||
[data, fieldPath, onChange],
|
||||
);
|
||||
|
||||
const handleRenameKey = useCallback(
|
||||
(oldKey: string, newKey: string) => {
|
||||
if (oldKey === newKey) return;
|
||||
const next: LiveStreamsData = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (k === oldKey) {
|
||||
next[newKey] = v;
|
||||
} else {
|
||||
next[k] = v;
|
||||
}
|
||||
}
|
||||
onChange(next, fieldPath);
|
||||
},
|
||||
[data, fieldPath, onChange],
|
||||
);
|
||||
|
||||
const handleUpdateValue = useCallback(
|
||||
(key: string, value: string) => {
|
||||
const next = { ...data, [key]: value };
|
||||
onChange(next, fieldPath);
|
||||
},
|
||||
[data, fieldPath, onChange],
|
||||
);
|
||||
|
||||
const baseId = idSchema?.$id || "live_streams";
|
||||
const deleteLabel = t("button.delete", {
|
||||
ns: "common",
|
||||
defaultValue: "Delete",
|
||||
});
|
||||
const streamNameLabel = t("configForm.liveStreams.streamNameLabel", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const streamNamePlaceholder = t(
|
||||
"configForm.liveStreams.streamNamePlaceholder",
|
||||
{ ns: "views/settings" },
|
||||
);
|
||||
const go2rtcStreamLabel = t("configForm.liveStreams.go2rtcStreamLabel", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const addStreamLabel = t("configForm.liveStreams.addStream", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className={cn("text-sm", isModified && "text-unsaved")}>
|
||||
{title}
|
||||
</CardTitle>
|
||||
{description && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-0">
|
||||
{entries.map(([key, value], entryIndex) => {
|
||||
const entryId = `${baseId}-${entryIndex}`;
|
||||
return (
|
||||
<div
|
||||
key={entryIndex}
|
||||
className="grid grid-cols-12 items-end gap-2 rounded-md border p-3"
|
||||
>
|
||||
<div className="col-span-12 space-y-2 md:col-span-5">
|
||||
<Label htmlFor={`${entryId}-key`}>{streamNameLabel}</Label>
|
||||
<Input
|
||||
id={`${entryId}-key`}
|
||||
defaultValue={key}
|
||||
placeholder={streamNamePlaceholder}
|
||||
disabled={disabled || readonly}
|
||||
onBlur={(e) => handleRenameKey(key, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-10 space-y-2 md:col-span-6">
|
||||
<Label htmlFor={`${entryId}-value`}>{go2rtcStreamLabel}</Label>
|
||||
<StreamValueCombobox
|
||||
id={`${entryId}-value`}
|
||||
value={value}
|
||||
options={go2rtcStreamNames}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
onChange={(next) => handleUpdateValue(key, next)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 flex justify-end md:col-span-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveEntry(key)}
|
||||
disabled={disabled || readonly}
|
||||
aria-label={deleteLabel}
|
||||
title={deleteLabel}
|
||||
className="shrink-0"
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddEntry}
|
||||
disabled={disabled || readonly}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{addStreamLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default LiveStreamsField;
|
||||
@ -2,3 +2,4 @@
|
||||
export { LayoutGridField } from "./LayoutGridField";
|
||||
export { DetectorHardwareField } from "./DetectorHardwareField";
|
||||
export { ReplaceRulesField } from "./ReplaceRulesField";
|
||||
export { LiveStreamsField } from "./LiveStreamsField";
|
||||
|
||||
@ -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";
|
||||
@ -51,6 +53,7 @@ import { ReplaceRulesField } from "./fields/ReplaceRulesField";
|
||||
import { CameraInputsField } from "./fields/CameraInputsField";
|
||||
import { DictAsYamlField } from "./fields/DictAsYamlField";
|
||||
import { KnownPlatesField } from "./fields/KnownPlatesField";
|
||||
import { LiveStreamsField } from "./fields/LiveStreamsField";
|
||||
|
||||
export interface FrigateTheme {
|
||||
widgets: RegistryWidgetsType;
|
||||
@ -89,6 +92,8 @@ export const frigateTheme: FrigateTheme = {
|
||||
semanticSearchModel: SemanticSearchModelWidget,
|
||||
semanticSearchModelSize: SemanticSearchModelSizeWidget,
|
||||
onvifProfile: OnvifProfileWidget,
|
||||
ptzPresets: PTZPresetsWidget,
|
||||
defaultRole: DefaultRoleWidget,
|
||||
},
|
||||
templates: {
|
||||
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,
|
||||
@ -109,5 +114,6 @@ export const frigateTheme: FrigateTheme = {
|
||||
CameraInputsField: CameraInputsField,
|
||||
DictAsYamlField: DictAsYamlField,
|
||||
KnownPlatesField: KnownPlatesField,
|
||||
LiveStreamsField: LiveStreamsField,
|
||||
},
|
||||
};
|
||||
|
||||
@ -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 || {}),
|
||||
}),
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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" })}
|
||||
|
||||
@ -77,7 +77,7 @@ export default function TextEntry({
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="text-md w-full"
|
||||
className="w-full"
|
||||
placeholder={placeholder}
|
||||
type="text"
|
||||
/>
|
||||
|
||||
@ -109,6 +109,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
"nb-NO": "nb",
|
||||
"yue-Hant": "yue",
|
||||
"zh-CN": "zhCN",
|
||||
"zh-Hant": "zhHant",
|
||||
"pt-BR": "ptBR",
|
||||
};
|
||||
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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" })}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user