Compare commits

..

No commits in common. "5d2a7254282930581b123d6a1fb8df39beca1ae6" and "c3628a339d7feadc2321c53952e9e8be632ae9c2" have entirely different histories.

25 changed files with 37 additions and 745 deletions

View File

@ -9,7 +9,6 @@ from typing import Any
from ruamel.yaml import YAML
sys.path.insert(0, "/opt/frigate")
from frigate.config.env import substitute_frigate_vars
from frigate.const import (
BIRDSEYE_PIPE,
DEFAULT_FFMPEG_VERSION,
@ -48,6 +47,14 @@ ALLOW_ARBITRARY_EXEC = allow_arbitrary_exec is not None and str(
allow_arbitrary_exec
).lower() in ("true", "1", "yes")
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
# read docker secret files as env vars too
if os.path.isdir("/run/secrets"):
for secret_file in os.listdir("/run/secrets"):
if secret_file.startswith("FRIGATE_"):
FRIGATE_ENV_VARS[secret_file] = (
Path(os.path.join("/run/secrets", secret_file)).read_text().strip()
)
config_file = find_config_file()
@ -96,13 +103,13 @@ if go2rtc_config["webrtc"].get("candidates") is None:
go2rtc_config["webrtc"]["candidates"] = default_candidates
if go2rtc_config.get("rtsp", {}).get("username") is not None:
go2rtc_config["rtsp"]["username"] = substitute_frigate_vars(
go2rtc_config["rtsp"]["username"]
go2rtc_config["rtsp"]["username"] = go2rtc_config["rtsp"]["username"].format(
**FRIGATE_ENV_VARS
)
if go2rtc_config.get("rtsp", {}).get("password") is not None:
go2rtc_config["rtsp"]["password"] = substitute_frigate_vars(
go2rtc_config["rtsp"]["password"]
go2rtc_config["rtsp"]["password"] = go2rtc_config["rtsp"]["password"].format(
**FRIGATE_ENV_VARS
)
# ensure ffmpeg path is set correctly
@ -138,7 +145,7 @@ for name in list(go2rtc_config.get("streams", {})):
if isinstance(stream, str):
try:
formatted_stream = substitute_frigate_vars(stream)
formatted_stream = stream.format(**FRIGATE_ENV_VARS)
if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream):
print(
f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec) which is disabled by default for security. "
@ -157,7 +164,7 @@ for name in list(go2rtc_config.get("streams", {})):
filtered_streams = []
for i, stream_item in enumerate(stream):
try:
formatted_stream = substitute_frigate_vars(stream_item)
formatted_stream = stream_item.format(**FRIGATE_ENV_VARS)
if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream):
print(
f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec) which is disabled by default for security. "

View File

@ -158,7 +158,7 @@ Enable debug logs for classification models by adding `frigate.data_processing.r
Navigate to <NavPath path="Settings > System > Logging" />.
- Set **Logging level** to `debug`
- Set **Per-process log level > `frigate.data_processing.real_time.custom_classification`** to `debug` for verbose classification logging
- Set **Per-process log level > Frigate.Data Processing.Real Time.Custom Classification** to `debug` for verbose classification logging
</TabItem>
<TabItem value="yaml">

View File

@ -24,12 +24,6 @@ For object filters, any single detection below `min_score` will be ignored as a
In frame 2, the score is below the `min_score` value, so Frigate ignores it and it becomes a 0.0. The computed score is the median of the score history (padding to at least 3 values), and only when that computed score crosses the `threshold` is the object marked as a true positive. That happens in frame 4 in the example.
The **top score** is the highest computed score the tracked object has ever reached during its lifetime. Because the computed score rises and falls as new frames come in, the top score can be thought of as the peak confidence Frigate had in the object. In Frigate's UI (such as the Tracking Details pane in Explore), you may see all three values:
- **Score** — the raw detector score for that single frame.
- **Computed Score** — the median of the most recent score history at that moment. This is the value compared against `threshold`.
- **Top Score** — the highest computed score reached so far for the tracked object.
### Minimum Score
Any detection below `min_score` will be immediately thrown out and never tracked because it is considered a false positive. If `min_score` is too low then false positives may be detected and tracked which can confuse the object tracker and may lead to wasted resources. If `min_score` is too high then lower scoring true positives like objects that are further away or partially occluded may be thrown out which can also confuse the tracker and cause valid tracked objects to be lost or disjointed.

View File

@ -123,76 +123,6 @@ record:
</TabItem>
</ConfigTabs>
## Pre-capture and Post-capture
The `pre_capture` and `post_capture` settings control how many seconds of video are included before and after an alert or detection. These can be configured independently for alerts and detections, and can be set globally or overridden per camera.
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Global configuration > Recording" /> for global defaults, or <NavPath path="Settings > Camera configuration > (select camera) > Recording" /> to override for a specific camera.
| Field | Description |
| ---------------------------------------------- | ---------------------------------------------------- |
| **Alert retention > Pre-capture seconds** | Seconds of video to include before an alert event |
| **Alert retention > Post-capture seconds** | Seconds of video to include after an alert event |
| **Detection retention > Pre-capture seconds** | Seconds of video to include before a detection event |
| **Detection retention > Post-capture seconds** | Seconds of video to include after a detection event |
</TabItem>
<TabItem value="yaml">
```yaml
record:
enabled: True
alerts:
pre_capture: 5 # seconds before the alert to include
post_capture: 5 # seconds after the alert to include
detections:
pre_capture: 5 # seconds before the detection to include
post_capture: 5 # seconds after the detection to include
```
</TabItem>
</ConfigTabs>
- **Default**: 5 seconds for both pre and post capture.
- **Pre-capture maximum**: 60 seconds.
- These settings apply per review category (alerts and detections), not per object type.
### How pre/post capture interacts with retention mode
The `pre_capture` and `post_capture` values define the **time window** around a review item, but only recording segments that also match the configured **retention mode** are actually kept on disk.
- **`mode: all`** — Retains every segment within the capture window, regardless of whether motion was detected.
- **`mode: motion`** (default) — Only retains segments within the capture window that contain motion. This includes segments with active tracked objects, since object motion implies motion. Segments without any motion are discarded even if they fall within the pre/post capture range.
- **`mode: active_objects`** — Only retains segments within the capture window where tracked objects were actively moving. Segments with general motion but no active objects are discarded.
This means that with the default `motion` mode, you may see less footage than the configured pre/post capture duration if parts of the capture window had no motion.
To guarantee the full pre/post capture duration is always retained:
```yaml
record:
enabled: True
alerts:
pre_capture: 10
post_capture: 10
retain:
days: 30
mode: all # retains all segments within the capture window
```
:::note
Because recording segments are written in 10 second chunks, pre-capture timing depends on segment boundaries. The actual pre-capture footage may be slightly shorter or longer than the exact configured value.
:::
### Where to view pre/post capture footage
Pre and post capture footage is included in the **recording timeline**, visible in the History view. Note that pre/post capture settings only affect which recording segments are **retained on disk** — they do not change the start and end points shown in the UI. The History view will still center on the review item's actual time range, but you can scrub backward and forward through the retained pre/post capture footage on the timeline. The Explore view shows object-specific clips that are trimmed to when the tracked object was actually visible, so pre/post capture time will not be reflected there.
## Will Frigate delete old recordings if my storage runs out?
As of Frigate 0.12 if there is less than an hour left of storage, the oldest 2 hours of recordings will be deleted.

View File

@ -694,9 +694,6 @@ def config_set(request: Request, body: AppConfigSetBody):
if request.app.stats_emitter is not None:
request.app.stats_emitter.config = config
if request.app.dispatcher is not None:
request.app.dispatcher.config = config
if body.update_topic:
if body.update_topic.startswith("config/cameras/"):
_, _, camera, field = body.update_topic.split("/")

View File

@ -30,7 +30,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateTopic,
)
from frigate.config.env import substitute_frigate_vars
from frigate.config.env import FRIGATE_ENV_VARS
from frigate.util.builtin import clean_camera_user_pass
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
from frigate.util.config import find_config_file
@ -126,7 +126,7 @@ def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
params = {"name": stream_name}
if src:
try:
params["src"] = substitute_frigate_vars(src)
params["src"] = src.format(**FRIGATE_ENV_VARS)
except KeyError:
params["src"] = src

View File

@ -1,5 +1,4 @@
import os
import re
from pathlib import Path
from typing import Annotated
@ -16,77 +15,8 @@ if os.path.isdir(secrets_dir) and os.access(secrets_dir, os.R_OK):
)
# Matches a FRIGATE_* identifier following an opening brace.
_FRIGATE_IDENT_RE = re.compile(r"FRIGATE_[A-Za-z0-9_]+")
def substitute_frigate_vars(value: str) -> str:
"""Substitute `{FRIGATE_*}` placeholders in *value*.
Reproduces the subset of `str.format()` brace semantics that Frigate's
config has historically supported, while leaving unrelated brace content
(e.g. ffmpeg `%{localtime\\:...}` expressions) untouched:
* `{{` and `}}` collapse to literal `{` / `}` (the documented escape).
* `{FRIGATE_NAME}` is replaced from `FRIGATE_ENV_VARS`; an unknown name
raises `KeyError` to preserve the existing "Invalid substitution"
error path.
* A `{` that begins `{FRIGATE_` but is not a well-formed
`{FRIGATE_NAME}` placeholder raises `ValueError` (malformed
placeholder). Callers that catch `KeyError` to allow unknown-var
passthrough will still surface malformed syntax as an error.
* Any other `{` or `}` is treated as a literal and passed through.
"""
out: list[str] = []
i = 0
n = len(value)
while i < n:
ch = value[i]
if ch == "{":
# Escaped literal `{{`.
if i + 1 < n and value[i + 1] == "{":
out.append("{")
i += 2
continue
# Possible `{FRIGATE_*}` placeholder.
if value.startswith("{FRIGATE_", i):
ident_match = _FRIGATE_IDENT_RE.match(value, i + 1)
if (
ident_match is not None
and ident_match.end() < n
and value[ident_match.end()] == "}"
):
key = ident_match.group(0)
if key not in FRIGATE_ENV_VARS:
raise KeyError(key)
out.append(FRIGATE_ENV_VARS[key])
i = ident_match.end() + 1
continue
# Looks like a FRIGATE placeholder but is malformed
# (no closing brace, illegal char, format spec, etc.).
raise ValueError(
f"Malformed FRIGATE_ placeholder near {value[i : i + 32]!r}"
)
# Plain `{` — pass through (e.g. `%{localtime\:...}`).
out.append("{")
i += 1
continue
if ch == "}":
# Escaped literal `}}`.
if i + 1 < n and value[i + 1] == "}":
out.append("}")
i += 2
continue
out.append("}")
i += 1
continue
out.append(ch)
i += 1
return "".join(out)
def validate_env_string(v: str) -> str:
return substitute_frigate_vars(v)
return v.format(**FRIGATE_ENV_VARS)
EnvString = Annotated[str, AfterValidator(validate_env_string)]

View File

@ -44,22 +44,6 @@ DEFAULT_ATTRIBUTE_LABEL_MAP = {
],
"motorcycle": ["license_plate"],
}
ATTRIBUTE_LABEL_DISPLAY_MAP = {
"amazon": "Amazon",
"an_post": "An Post",
"canada_post": "Canada Post",
"dhl": "DHL",
"dpd": "DPD",
"fedex": "FedEx",
"gls": "GLS",
"nzpost": "NZ Post",
"postnl": "PostNL",
"postnord": "PostNord",
"purolator": "Purolator",
"royal_mail": "Royal Mail",
"ups": "UPS",
"usps": "USPS",
}
LABEL_CONSOLIDATION_MAP = {
"car": 0.8,
"face": 0.5,

View File

@ -19,12 +19,7 @@ from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig
from frigate.config.camera import CameraConfig
from frigate.config.camera.review import GenAIReviewConfig, ImageSourceEnum
from frigate.const import (
ATTRIBUTE_LABEL_DISPLAY_MAP,
CACHE_DIR,
CLIPS_DIR,
UPDATE_REVIEW_DESCRIPTION,
)
from frigate.const import CACHE_DIR, CLIPS_DIR, UPDATE_REVIEW_DESCRIPTION
from frigate.data_processing.types import PostProcessDataEnum
from frigate.genai import GenAIClient
from frigate.genai.manager import GenAIClientManager
@ -561,11 +556,10 @@ def run_analysis(
if "-verified" in label:
continue
elif label in labelmap_objects:
object_type = label.replace("_", " ")
object_type = titlecase(label.replace("_", " "))
if label in attribute_labels:
display_name = ATTRIBUTE_LABEL_DISPLAY_MAP.get(label, object_type)
unified_objects.append(f"{display_name} (delivery/service)")
unified_objects.append(f"{object_type} (delivery/service)")
else:
unified_objects.append(object_type)

View File

@ -92,7 +92,6 @@ class EmbeddingMaintainer(threading.Thread):
CameraConfigUpdateEnum.add,
CameraConfigUpdateEnum.remove,
CameraConfigUpdateEnum.object_genai,
CameraConfigUpdateEnum.review,
CameraConfigUpdateEnum.review_genai,
CameraConfigUpdateEnum.semantic_search,
],

View File

@ -106,8 +106,8 @@ When forming your description:
## Response Field Guidelines
Respond with a JSON object matching the provided schema. Field-specific guidance:
- `scene`: Describe how the sequence begins, then the progression of events all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. For named subjects (those with a `` separator in "Objects in Scene"), always use their name do not replace them with generic terms. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign.
- `title`: Characterize **what took place and where** interpret the overall purpose or outcome, do not simply compress the scene description into fewer words. Include the relevant location (zone, area, or entry point). For named subjects, always use their name. For unnamed objects, refer to them naturally with articles. No editorial qualifiers like "routine" or "suspicious."
- `scene`: Describe how the sequence begins, then the progression of events all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. Always use subject names from "Objects in Scene" do not replace named subjects with generic terms like "a person" or "the individual". Your description should align with and support the threat level you assign.
- `title`: Characterize **what took place and where** interpret the overall purpose or outcome, do not simply compress the scene description into fewer words. Include the relevant location (zone, area, or entry point). Always include subject names from "Objects in Scene" do not replace named subjects with generic terms. No editorial qualifiers like "routine" or "suspicious."
- `potential_threat_level`: Must be consistent with your scene description and the activity patterns above.
{get_concern_prompt()}
@ -190,7 +190,6 @@ Each line represents a detection state, not necessarily unique individuals. The
if any("" in obj for obj in review_data["unified_objects"]):
metadata.potential_threat_level = 0
metadata.title = metadata.title[0].upper() + metadata.title[1:]
metadata.time = review_data["start"]
return metadata
except Exception as e:

View File

@ -2,7 +2,6 @@
import os
import unittest
from unittest.mock import MagicMock, patch
from frigate.config.env import (
FRIGATE_ENV_VARS,
@ -11,71 +10,6 @@ from frigate.config.env import (
)
class TestGo2RtcAddStreamSubstitution(unittest.TestCase):
"""Covers the API path: PUT /go2rtc/streams/{stream_name}.
The route shells out to go2rtc via `requests.put`; we mock the HTTP call
and assert that the substituted `src` parameter handles the same mixed
{FRIGATE_*} + literal-brace strings as the config-loading path.
"""
def setUp(self):
self._original_env_vars = dict(FRIGATE_ENV_VARS)
def tearDown(self):
FRIGATE_ENV_VARS.clear()
FRIGATE_ENV_VARS.update(self._original_env_vars)
def _call_route(self, src: str) -> str:
"""Invoke go2rtc_add_stream and return the substituted src param."""
from frigate.api import camera as camera_api
captured = {}
def fake_put(url, params=None, timeout=None):
captured["params"] = params
resp = MagicMock()
resp.ok = True
resp.text = ""
resp.status_code = 200
return resp
with patch.object(camera_api.requests, "put", side_effect=fake_put):
camera_api.go2rtc_add_stream(
request=MagicMock(), stream_name="cam1", src=src
)
return captured["params"]["src"]
def test_mixed_localtime_and_frigate_var(self):
"""%{localtime\\:...} alongside {FRIGATE_USER} substitutes only the var."""
FRIGATE_ENV_VARS["FRIGATE_USER"] = "admin"
src = (
"ffmpeg:rtsp://host/s#raw=-vf "
"drawtext=text=%{localtime\\:%Y-%m-%d}:user={FRIGATE_USER}"
)
self.assertEqual(
self._call_route(src),
"ffmpeg:rtsp://host/s#raw=-vf "
"drawtext=text=%{localtime\\:%Y-%m-%d}:user=admin",
)
def test_unknown_var_falls_back_to_raw_src(self):
"""Existing route behavior: unknown {FRIGATE_*} keeps raw src."""
src = "rtsp://host/{FRIGATE_NONEXISTENT}/stream"
self.assertEqual(self._call_route(src), src)
def test_malformed_placeholder_rejected_via_api(self):
"""Malformed FRIGATE placeholders raise (not silently passed through).
Regression: previously camera.py caught any KeyError and fell back
to the raw src, so `{FRIGATE_FOO:>5}` was silently accepted via the
API while config loading rejected it. The helper now raises
ValueError for malformed syntax to keep the two paths consistent.
"""
with self.assertRaises(ValueError):
self._call_route("rtsp://host/{FRIGATE_FOO:>5}/stream")
class TestEnvString(unittest.TestCase):
def setUp(self):
self._original_env_vars = dict(FRIGATE_ENV_VARS)
@ -109,72 +43,6 @@ class TestEnvString(unittest.TestCase):
with self.assertRaises(KeyError):
validate_env_string("{FRIGATE_NONEXISTENT_VAR}")
def test_non_frigate_braces_passthrough(self):
"""Braces that are not {FRIGATE_*} placeholders pass through untouched.
Regression test for ffmpeg drawtext expressions like
"%{localtime\\:%Y-%m-%d}" being mangled by str.format().
"""
expr = (
"ffmpeg:rtsp://127.0.0.1/src#raw=-vf "
"drawtext=text=%{localtime\\:%Y-%m-%d_%H\\:%M\\:%S}"
":x=5:fontcolor=white"
)
self.assertEqual(validate_env_string(expr), expr)
def test_double_brace_escape_preserved(self):
"""`{{output}}` collapses to `{output}` (documented go2rtc escape)."""
result = validate_env_string(
"exec:ffmpeg -i /media/file.mp4 -f rtsp {{output}}"
)
self.assertEqual(result, "exec:ffmpeg -i /media/file.mp4 -f rtsp {output}")
def test_double_brace_around_frigate_var(self):
"""`{{FRIGATE_FOO}}` stays literal — escape takes precedence."""
FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar"
self.assertEqual(validate_env_string("{{FRIGATE_FOO}}"), "{FRIGATE_FOO}")
def test_mixed_frigate_var_and_braces(self):
"""A FRIGATE_ var alongside literal single braces substitutes only the var."""
FRIGATE_ENV_VARS["FRIGATE_USER"] = "admin"
result = validate_env_string(
"drawtext=text=%{localtime}:user={FRIGATE_USER}:x=5"
)
self.assertEqual(result, "drawtext=text=%{localtime}:user=admin:x=5")
def test_triple_braces_around_frigate_var(self):
"""`{{{FRIGATE_FOO}}}` collapses like str.format(): `{bar}`."""
FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar"
self.assertEqual(validate_env_string("{{{FRIGATE_FOO}}}"), "{bar}")
def test_trailing_double_brace_after_var(self):
"""`{FRIGATE_FOO}}}` collapses like str.format(): `bar}`."""
FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar"
self.assertEqual(validate_env_string("{FRIGATE_FOO}}}"), "bar}")
def test_leading_double_brace_then_var(self):
"""`{{{FRIGATE_FOO}` collapses like str.format(): `{bar`."""
FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar"
self.assertEqual(validate_env_string("{{{FRIGATE_FOO}"), "{bar")
def test_malformed_unterminated_placeholder_raises(self):
"""`{FRIGATE_FOO` (no closing brace) raises like str.format() did."""
FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar"
with self.assertRaises(ValueError):
validate_env_string("prefix-{FRIGATE_FOO")
def test_malformed_format_spec_raises(self):
"""`{FRIGATE_FOO:>5}` (format spec) raises like str.format() did."""
FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar"
with self.assertRaises(ValueError):
validate_env_string("{FRIGATE_FOO:>5}")
def test_malformed_conversion_raises(self):
"""`{FRIGATE_FOO!r}` (conversion) raises like str.format() did."""
FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar"
with self.assertRaises(ValueError):
validate_env_string("{FRIGATE_FOO!r}")
class TestEnvVars(unittest.TestCase):
def setUp(self):

View File

@ -116,8 +116,6 @@ class TimelineProcessor(threading.Thread):
),
"attribute": "",
"score": event_data["score"],
"computed_score": event_data.get("computed_score"),
"top_score": event_data.get("top_score"),
},
}

View File

@ -400,7 +400,6 @@ class TrackedObject:
"start_time": self.obj_data["start_time"],
"end_time": self.obj_data.get("end_time", None),
"score": self.obj_data["score"],
"computed_score": self.computed_score,
"box": self.obj_data["box"],
"area": self.obj_data["area"],
"ratio": self.obj_data["ratio"],

View File

@ -62,10 +62,7 @@
"zones": "Zones",
"ratio": "Ratio",
"area": "Area",
"score": "Score",
"computedScore": "Computed Score",
"topScore": "Top Score",
"toggleAdvancedScores": "Toggle advanced scores"
"score": "Score"
}
},
"annotationSettings": {

View File

@ -1326,10 +1326,6 @@
"keyPlaceholder": "New key",
"remove": "Remove"
},
"knownPlates": {
"namePlaceholder": "e.g., Wife's Car",
"platePlaceholder": "Plate number or regex"
},
"timezone": {
"defaultOption": "Use browser timezone"
},

View File

@ -67,13 +67,6 @@ const lpr: SectionConfigOverrides = {
format: {
"ui:options": { size: "md" },
},
known_plates: {
"ui:field": "KnownPlatesField",
"ui:options": {
label: false,
suppressDescription: true,
},
},
replace_rules: {
"ui:field": "ReplaceRulesField",
"ui:options": {

View File

@ -16,16 +16,6 @@ const record: SectionConfigOverrides = {
},
},
],
fieldDocs: {
"alerts.pre_capture":
"/configuration/record#pre-capture-and-post-capture",
"alerts.post_capture":
"/configuration/record#pre-capture-and-post-capture",
"detections.pre_capture":
"/configuration/record#pre-capture-and-post-capture",
"detections.post_capture":
"/configuration/record#pre-capture-and-post-capture",
},
restartRequired: [],
fieldOrder: [
"enabled",

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from "react";
import { useMemo } from "react";
import useSWR from "swr";
import { Trans } from "react-i18next";
import Heading from "@/components/ui/heading";
@ -37,21 +37,6 @@ export default function CameraReviewStatusToggles({
const { payload: revDescState, send: sendRevDesc } =
useReviewDescriptionState(cameraId);
// Sync WS runtime state when review genai transitions from disabled to enabled in config
const prevRevGenaiEnabled = useRef(
cameraConfig?.review?.genai?.enabled_in_config,
);
useEffect(() => {
const wasEnabled = prevRevGenaiEnabled.current;
const isEnabled = cameraConfig?.review?.genai?.enabled_in_config;
prevRevGenaiEnabled.current = isEnabled;
if (!wasEnabled && isEnabled) {
sendRevDesc("ON");
}
}, [cameraConfig?.review?.genai?.enabled_in_config, sendRevDesc]);
if (!selectedCamera || !cameraConfig) {
return null;
}

View File

@ -1,277 +0,0 @@
import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
LuChevronDown,
LuChevronRight,
LuPlus,
LuTrash2,
} from "react-icons/lu";
import type { ConfigFormContext } from "@/types/configForm";
import get from "lodash/get";
import { isSubtreeModified } from "../utils";
type KnownPlatesData = Record<string, string[]>;
export function KnownPlatesField(props: FieldProps) {
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
const formContext = props.registry?.formContext as
| ConfigFormContext
| undefined;
const { t } = useTranslation(["views/settings", "common"]);
const data: KnownPlatesData = useMemo(() => {
if (!formData || typeof formData !== "object" || Array.isArray(formData)) {
return {};
}
return formData as KnownPlatesData;
}, [formData]);
const entries = useMemo(() => Object.entries(data), [data]);
const title = (schema as RJSFSchema).title;
const description = (schema as RJSFSchema).description;
const hasItems = entries.length > 0;
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 [open, setOpen] = useState(hasItems || isModified);
useEffect(() => {
if (isModified) {
setOpen(true);
}
}, [isModified]);
useEffect(() => {
if (hasItems) {
setOpen(true);
}
}, [hasItems]);
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;
// Preserve order by rebuilding the object
const next: KnownPlatesData = {};
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 handleAddPlate = useCallback(
(key: string) => {
const next = { ...data, [key]: [...(data[key] || []), ""] };
onChange(next, fieldPath);
},
[data, fieldPath, onChange],
);
const handleRemovePlate = useCallback(
(key: string, plateIndex: number) => {
const plates = [...(data[key] || [])];
plates.splice(plateIndex, 1);
const next = { ...data, [key]: plates };
onChange(next, fieldPath);
},
[data, fieldPath, onChange],
);
const handleUpdatePlate = useCallback(
(key: string, plateIndex: number, value: string) => {
const plates = [...(data[key] || [])];
plates[plateIndex] = value;
const next = { ...data, [key]: plates };
onChange(next, fieldPath);
},
[data, fieldPath, onChange],
);
const baseId = idSchema?.$id || "known_plates";
const deleteLabel = t("button.delete", {
ns: "common",
defaultValue: "Delete",
});
const namePlaceholder = t("configForm.knownPlates.namePlaceholder", {
ns: "views/settings",
});
const platePlaceholder = t("configForm.knownPlates.platePlaceholder", {
ns: "views/settings",
});
return (
<Card className="w-full">
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer p-4 transition-colors hover:bg-muted/50">
<div className="flex items-center justify-between">
<div>
<CardTitle
className={cn("text-sm", isModified && "text-danger")}
>
{title}
</CardTitle>
{description && (
<p className="mt-1 text-xs text-muted-foreground">
{description}
</p>
)}
</div>
{open ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-3 p-4 pt-0">
{entries.map(([key, plates], entryIndex) => {
const entryId = `${baseId}-${entryIndex}`;
return (
<div
key={entryIndex}
className="space-y-2 rounded-md border p-3"
>
<div className="flex items-center gap-2">
<Input
id={`${entryId}-key`}
defaultValue={key}
placeholder={namePlaceholder}
disabled={disabled || readonly}
onBlur={(e) => handleRenameKey(key, e.target.value)}
className="flex-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 className="ml-1 space-y-2 border-l-2 border-muted-foreground/20 pl-3">
{plates.map((plate, plateIndex) => (
<div key={plateIndex} className="flex items-center gap-2">
<Input
id={`${entryId}-plate-${plateIndex}`}
value={plate}
placeholder={platePlaceholder}
disabled={disabled || readonly}
onChange={(e) =>
handleUpdatePlate(key, plateIndex, e.target.value)
}
className="flex-1"
/>
{plates.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemovePlate(key, plateIndex)}
disabled={disabled || readonly}
aria-label={deleteLabel}
title={deleteLabel}
className="shrink-0"
>
<LuTrash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleAddPlate(key)}
disabled={disabled || readonly}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{t("button.add", {
ns: "common",
defaultValue: "Add",
})}
</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" />
{t("button.add", { ns: "common", defaultValue: "Add" })}
</Button>
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
export default KnownPlatesField;

View File

@ -49,7 +49,6 @@ import { DetectorHardwareField } from "./fields/DetectorHardwareField";
import { ReplaceRulesField } from "./fields/ReplaceRulesField";
import { CameraInputsField } from "./fields/CameraInputsField";
import { DictAsYamlField } from "./fields/DictAsYamlField";
import { KnownPlatesField } from "./fields/KnownPlatesField";
export interface FrigateTheme {
widgets: RegistryWidgetsType;
@ -106,6 +105,5 @@ export const frigateTheme: FrigateTheme = {
ReplaceRulesField: ReplaceRulesField,
CameraInputsField: CameraInputsField,
DictAsYamlField: DictAsYamlField,
KnownPlatesField: KnownPlatesField,
},
};

View File

@ -23,7 +23,7 @@ import { GeneralFilterContent } from "../filter/ReviewFilterGroup";
import { toast } from "sonner";
import axios, { AxiosError } from "axios";
import SaveExportOverlay from "./SaveExportOverlay";
import { isMobile } from "react-device-detect";
import { isIOS, isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@ -505,6 +505,7 @@ export default function MobileReviewSettingsDrawer({
setShowPreview={setShowExportPreview}
/>
<Drawer
modal={!(isIOS && drawerMode == "export")}
open={drawerMode != "none"}
onOpenChange={(open) => {
if (!open) {

View File

@ -9,12 +9,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { use24HourTime } from "@/hooks/use-date-utils";
import { getIconForLabel } from "@/utils/iconUtil";
import {
LuChevronDown,
LuChevronRight,
LuCircle,
LuFolderX,
} from "react-icons/lu";
import { LuCircle, LuFolderX } from "react-icons/lu";
import { cn } from "@/lib/utils";
import HlsVideoPlayer from "@/components/player/HlsVideoPlayer";
import { baseUrl } from "@/api/baseUrl";
@ -904,7 +899,6 @@ function LifecycleIconRow({
const { t } = useTranslation(["views/explore", "components/player"]);
const { data: config } = useSWR<FrigateConfig>("config");
const [isOpen, setIsOpen] = useState(false);
const [showAdvancedScores, setShowAdvancedScores] = useState(false);
const navigate = useNavigate();
const isAdmin = useIsAdmin();
@ -999,31 +993,12 @@ function LifecycleIconRow({
[item.data.box],
);
const currentScore = useMemo(
() =>
item.data.score !== undefined
? (item.data.score * 100).toFixed(0) + "%"
: null,
[item.data.score],
);
const computedScore = useMemo(
() =>
item.data.computed_score !== undefined &&
item.data.computed_score !== null &&
item.data.computed_score > 0
? (item.data.computed_score * 100).toFixed(0) + "%"
: null,
[item.data.computed_score],
);
const topScore = useMemo(
() =>
item.data.top_score !== undefined &&
item.data.top_score !== null &&
item.data.top_score > 0
? (item.data.top_score * 100).toFixed(0) + "%"
: null,
[item.data.top_score],
);
const score = useMemo(() => {
if (item.data.score !== undefined) {
return (item.data.score * 100).toFixed(0) + "%";
}
return "N/A";
}, [item.data.score]);
return (
<div
@ -1059,50 +1034,8 @@ function LifecycleIconRow({
<span className="text-primary-variant">
{t("trackingDetails.lifecycleItemDesc.header.score")}
</span>
<span className="font-medium text-primary">
{currentScore ?? "N/A"}
</span>
{(computedScore || topScore) && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowAdvancedScores((v) => !v);
}}
className="ml-1 inline-flex items-center text-primary-variant hover:text-primary"
aria-expanded={showAdvancedScores}
aria-label={t(
"trackingDetails.lifecycleItemDesc.header.toggleAdvancedScores",
)}
>
{showAdvancedScores ? (
<LuChevronDown className="size-3.5" />
) : (
<LuChevronRight className="size-3.5" />
)}
</button>
)}
<span className="font-medium text-primary">{score}</span>
</div>
{showAdvancedScores && computedScore && (
<div className="flex items-center gap-1.5">
<span className="text-primary-variant">
{t(
"trackingDetails.lifecycleItemDesc.header.computedScore",
)}
</span>
<span className="font-medium text-primary">
{computedScore}
</span>
</div>
)}
{showAdvancedScores && topScore && (
<div className="flex items-center gap-1.5">
<span className="text-primary-variant">
{t("trackingDetails.lifecycleItemDesc.header.topScore")}
</span>
<span className="font-medium text-primary">{topScore}</span>
</div>
)}
<div className="flex items-center gap-1.5">
<span className="text-primary-variant">
{t("trackingDetails.lifecycleItemDesc.header.ratio")}

View File

@ -818,27 +818,6 @@ export default function Settings() {
[],
);
// Show save/undo all buttons only when changes span multiple sections
// or the single changed section is not the one currently being viewed
const showSaveAllButtons = useMemo(() => {
const pendingKeys = Object.keys(pendingDataBySection);
if (pendingKeys.length === 0) return false;
if (pendingKeys.length >= 2) return true;
// Exactly one pending section — check if it matches the current view
const key = pendingKeys[0];
const menuKey = pendingKeyToMenuKey(key);
if (menuKey !== pageToggle) return true;
// For camera-scoped keys, also check if the camera matches
if (key.includes("::")) {
const cameraName = key.slice(0, key.indexOf("::"));
return cameraName !== selectedCamera;
}
return false;
}, [pendingDataBySection, pendingKeyToMenuKey, pageToggle, selectedCamera]);
const handleSaveAll = useCallback(async () => {
if (
!config ||
@ -1512,7 +1491,7 @@ export default function Settings() {
);
})}
</div>
{showSaveAllButtons && (
{hasPendingChanges && (
<div className="sticky bottom-0 z-50 mt-2 bg-background p-4">
<div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
@ -1688,7 +1667,7 @@ export default function Settings() {
</Heading>
</div>
<div className="flex items-center gap-2">
{showSaveAllButtons && (
{hasPendingChanges && (
<div
className={cn(
"flex flex-row items-center gap-2",

View File

@ -17,8 +17,6 @@ export type TrackingDetailsSequence = {
camera: string;
label: string;
score: number;
computed_score?: number;
top_score?: number;
sub_label: string;
box?: [number, number, number, number];
region: [number, number, number, number];