Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
33c67ed34b
Merge 96988e1905 into 8073174c20 2026-06-01 13:37:14 -05:00
34 changed files with 114 additions and 486 deletions

View File

@ -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="8.0"
ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:7.0:5.0"
ENV DEFAULT_FFMPEG_VERSION="7.0"
ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}: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 \

View File

@ -52,13 +52,9 @@ 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-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linux64-gpl-7.0.tar.xz"
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"
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
@ -68,13 +64,9 @@ 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-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linuxarm64-gpl-7.0.tar.xz"
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"
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

View File

@ -5,7 +5,11 @@ from typing import Any
from ruamel.yaml import YAML
sys.path.insert(0, "/opt/frigate")
from frigate.util.config import find_config_file, resolve_ffmpeg_path
from frigate.const import (
DEFAULT_FFMPEG_VERSION,
INCLUDED_FFMPEG_VERSIONS,
)
from frigate.util.config import find_config_file
sys.path.remove("/opt/frigate")
@ -25,4 +29,9 @@ except FileNotFoundError:
config: dict[str, Any] = {}
path = config.get("ffmpeg", {}).get("path", "default")
print(resolve_ffmpeg_path(path, "ffmpeg"))
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")

View File

@ -11,10 +11,12 @@ 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, resolve_ffmpeg_path
from frigate.util.config import find_config_file
from frigate.util.services import is_restricted_go2rtc_source
sys.path.remove("/opt/frigate")
@ -79,7 +81,12 @@ if go2rtc_config.get("rtsp", {}).get("password") is not None:
# ensure ffmpeg path is set correctly
path = config.get("ffmpeg", {}).get("path", "default")
ffmpeg_path = resolve_ffmpeg_path(path, "ffmpeg")
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"
if go2rtc_config.get("ffmpeg") is None:
go2rtc_config["ffmpeg"] = {"bin": ffmpeg_path}

View File

@ -179,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 incompatible. |
| Sunba 405-D20X | ✅ | ❌ | Incomplete ONVIF support reported on original, and 4k models. All models are suspected incompatable. |
| 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. |

View File

@ -660,7 +660,7 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl
#### RF-DETR
[RF-DETR](https://github.com/roboflow/rf-detr) is a DETR based model. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-rf-detr-model) for more information 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 informatoin on downloading the RF-DETR model for use in Frigate.
:::warning

View File

@ -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 `8.0` or `5.0` to specify one of the included versions
# can also be set to `7.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)

View File

@ -749,7 +749,7 @@ Failure to remap port 5000 on the host will result in the WebUI and all API endp
:::
Docker containers on macOS can be orchestrated by either [Docker Desktop](https://docs.docker.com/desktop/setup/install/mac-install/) or [OrbStack](https://orbstack.dev) (native Swift app). The difference in inference speeds is negligible, 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 negligable, 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 different host port like 5001 or any other port with no conflicts
# If exposing on macOS map to a diffent host port like 5001 or any orher port with no conflicts
# - "5001:5000" # Internal unauthenticated access. Expose carefully.
- "8554:8554" # RTSP feeds
extra_hosts:

View File

@ -299,36 +299,22 @@ async def no_recordings(
.iterator()
)
# Convert recordings to list of (start, end) tuples, ordered by start_time
# Convert recordings to list of (start, end) tuples
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)
# 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
# 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
)
if not has_recording:
# This segment has no recordings

View File

@ -605,10 +605,9 @@ def motion_activity(
if not filtered:
return JSONResponse(content=[])
camera_list = list(filtered)
clauses.append((Recordings.camera << camera_list))
else:
camera_list = list(allowed_cameras)
clauses.append((Recordings.camera << camera_list))
clauses.append((Recordings.camera << allowed_cameras))
data: list[Recordings] = (
Recordings.select(
@ -636,12 +635,14 @@ def motion_activity(
df.set_index(["start_time"], inplace=True)
# normalize data
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)))
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)))
df = motion.join(cameras)
length = df.shape[0]
@ -657,11 +658,6 @@ 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")

View File

@ -3,7 +3,7 @@ from typing import Union
from pydantic import Field, field_validator
from frigate.util.config import resolve_ffmpeg_path
from frigate.const import DEFAULT_FFMPEG_VERSION, INCLUDED_FFMPEG_VERSIONS
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 "8.0").',
description='Path to the FFmpeg binary to use or a version alias ("5.0" or "7.0").',
)
global_args: Union[str, list[str]] = Field(
default=FFMPEG_GLOBAL_ARGS_DEFAULT,
@ -90,11 +90,21 @@ class FfmpegConfig(FrigateBaseModel):
@property
def ffmpeg_path(self) -> str:
return resolve_ffmpeg_path(self.path, "ffmpeg")
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"
@property
def ffprobe_path(self) -> str:
return resolve_ffmpeg_path(self.path, "ffprobe")
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"
class CameraRoleEnum(str, Enum):

View File

@ -16,8 +16,3 @@ 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).",
)

View File

@ -5,7 +5,7 @@ import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Optional
from typing import Optional
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
@ -34,45 +34,6 @@ 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"
@ -349,15 +310,6 @@ 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:

View File

@ -465,6 +465,16 @@ 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",
@ -476,8 +486,10 @@ PRESETS_RECORD_OUTPUT = {
"1",
"-strftime",
"1",
"-c",
"-c:v",
"copy",
"-c:a",
"aac",
],
"preset-record-mjpeg": [
"-f",

View File

@ -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/8.0/bin/ffmpeg", # hardcode path for exports thumbnail due to missing libwebp support
"/usr/lib/ffmpeg/7.0/bin/ffmpeg", # hardcode path for exports thumbnail due to missing libwebp support
"-hide_banner",
"-loglevel",
"warning",

View File

@ -403,75 +403,3 @@ 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() == []

View File

@ -610,16 +610,19 @@ class TestHttpReview(BaseTestHttp):
response = client.get("/review/activity/motion", params=params)
assert response.status_code == 200
response_json = response.json()
# Only buckets with an actual recording are returned. Empty
# gap-fill buckets between the two recordings are dropped.
assert len(response_json) == 2
assert len(response_json) == 61
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[1],
response_json[len(response_json) - 1],
)
####################################################################################################################

View File

@ -1,6 +1,5 @@
"""Tests for the profiles system."""
import copy
import json
import os
import unittest
@ -747,36 +746,6 @@ class TestProfileManager(unittest.TestCase):
manager.activate_profile(None)
dispatcher.clear_runtime_state.assert_called_once_with()
@patch.object(ProfileManager, "_persist_active_profile")
def test_profile_change_republishes_switch_states(self, mock_persist):
"""Profile changes republish MQTT switch states so HA stays in sync.
Regression: activating/deactivating a profile updated the in-memory
config (and Frigate's behavior) but left the retained MQTT state
topics stale, so external integrations like Home Assistant kept
showing the pre-profile toggle position.
"""
config_data = copy.deepcopy(self.config_data)
config_data["cameras"]["front"]["profiles"]["disarmed"]["review"] = {
"alerts": {"enabled": False},
}
config = FrigateConfig(**config_data)
dispatcher = MagicMock()
manager = ProfileManager(config, self.mock_updater, dispatcher)
# Activating disarmed turns alerts off -> MQTT state must follow
manager.activate_profile("disarmed")
dispatcher.publish.assert_any_call(
"front/review_alerts/state", "OFF", retain=True
)
# Deactivating restores the base (alerts on) -> MQTT state must follow
dispatcher.publish.reset_mock()
manager.activate_profile(None)
dispatcher.publish.assert_any_call(
"front/review_alerts/state", "ON", retain=True
)
@patch.object(ProfileManager, "_persist_active_profile")
def test_startup_replay_does_not_clear_runtime_state(self, mock_persist):
"""Startup callers pass clear_runtime_overrides=False to preserve state."""

View File

@ -394,7 +394,7 @@ def collect_state_classification_examples(
# Step 3: Extract keyframes from recordings with crops applied
keyframes = _extract_keyframes(
"/usr/lib/ffmpeg/8.0/bin/ffmpeg", timestamps, temp_dir, cameras
"/usr/lib/ffmpeg/7.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/8.0")
config = FfmpegConfig(path="/usr/lib/ffmpeg/7.0")
image_data = get_image_from_recording(
config,
recording.path,

View File

@ -8,13 +8,7 @@ from typing import Any, Optional, Union
from ruamel.yaml import YAML
from frigate.const import (
CONFIG_DIR,
DEFAULT_FFMPEG_VERSION,
EXPORT_DIR,
INCLUDED_FFMPEG_VERSIONS,
REDACTED_CREDENTIAL_SENTINEL,
)
from frigate.const import CONFIG_DIR, EXPORT_DIR, REDACTED_CREDENTIAL_SENTINEL
from frigate.util.builtin import deep_merge
from frigate.util.services import get_video_properties
@ -24,26 +18,6 @@ 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.

View File

@ -862,10 +862,6 @@
"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": {

View File

@ -1546,10 +1546,6 @@
"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": {

View File

@ -492,16 +492,12 @@
"details": {
"edit": "Edit camera details",
"title": "Edit Camera Details",
"description": "Update the display name, external URL, and visibility used for this camera throughout the Frigate UI.",
"description": "Update the display name and external URL 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).",
"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."
"webuiUrlInvalid": "Must be a valid URL (e.g., https://example.com)."
}
},
"cameraConfig": {

View File

@ -144,13 +144,11 @@ export default function ReviewFilterGroup({
const filterValues = useMemo(
() => ({
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),
),
cameras: allowedCameras.sort(
(a, b) =>
(config?.cameras[a]?.ui?.order ?? 0) -
(config?.cameras[b]?.ui?.order ?? 0),
),
labels: Object.values(allLabels || {}),
zones: Object.values(allZones || {}),
}),

View File

@ -54,7 +54,6 @@ type HlsVideoPlayerProps = {
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined;
getSnapshotUrl?: (playTime: number) => string | undefined;
onSnapshot?: (playTime: number) => Promise<void> | void;
toggleFullscreen?: () => void;
onError?: (error: RecordingPlayerError) => void;
isDetailMode?: boolean;
@ -81,7 +80,6 @@ export default function HlsVideoPlayer({
setFullResolution,
onUploadFrame,
getSnapshotUrl,
onSnapshot,
toggleFullscreen,
onError,
isDetailMode = false,
@ -234,7 +232,6 @@ export default function HlsVideoPlayer({
const [mobileCtrlTimeout, setMobileCtrlTimeout] = useState<NodeJS.Timeout>();
const [controls, setControls] = useState(isMobile);
const [controlsOpen, setControlsOpen] = useState(false);
const [isSnapshotLoading, setIsSnapshotLoading] = useState(false);
const [zoomScale, setZoomScale] = useState(1.0);
const [videoDimensions, setVideoDimensions] = useState<{
width: number;
@ -290,21 +287,6 @@ export default function HlsVideoPlayer({
return currentTime + inpointOffset;
}, [videoRef, inpointOffset]);
const handleSnapshot = useCallback(async () => {
const frameTime = getVideoTime();
if (!frameTime || !onSnapshot) {
return;
}
setIsSnapshotLoading(true);
try {
await onSnapshot(frameTime);
} finally {
setIsSnapshotLoading(false);
}
}, [getVideoTime, onSnapshot]);
return (
<TransformWrapper
minScale={1.0}
@ -328,7 +310,6 @@ export default function HlsVideoPlayer({
seek: true,
playbackRate: true,
plusUpload: isAdmin && config?.plus?.enabled == true,
snapshot: !!onSnapshot,
fullscreen: supportsFullscreen,
}}
setControlsOpen={setControlsOpen}
@ -376,8 +357,6 @@ export default function HlsVideoPlayer({
}
}
}}
onSnapshot={onSnapshot ? handleSnapshot : undefined}
snapshotLoading={isSnapshotLoading}
fullscreen={fullscreen}
toggleFullscreen={toggleFullscreen}
containerRef={containerRef}

View File

@ -34,7 +34,6 @@ import {
} from "../ui/alert-dialog";
import { cn } from "@/lib/utils";
import { FaCompress, FaExpand } from "react-icons/fa";
import { TbCameraDown } from "react-icons/tb";
import { useTranslation } from "react-i18next";
type VideoControls = {
@ -42,7 +41,6 @@ type VideoControls = {
seek?: boolean;
playbackRate?: boolean;
plusUpload?: boolean;
snapshot?: boolean;
fullscreen?: boolean;
};
@ -51,7 +49,6 @@ const CONTROLS_DEFAULT: VideoControls = {
seek: true,
playbackRate: true,
plusUpload: false,
snapshot: false,
fullscreen: false,
};
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
@ -76,8 +73,6 @@ type VideoControlsProps = {
onSetPlaybackRate: (rate: number) => void;
onUploadFrame?: () => void;
getSnapshotUrl?: () => string | undefined;
onSnapshot?: () => void;
snapshotLoading?: boolean;
toggleFullscreen?: () => void;
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
};
@ -100,8 +95,6 @@ export default function VideoControls({
onSetPlaybackRate,
onUploadFrame,
getSnapshotUrl,
onSnapshot,
snapshotLoading = false,
toggleFullscreen,
containerRef,
}: VideoControlsProps) {
@ -302,25 +295,6 @@ export default function VideoControls({
fullscreen={fullscreen}
/>
)}
{features.snapshot && onSnapshot && (
<TbCameraDown
className={cn(
"size-5",
snapshotLoading
? "cursor-not-allowed opacity-50"
: "cursor-pointer",
)}
onClick={(e: React.MouseEvent<SVGElement>) => {
e.stopPropagation();
if (snapshotLoading) {
return;
}
onSnapshot();
}}
/>
)}
{features.fullscreen && toggleFullscreen && (
<div className="cursor-pointer" onClick={toggleFullscreen}>
{fullscreen ? <FaCompress /> : <FaExpand />}

View File

@ -19,18 +19,12 @@ import { TimeRange } from "@/types/timeline";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { VideoResolutionType } from "@/types/live";
import axios from "axios";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import {
calculateInpointOffset,
calculateSeekPosition,
} from "@/utils/videoUtil";
import {
downloadSnapshot,
generateSnapshotFilename,
grabVideoSnapshot,
} from "@/utils/snapshotUtil";
import { isFirefox } from "react-device-detect";
/**
@ -74,7 +68,7 @@ export default function DynamicVideoPlayer({
containerRef,
transformedOverlay,
}: DynamicVideoPlayerProps) {
const { t } = useTranslation(["components/player", "views/live"]);
const { t } = useTranslation(["components/player"]);
const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config");
@ -202,34 +196,6 @@ export default function DynamicVideoPlayer({
[apiHost, camera, controller],
);
const onDownloadSnapshot = useCallback(
async (playTime: number) => {
if (!controller || !playerRef.current) {
return;
}
// map the player time back to the timeline timestamp so the filename
// reflects the moment being viewed rather than the current time
const frameTime = controller.getProgress(playTime);
const result = await grabVideoSnapshot(playerRef.current);
if (result.success) {
downloadSnapshot(
result.data.dataUrl,
generateSnapshotFilename(camera, frameTime),
);
toast.success(t("snapshot.downloadStarted", { ns: "views/live" }), {
position: "top-center",
});
} else {
toast.error(t("snapshot.captureFailed", { ns: "views/live" }), {
position: "top-center",
});
}
},
[camera, controller, t],
);
// state of playback player
const recordingParams = useMemo(
@ -362,7 +328,6 @@ export default function DynamicVideoPlayer({
setFullResolution={setFullResolution}
onUploadFrame={onUploadFrameToPlus}
getSnapshotUrl={getSnapshotUrlForPlus}
onSnapshot={onDownloadSnapshot}
toggleFullscreen={toggleFullscreen}
onError={(error) => {
if (error == "stalled" && !isScrubbing) {

View File

@ -12,7 +12,7 @@ export const JINA_EMBEDDING_MODELS = ["jinav1", "jinav2"] as const;
export const REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__";
export const ANNOTATION_OFFSET_MIN = -10000;
export const ANNOTATION_OFFSET_MAX = 10000;
export const ANNOTATION_OFFSET_MAX = 5000;
export const ANNOTATION_OFFSET_STEP = 50;
export const supportedLanguageKeys = [

View File

@ -70,15 +70,13 @@ export default function Events() {
undefined,
);
const reviewCameras = useMemo(() => {
const motionSearchCameras = useMemo(() => {
if (!config?.cameras) {
return [] as string[];
}
return Object.keys(config.cameras).filter(
(cam) =>
allowedCameras.includes(cam) &&
config.cameras[cam]?.ui?.review !== false,
return Object.keys(config.cameras).filter((cam) =>
allowedCameras.includes(cam),
);
}, [allowedCameras, config?.cameras]);
@ -87,12 +85,12 @@ export default function Events() {
return null;
}
if (reviewCameras.includes(motionSearchCamera)) {
if (motionSearchCameras.includes(motionSearchCamera)) {
return motionSearchCamera;
}
return reviewCameras[0] ?? null;
}, [motionSearchCamera, reviewCameras]);
return motionSearchCameras[0] ?? null;
}, [motionSearchCamera, motionSearchCameras]);
const motionSearchTimeRange = useMemo(() => {
if (motionSearchDay) {
@ -359,10 +357,6 @@ export default function Events() {
const motion: ReviewSegment[] = [];
reviews?.forEach((segment) => {
if (config?.cameras[segment.camera]?.ui?.review === false) {
return;
}
all.push(segment);
switch (segment.severity) {
@ -384,7 +378,7 @@ export default function Events() {
detection: detections,
significant_motion: motion,
};
}, [reviews, config?.cameras]);
}, [reviews]);
// update review items in place when a review segment ends
const reviewUpdate = useFrigateReviews();
@ -641,7 +635,7 @@ export default function Events() {
}
setStartTime(recording.startTime);
const allCameras = reviewFilter?.cameras ?? reviewCameras;
const allCameras = reviewFilter?.cameras ?? allowedCameras;
return {
camera: recording.camera,
@ -686,7 +680,7 @@ export default function Events() {
) : (
<MotionSearchView
config={config}
cameras={reviewCameras}
cameras={motionSearchCameras}
selectedCamera={selectedMotionSearchCamera}
onCameraSelect={handleMotionSearchCameraSelect}
cameraLocked={true}

View File

@ -5,7 +5,6 @@ export interface UiConfig {
timezone?: string;
time_format?: "browser" | "12hour" | "24hour";
dashboard: boolean;
review: boolean;
order: number;
unit_system?: "metric" | "imperial";
}

View File

@ -97,27 +97,17 @@ export function downloadSnapshot(dataUrl: string, filename: string): void {
}
}
export function generateSnapshotFilename(
cameraName: string,
timestampSeconds?: number,
): string {
// Live snapshots use the current time, while History snapshots pass the
// playback timestamp so the filename matches the moment being viewed.
const date =
typeof timestampSeconds === "number" && Number.isFinite(timestampSeconds)
? new Date(timestampSeconds * 1000)
: new Date();
const timestamp = date.toISOString().replace(/[:.]/g, "-").slice(0, -5);
export function generateSnapshotFilename(cameraName: string): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5);
return `${cameraName}_snapshot_${timestamp}.jpg`;
}
export async function grabVideoSnapshot(
targetVideo?: HTMLVideoElement | null,
): Promise<SnapshotResult> {
export async function grabVideoSnapshot(): Promise<SnapshotResult> {
try {
const videoElement =
targetVideo ??
(document.querySelector("#player-container video") as HTMLVideoElement);
// Find the video element in the player
const videoElement = document.querySelector(
"#player-container video",
) as HTMLVideoElement;
if (!videoElement) {
return {

View File

@ -123,13 +123,8 @@ export function RecordingView({
const allowedCameras = useAllowedCameras();
const effectiveCameras = useMemo(
() =>
allCameras.filter(
(camera) =>
allowedCameras.includes(camera) &&
config?.cameras[camera]?.ui?.review !== false,
),
[allCameras, allowedCameras, config?.cameras],
() => allCameras.filter((camera) => allowedCameras.includes(camera)),
[allCameras, allowedCameras],
);
const [mainCamera, setMainCamera] = useState(startCamera);

View File

@ -75,7 +75,6 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
@ -705,8 +704,6 @@ type CameraDetailsEditorProps = {
type CameraDetailsFormValues = {
friendlyName: string;
webuiUrl: string;
dashboard: boolean;
review: boolean;
};
function CameraDetailsEditor({
@ -720,15 +717,11 @@ function CameraDetailsEditor({
const currentFriendlyName = config?.cameras?.[cameraName]?.friendly_name;
const currentWebuiUrl = config?.cameras?.[cameraName]?.webui_url;
const currentDashboard = config?.cameras?.[cameraName]?.ui?.dashboard ?? true;
const currentReview = config?.cameras?.[cameraName]?.ui?.review ?? true;
const formSchema = useMemo(
() =>
z.object({
friendlyName: z.string(),
dashboard: z.boolean(),
review: z.boolean(),
webuiUrl: z.string().refine(
(val) => {
const trimmed = val.trim();
@ -755,8 +748,6 @@ function CameraDetailsEditor({
defaultValues: {
friendlyName: currentFriendlyName ?? "",
webuiUrl: currentWebuiUrl ?? "",
dashboard: currentDashboard,
review: currentReview,
},
});
@ -766,18 +757,9 @@ function CameraDetailsEditor({
form.reset({
friendlyName: currentFriendlyName ?? "",
webuiUrl: currentWebuiUrl ?? "",
dashboard: currentDashboard,
review: currentReview,
});
}
}, [
open,
currentFriendlyName,
currentWebuiUrl,
currentDashboard,
currentReview,
form,
]);
}, [open, currentFriendlyName, currentWebuiUrl, form]);
const onSubmit = useCallback(
async (values: CameraDetailsFormValues) => {
@ -786,7 +768,7 @@ function CameraDetailsEditor({
// only send fields the user actually changed
const newFriendly = values.friendlyName.trim() || null;
const newWebui = values.webuiUrl.trim() || null;
const cameraUpdate: Record<string, unknown> = {};
const cameraUpdate: Record<string, string | null> = {};
if (newFriendly !== (currentFriendlyName ?? null)) {
cameraUpdate.friendly_name = newFriendly;
}
@ -794,17 +776,6 @@ function CameraDetailsEditor({
cameraUpdate.webui_url = newWebui;
}
const uiUpdate: Record<string, boolean> = {};
if (values.dashboard !== currentDashboard) {
uiUpdate.dashboard = values.dashboard;
}
if (values.review !== currentReview) {
uiUpdate.review = values.review;
}
if (Object.keys(uiUpdate).length > 0) {
cameraUpdate.ui = uiUpdate;
}
if (Object.keys(cameraUpdate).length === 0) {
setOpen(false);
return;
@ -847,8 +818,6 @@ function CameraDetailsEditor({
cameraName,
currentFriendlyName,
currentWebuiUrl,
currentDashboard,
currentReview,
isSaving,
onConfigChanged,
t,
@ -945,60 +914,6 @@ function CameraDetailsEditor({
</FormItem>
)}
/>
<FormField
control={form.control}
name="dashboard"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between gap-3">
<div className="space-y-0.5">
<FormLabel>
{t("cameraManagement.streams.details.dashboardLabel", {
ns: "views/settings",
})}
</FormLabel>
<p className="text-xs text-muted-foreground">
{t("cameraManagement.streams.details.dashboardHelp", {
ns: "views/settings",
})}
</p>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={isSaving}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="review"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between gap-3">
<div className="space-y-0.5">
<FormLabel>
{t("cameraManagement.streams.details.reviewLabel", {
ns: "views/settings",
})}
</FormLabel>
<p className="text-xs text-muted-foreground">
{t("cameraManagement.streams.details.reviewHelp", {
ns: "views/settings",
})}
</p>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={isSaving}
/>
</FormControl>
</FormItem>
)}
/>
<DialogFooter className="pt-2">
<Button
type="button"

View File

@ -53,7 +53,6 @@ export default function MasksAndZonesView({
const { data: config } = useSWR<FrigateConfig>("config");
const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]);
const [polygonsInitialized, setPolygonsInitialized] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [loadingPolygonIndex, setLoadingPolygonIndex] = useState<
number | undefined
@ -610,7 +609,6 @@ export default function MasksAndZonesView({
...globalObjectMasks,
...objectMasks,
]);
setPolygonsInitialized(true);
// Don't overwrite editingPolygons during editing layout shifts
// from switching to the edit pane can trigger a resize which
// recalculates scaledWidth/scaledHeight and would discard the
@ -678,7 +676,7 @@ export default function MasksAndZonesView({
}, [currentEditingProfile]);
useSearchEffect("object_mask", (coordinates: string) => {
if (!scaledWidth || !scaledHeight || isLoading || !polygonsInitialized) {
if (!scaledWidth || !scaledHeight || isLoading) {
return false;
}
// convert box points string to points array