mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 06:55:28 +03:00
Compare commits
3 Commits
c3628a339d
...
5d2a725428
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d2a725428 | ||
|
|
dfe365cd28 | ||
|
|
49c3732726 |
@ -9,6 +9,7 @@ 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,
|
||||
@ -47,14 +48,6 @@ 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()
|
||||
|
||||
@ -103,13 +96,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"] = go2rtc_config["rtsp"]["username"].format(
|
||||
**FRIGATE_ENV_VARS
|
||||
go2rtc_config["rtsp"]["username"] = substitute_frigate_vars(
|
||||
go2rtc_config["rtsp"]["username"]
|
||||
)
|
||||
|
||||
if go2rtc_config.get("rtsp", {}).get("password") is not None:
|
||||
go2rtc_config["rtsp"]["password"] = go2rtc_config["rtsp"]["password"].format(
|
||||
**FRIGATE_ENV_VARS
|
||||
go2rtc_config["rtsp"]["password"] = substitute_frigate_vars(
|
||||
go2rtc_config["rtsp"]["password"]
|
||||
)
|
||||
|
||||
# ensure ffmpeg path is set correctly
|
||||
@ -145,7 +138,7 @@ for name in list(go2rtc_config.get("streams", {})):
|
||||
|
||||
if isinstance(stream, str):
|
||||
try:
|
||||
formatted_stream = stream.format(**FRIGATE_ENV_VARS)
|
||||
formatted_stream = substitute_frigate_vars(stream)
|
||||
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. "
|
||||
@ -164,7 +157,7 @@ for name in list(go2rtc_config.get("streams", {})):
|
||||
filtered_streams = []
|
||||
for i, stream_item in enumerate(stream):
|
||||
try:
|
||||
formatted_stream = stream_item.format(**FRIGATE_ENV_VARS)
|
||||
formatted_stream = substitute_frigate_vars(stream_item)
|
||||
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. "
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -24,6 +24,12 @@ 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.
|
||||
|
||||
@ -123,6 +123,76 @@ 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.
|
||||
|
||||
@ -694,6 +694,9 @@ 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("/")
|
||||
|
||||
@ -30,7 +30,7 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.config.env import FRIGATE_ENV_VARS
|
||||
from frigate.config.env import substitute_frigate_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"] = src.format(**FRIGATE_ENV_VARS)
|
||||
params["src"] = substitute_frigate_vars(src)
|
||||
except KeyError:
|
||||
params["src"] = src
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
@ -15,8 +16,77 @@ 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 v.format(**FRIGATE_ENV_VARS)
|
||||
return substitute_frigate_vars(v)
|
||||
|
||||
|
||||
EnvString = Annotated[str, AfterValidator(validate_env_string)]
|
||||
|
||||
@ -44,6 +44,22 @@ 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,
|
||||
|
||||
@ -19,7 +19,12 @@ 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 CACHE_DIR, CLIPS_DIR, UPDATE_REVIEW_DESCRIPTION
|
||||
from frigate.const import (
|
||||
ATTRIBUTE_LABEL_DISPLAY_MAP,
|
||||
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
|
||||
@ -556,10 +561,11 @@ def run_analysis(
|
||||
if "-verified" in label:
|
||||
continue
|
||||
elif label in labelmap_objects:
|
||||
object_type = titlecase(label.replace("_", " "))
|
||||
object_type = label.replace("_", " ")
|
||||
|
||||
if label in attribute_labels:
|
||||
unified_objects.append(f"{object_type} (delivery/service)")
|
||||
display_name = ATTRIBUTE_LABEL_DISPLAY_MAP.get(label, object_type)
|
||||
unified_objects.append(f"{display_name} (delivery/service)")
|
||||
else:
|
||||
unified_objects.append(object_type)
|
||||
|
||||
|
||||
@ -92,6 +92,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
CameraConfigUpdateEnum.add,
|
||||
CameraConfigUpdateEnum.remove,
|
||||
CameraConfigUpdateEnum.object_genai,
|
||||
CameraConfigUpdateEnum.review,
|
||||
CameraConfigUpdateEnum.review_genai,
|
||||
CameraConfigUpdateEnum.semantic_search,
|
||||
],
|
||||
|
||||
@ -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. 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."
|
||||
- `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."
|
||||
- `potential_threat_level`: Must be consistent with your scene description and the activity patterns above.
|
||||
{get_concern_prompt()}
|
||||
|
||||
@ -190,6 +190,7 @@ 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:
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.config.env import (
|
||||
FRIGATE_ENV_VARS,
|
||||
@ -10,6 +11,71 @@ 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)
|
||||
@ -43,6 +109,72 @@ 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):
|
||||
|
||||
@ -116,6 +116,8 @@ class TimelineProcessor(threading.Thread):
|
||||
),
|
||||
"attribute": "",
|
||||
"score": event_data["score"],
|
||||
"computed_score": event_data.get("computed_score"),
|
||||
"top_score": event_data.get("top_score"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -400,6 +400,7 @@ 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"],
|
||||
|
||||
@ -62,7 +62,10 @@
|
||||
"zones": "Zones",
|
||||
"ratio": "Ratio",
|
||||
"area": "Area",
|
||||
"score": "Score"
|
||||
"score": "Score",
|
||||
"computedScore": "Computed Score",
|
||||
"topScore": "Top Score",
|
||||
"toggleAdvancedScores": "Toggle advanced scores"
|
||||
}
|
||||
},
|
||||
"annotationSettings": {
|
||||
|
||||
@ -1326,6 +1326,10 @@
|
||||
"keyPlaceholder": "New key",
|
||||
"remove": "Remove"
|
||||
},
|
||||
"knownPlates": {
|
||||
"namePlaceholder": "e.g., Wife's Car",
|
||||
"platePlaceholder": "Plate number or regex"
|
||||
},
|
||||
"timezone": {
|
||||
"defaultOption": "Use browser timezone"
|
||||
},
|
||||
|
||||
@ -67,6 +67,13 @@ 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": {
|
||||
|
||||
@ -16,6 +16,16 @@ 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",
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import useSWR from "swr";
|
||||
import { Trans } from "react-i18next";
|
||||
import Heading from "@/components/ui/heading";
|
||||
@ -37,6 +37,21 @@ 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;
|
||||
}
|
||||
|
||||
277
web/src/components/config-form/theme/fields/KnownPlatesField.tsx
Normal file
277
web/src/components/config-form/theme/fields/KnownPlatesField.tsx
Normal file
@ -0,0 +1,277 @@
|
||||
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;
|
||||
@ -49,6 +49,7 @@ 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;
|
||||
@ -105,5 +106,6 @@ export const frigateTheme: FrigateTheme = {
|
||||
ReplaceRulesField: ReplaceRulesField,
|
||||
CameraInputsField: CameraInputsField,
|
||||
DictAsYamlField: DictAsYamlField,
|
||||
KnownPlatesField: KnownPlatesField,
|
||||
},
|
||||
};
|
||||
|
||||
@ -23,7 +23,7 @@ import { GeneralFilterContent } from "../filter/ReviewFilterGroup";
|
||||
import { toast } from "sonner";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import SaveExportOverlay from "./SaveExportOverlay";
|
||||
import { isIOS, isMobile } from "react-device-detect";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
@ -505,7 +505,6 @@ export default function MobileReviewSettingsDrawer({
|
||||
setShowPreview={setShowExportPreview}
|
||||
/>
|
||||
<Drawer
|
||||
modal={!(isIOS && drawerMode == "export")}
|
||||
open={drawerMode != "none"}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
|
||||
@ -9,7 +9,12 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { use24HourTime } from "@/hooks/use-date-utils";
|
||||
import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import { LuCircle, LuFolderX } from "react-icons/lu";
|
||||
import {
|
||||
LuChevronDown,
|
||||
LuChevronRight,
|
||||
LuCircle,
|
||||
LuFolderX,
|
||||
} from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import HlsVideoPlayer from "@/components/player/HlsVideoPlayer";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
@ -899,6 +904,7 @@ 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();
|
||||
|
||||
@ -993,12 +999,31 @@ function LifecycleIconRow({
|
||||
[item.data.box],
|
||||
);
|
||||
|
||||
const score = useMemo(() => {
|
||||
if (item.data.score !== undefined) {
|
||||
return (item.data.score * 100).toFixed(0) + "%";
|
||||
}
|
||||
return "N/A";
|
||||
}, [item.data.score]);
|
||||
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],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -1034,8 +1059,50 @@ function LifecycleIconRow({
|
||||
<span className="text-primary-variant">
|
||||
{t("trackingDetails.lifecycleItemDesc.header.score")}
|
||||
</span>
|
||||
<span className="font-medium text-primary">{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>
|
||||
)}
|
||||
</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")}
|
||||
|
||||
@ -818,6 +818,27 @@ 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 ||
|
||||
@ -1491,7 +1512,7 @@ export default function Settings() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasPendingChanges && (
|
||||
{showSaveAllButtons && (
|
||||
<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">
|
||||
@ -1667,7 +1688,7 @@ export default function Settings() {
|
||||
</Heading>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasPendingChanges && (
|
||||
{showSaveAllButtons && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row items-center gap-2",
|
||||
|
||||
@ -17,6 +17,8 @@ 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];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user