Compare commits

..
1 Commits
Author SHA1 Message Date
Jozef HuscavaandGitHub 13f75f2d88 Merge 433ee15275 into 775ce22204 2026-07-14 18:03:06 +08:00
21 changed files with 87 additions and 413 deletions
@@ -274,13 +274,6 @@ http {
include proxy.conf;
}
location /api/logout {
auth_request off;
rewrite ^/api(/.*)$ $1 break;
proxy_pass http://frigate_api;
include proxy.conf;
}
# Allow unauthenticated access to the first_time_login endpoint
# so the login page can load help text before authentication.
location /api/auth/first_time_login {
+5 -13
View File
@@ -415,7 +415,7 @@ def create_encoded_jwt(user, role, expiration, secret):
)
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure):
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, secure):
# TODO: ideally this would set secure as well, but that requires TLS
# SameSite is intentionally left unset (browsers default to Lax). Setting
# SameSite=Lax/Strict would stop the cookie from being sent in cross-origin
@@ -427,7 +427,7 @@ def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure
key=cookie_name,
value=encoded_jwt,
httponly=True,
max_age=max_age,
expires=expiration,
secure=secure,
)
@@ -762,7 +762,7 @@ def auth(request: Request):
success_response,
JWT_COOKIE_NAME,
new_encoded_jwt,
JWT_SESSION_LENGTH,
new_expiration,
JWT_COOKIE_SECURE,
)
@@ -875,11 +875,7 @@ def login(request: Request, body: AppPostLoginBody):
encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token)
response = Response("", 200)
set_jwt_cookie(
response,
JWT_COOKIE_NAME,
encoded_jwt,
JWT_SESSION_LENGTH,
JWT_COOKIE_SECURE,
response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE
)
# Clear admin_first_time_login flag after successful admin login so the
# UI stops showing the first-time login documentation link.
@@ -1041,11 +1037,7 @@ async def update_password(
)
# Set new JWT cookie on response
set_jwt_cookie(
response,
JWT_COOKIE_NAME,
encoded_jwt,
JWT_SESSION_LENGTH,
JWT_COOKIE_SECURE,
response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE
)
return response
+7 -28
View File
@@ -7,7 +7,7 @@ import operator
import time
from datetime import datetime
from functools import reduce
from typing import Any, Literal
from typing import Any
import cv2
from fastapi import APIRouter, Body, Depends, HTTPException, Request
@@ -37,7 +37,6 @@ from frigate.api.defs.response.chat_response import (
from frigate.api.defs.tags import Tags
from frigate.api.event import _build_attribute_filter_clause, events
from frigate.config import FrigateConfig
from frigate.config.classification import SemanticSearchModelEnum
from frigate.genai.prompts import (
build_chat_system_prompt,
get_attribute_classifications,
@@ -87,23 +86,10 @@ def get_tools(request: Request) -> JSONResponse:
tools = get_tool_definitions(
semantic_search_enabled=semantic_search_enabled,
attribute_classifications=attribute_classifications,
embeddings_language=_embeddings_language(config),
)
return JSONResponse(content={"tools": tools})
def _embeddings_language(config: FrigateConfig) -> Literal["english", "multi"]:
"""Return the language capability of the configured embeddings model.
JinaV1 is English-only; every other option (JinaV2 or a GenAI embeddings
provider) handles multiple languages.
"""
if config.semantic_search.model == SemanticSearchModelEnum.jinav1:
return "english"
return "multi"
def _resolve_zones(
zones: list[str],
config: FrigateConfig,
@@ -112,14 +98,11 @@ def _resolve_zones(
"""Map zone names to their canonical config keys, case-insensitively.
LLMs frequently echo a user's casing ("Front Yard") instead of the
configured key ("front_yard"), or fall back to a zone's friendly name
("Front Walkway") instead of its ID ("front_walk"). The downstream zone
filter is a SQLite GLOB over the JSON-encoded zones column, which stores
config keys and is case-sensitive — so an unnormalized name silently
returns zero matches. Build a lookup over the relevant cameras' configured
zones, keyed by both the config key and the friendly name, and substitute
when we find a match; unknown names pass through so behavior matches what
the model asked for.
configured key ("front_yard"). The downstream zone filter is a SQLite GLOB
over the JSON-encoded zones column, which is case-sensitive — so an
unnormalized name silently returns zero matches. Build a lookup over the
relevant cameras' configured zones and substitute when we find a match;
unknown names pass through so behavior matches what the model asked for.
"""
if not zones:
return zones
@@ -129,11 +112,8 @@ def _resolve_zones(
camera_config = config.cameras.get(camera_id)
if camera_config is None:
continue
for zone_name, zone_config in camera_config.zones.items():
for zone_name in camera_config.zones.keys():
lookup.setdefault(zone_name.lower(), zone_name)
lookup.setdefault(
zone_config.get_formatted_name(zone_name).lower(), zone_name
)
return [lookup.get(z.lower(), z) for z in zones]
@@ -1154,7 +1134,6 @@ async def chat_completion(
tools = get_tool_definitions(
semantic_search_enabled=semantic_search_enabled,
attribute_classifications=attribute_classifications,
embeddings_language=_embeddings_language(config),
)
conversation = []
+3 -7
View File
@@ -1,11 +1,9 @@
import re
import sqlite3
from typing import Any
import regex
from playhouse.sqliteq import SqliteQueueDatabase
REGEXP_TIMEOUT_SECONDS = 1.0
class SqliteVecQueueDatabase(SqliteQueueDatabase):
def __init__(
@@ -36,10 +34,8 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
if item is None:
return False
try:
return (
regex.search(expr, item, timeout=REGEXP_TIMEOUT_SECONDS) is not None
)
except (regex.error, TimeoutError):
return re.search(expr, item) is not None
except re.error:
return False
conn.create_function("REGEXP", 2, regexp)
+8 -8
View File
@@ -21,6 +21,8 @@ from frigate.config.camera.updater import (
CameraConfigUpdateTopic,
)
from frigate.const import (
CLIPS_DIR,
RECORD_DIR,
REPLAY_CAMERA_PREFIX,
REPLAY_DIR,
THUMB_DIR,
@@ -329,14 +331,12 @@ def cleanup_replay_cameras() -> None:
"""
stale_cameras: set[str] = set()
# Derive stale camera names from THUMB_DIR (per-camera dirs) and
# REPLAY_DIR (the session's source clip); both listings are bounded by
# camera count. cleanup_camera_files below removes any remaining
# per-camera artifacts (snapshots, thumbnails, LPR images, etc.) by name.
if os.path.isdir(THUMB_DIR):
for entry in os.listdir(THUMB_DIR):
if entry.startswith(REPLAY_CAMERA_PREFIX):
stale_cameras.add(entry)
# Scan filesystem for leftover replay artifacts to derive camera names
for dir_path in [RECORD_DIR, CLIPS_DIR, THUMB_DIR]:
if os.path.isdir(dir_path):
for entry in os.listdir(dir_path):
if entry.startswith(REPLAY_CAMERA_PREFIX):
stale_cameras.add(entry)
if os.path.isdir(REPLAY_DIR):
for entry in os.listdir(REPLAY_DIR):
+1 -5
View File
@@ -150,11 +150,7 @@ PRESETS_HW_ACCEL_SCALE["preset-rk-h265"] = PRESETS_HW_ACCEL_SCALE[FFMPEG_HWACCEL
PRESETS_HW_ACCEL_ENCODE_BIRDSEYE = {
"preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}",
"preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m {2}",
# -vaapi_device is required in addition to -hwaccel_device: this is the only
# birdseye preset that uses hwupload, and ffmpeg 8 initializes filters before
# the decoder creates a device, so hwupload cannot see an -hwaccel_device one.
# See https://github.com/AlexxIT/go2rtc/issues/1984
FFMPEG_HWACCEL_VAAPI: "{0} -hide_banner -vaapi_device {3} -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {3} {1} -c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0 -an -vf format=vaapi|nv12,hwupload {2}",
FFMPEG_HWACCEL_VAAPI: "{0} -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {3} {1} -c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0 -an -vf format=vaapi|nv12,hwupload {2}",
"preset-intel-qsv-h264": "{0} -hide_banner {1} -c:v h264_qsv -g 50 -bf 0 -profile:v high -level:v 4.1 -async_depth:v 1 {2}",
"preset-intel-qsv-h265": "{0} -hide_banner {1} -c:v h264_qsv -g 50 -bf 0 -profile:v main -level:v 4.1 -async_depth:v 1 {2}",
FFMPEG_HWACCEL_NVIDIA: "{0} -hide_banner {1} -c:v h264_nvenc -g 50 -profile:v high -level:v auto -preset:v p2 -tune:v ll {2}",
+31 -46
View File
@@ -76,6 +76,29 @@ def _parse_launch_arg(args: list[str], flag: str) -> str | None:
return args[idx + 1]
def _fetch_llama_props(base_url: str, model: str) -> dict[str, Any]:
"""Fetch /props from a llama.cpp server, with llama-swap fallback.
Raises the underlying RequestException if both endpoints fail; callers
decide how to surface the failure.
"""
try:
response = requests.get(
f"{base_url}/props",
params={"model": model},
timeout=10,
)
response.raise_for_status()
return cast(dict[str, Any], response.json())
except Exception:
response = requests.get(
f"{base_url}/upstream/{model}/props",
timeout=10,
)
response.raise_for_status()
return cast(dict[str, Any], response.json())
def _to_jpeg(img_bytes: bytes) -> bytes | None:
"""Convert image bytes to JPEG. llama.cpp/STB does not support WebP."""
try:
@@ -110,43 +133,6 @@ class LlamaCppClient(GenAIClient):
"""llama.cpp exposes an /embeddings endpoint for any loaded model."""
return True
def _auth_headers(self) -> dict | None:
"""Bearer auth header when an API key is configured, else None."""
if self.genai_config.api_key:
return {"Authorization": "Bearer " + self.genai_config.api_key}
return None
def _get(self, url: str, **kwargs: Any) -> requests.Response:
"""GET with the configured auth headers injected."""
return requests.get(url, headers=self._auth_headers(), **kwargs)
def _post(self, url: str, **kwargs: Any) -> requests.Response:
"""POST with the configured auth headers injected."""
return requests.post(url, headers=self._auth_headers(), **kwargs)
def _fetch_llama_props(self, base_url: str, model: str) -> dict[str, Any]:
"""Fetch /props from a llama.cpp server, with llama-swap fallback.
Raises the underlying RequestException if both endpoints fail; callers
decide how to surface the failure.
"""
try:
response = self._get(
f"{base_url}/props",
params={"model": model},
timeout=10,
)
response.raise_for_status()
return cast(dict[str, Any], response.json())
except Exception:
response = self._get(
f"{base_url}/upstream/{model}/props",
timeout=10,
)
response.raise_for_status()
return cast(dict[str, Any], response.json())
def _init_provider(self) -> str | None:
"""Initialize the client and query model metadata from the server."""
self.provider_options = {
@@ -230,7 +216,7 @@ class LlamaCppClient(GenAIClient):
model_entry: dict[str, Any] | None = None
try:
response = self._get(f"{base_url}/v1/models", timeout=10)
response = requests.get(f"{base_url}/v1/models", timeout=10)
response.raise_for_status()
models_data = response.json()
@@ -291,7 +277,7 @@ class LlamaCppClient(GenAIClient):
info["supports_tools"] = True
try:
props = self._fetch_llama_props(base_url, configured_model)
props = _fetch_llama_props(base_url, configured_model)
if info["context_size"] is None:
default_settings = props.get("default_generation_settings", {})
@@ -377,7 +363,7 @@ class LlamaCppClient(GenAIClient):
if self.supports_toggleable_thinking:
payload["chat_template_kwargs"] = {"enable_thinking": enable_thinking}
response = self._post(
response = requests.post(
f"{self.provider}/v1/chat/completions",
json=payload,
timeout=self.timeout,
@@ -427,7 +413,7 @@ class LlamaCppClient(GenAIClient):
if base_url is None:
return []
try:
response = self._get(f"{base_url}/v1/models", timeout=10)
response = requests.get(f"{base_url}/v1/models", timeout=10)
response.raise_for_status()
models = []
for m in response.json().get("data", []):
@@ -530,7 +516,7 @@ class LlamaCppClient(GenAIClient):
"messages": [{"role": "user", "content": content}],
"max_tokens": 1,
}
response = self._post(
response = requests.post(
f"{self.provider}/v1/chat/completions",
json=payload,
timeout=60,
@@ -640,7 +626,7 @@ class LlamaCppClient(GenAIClient):
if self.provider is None:
return False
try:
props = self._fetch_llama_props(self.provider, self.genai_config.model)
props = _fetch_llama_props(self.provider, self.genai_config.model)
except Exception as e:
logger.warning("Failed to refresh llama.cpp media marker: %s", e)
return False
@@ -701,7 +687,7 @@ class LlamaCppClient(GenAIClient):
return content
def post_embeddings() -> requests.Response:
return self._post(
return requests.post(
f"{self.provider}/embeddings",
json={"model": self.genai_config.model, "content": build_content()},
timeout=self.timeout,
@@ -805,7 +791,7 @@ class LlamaCppClient(GenAIClient):
stream=False,
enable_thinking=enable_thinking,
)
response = self._post(
response = requests.post(
f"{self.provider}/v1/chat/completions",
json=payload,
timeout=self.timeout,
@@ -886,7 +872,6 @@ class LlamaCppClient(GenAIClient):
"POST",
f"{self.provider}/v1/chat/completions",
json=payload,
headers=self._auth_headers(),
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
+6 -20
View File
@@ -6,7 +6,7 @@ transport.
"""
import datetime
from typing import Any, Literal
from typing import Any
from playhouse.shortcuts import model_to_dict
@@ -249,7 +249,6 @@ def get_attribute_classifications(config: FrigateConfig) -> list[dict[str, Any]]
def get_tool_definitions(
semantic_search_enabled: bool = False,
attribute_classifications: list[dict[str, Any]] | None = None,
embeddings_language: Literal["english", "multi"] = "multi",
) -> list[dict[str, Any]]:
"""
Get OpenAI-compatible tool definitions for Frigate.
@@ -259,9 +258,7 @@ def get_tool_definitions(
tool exposes an additional `semantic_query` parameter for descriptive
queries (e.g. "person riding a lawn mower") and find_similar_objects is
included. When attribute classification models are configured, an
`attribute` parameter is exposed for filtering by their labels. When the
embeddings model only understands English (JinaV1), the `semantic_query`
description instructs the model to write the query in English.
`attribute` parameter is exposed for filtering by their labels.
"""
search_objects_properties: dict[str, Any] = {
"camera": {
@@ -352,14 +349,6 @@ def get_tool_definitions(
"When set, combine with label/time/camera/zone filters as "
"usual (e.g. label='person', semantic_query='riding a lawn "
"mower', after='2024-05-01T00:00:00Z')."
+ (
" The configured embeddings model only understands "
"English, so always write semantic_query in English, "
"translating the user's description if they phrased it "
"in another language."
if embeddings_language == "english"
else ""
)
),
}
@@ -693,17 +682,14 @@ def build_chat_system_prompt(
if camera_config.friendly_name
else camera_id.replace("_", " ").title()
)
zone_descriptors = [
f"{zone_config.get_formatted_name(zone_name)} (ID: {zone_name})"
for zone_name, zone_config in camera_config.zones.items()
]
zone_names = list(camera_config.zones.keys())
if not has_speed_zone:
has_speed_zone = any(
zone.distances for zone in camera_config.zones.values()
)
if zone_descriptors:
if zone_names:
cameras_info.append(
f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_descriptors)})"
f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_names)})"
)
else:
cameras_info.append(f" - {friendly_name} (ID: {camera_id})")
@@ -713,7 +699,7 @@ def build_chat_system_prompt(
cameras_section = (
"\n\nAvailable cameras:\n"
+ "\n".join(cameras_info)
+ "\n\nWhen users refer to cameras or zones by their friendly name (e.g., 'Back Deck Camera', 'Front Walkway'), use the corresponding ID (e.g., 'back_deck_cam', 'front_walk') in tool calls. Tool results also identify zones by their ID, so when presenting cameras or zones back to the user, translate the ID to its friendly name."
+ "\n\nWhen users refer to cameras by their friendly name (e.g., 'Back Deck Camera'), use the corresponding camera ID (e.g., 'back_deck_cam') in tool calls."
)
speed_units_section = ""
+1 -1
View File
@@ -401,7 +401,7 @@ class _FakeAsyncClient:
async def __aexit__(self, *exc):
return False
def stream(self, method, url, json=None, headers=None):
def stream(self, method, url, json=None):
return _FakeStreamCtx(self._lines)
-54
View File
@@ -1,54 +0,0 @@
"""Tests for the REGEXP function registered on the main Frigate database.
Regression coverage for GHSA-q8jx-q884-jcq9: an attacker-controlled
catastrophic (ReDoS) pattern reaching the REGEXP sink must not be able to
stall the serialized database worker thread.
"""
import sqlite3
import time
import unittest
from frigate.db.sqlitevecq import REGEXP_TIMEOUT_SECONDS, SqliteVecQueueDatabase
class TestRegexpFunction(unittest.TestCase):
def setUp(self) -> None:
# autostart=False keeps the queue worker thread from spinning up; we
# only need the REGEXP registration, exercised on our own connection.
self.db = SqliteVecQueueDatabase(":memory:", autostart=False)
self.conn = sqlite3.connect(":memory:")
self.db._register_regexp(self.conn)
def tearDown(self) -> None:
self.conn.close()
def _regexp(self, value: str | None, pattern: str) -> int | None:
# SQLite maps "value REGEXP pattern" to regexp(pattern, value).
return self.conn.execute("SELECT ? REGEXP ?", (value, pattern)).fetchone()[0]
def test_normal_patterns_still_match(self) -> None:
self.assertTrue(self._regexp("ABC123", "^ABC"))
self.assertTrue(self._regexp("ABC123", "ABC.*"))
self.assertTrue(self._regexp("ABC123", "[0-9]+$"))
self.assertFalse(self._regexp("ABC123", "^XYZ"))
def test_null_value_does_not_match(self) -> None:
self.assertFalse(self._regexp(None, ".*"))
def test_invalid_pattern_does_not_raise(self) -> None:
self.assertFalse(self._regexp("ABC123", "(unclosed"))
def test_catastrophic_pattern_is_time_bounded(self) -> None:
# Without the timeout this evaluation backtracks for minutes to hours
# and wedges the whole database thread (GHSA-q8jx-q884-jcq9).
catastrophic = "(a{2,})+c"
subject = "a" * 4000
start = time.monotonic()
result = self._regexp(subject, catastrophic)
elapsed = time.monotonic() - start
# The pattern does not match; the guarantee is that it returns quickly.
self.assertFalse(result)
self.assertLess(elapsed, REGEXP_TIMEOUT_SECONDS + 2.0)
@@ -169,13 +169,6 @@ const detect: SectionConfigOverrides = {
resolution: ["width", "height", "fps"],
tracking: ["min_initialized", "max_disappeared"],
},
uiSchema: {
annotation_offset: {
"ui:options": {
signed: true,
},
},
},
hiddenFields: ["enabled_in_config"],
advancedFields: [
"min_initialized",
@@ -57,7 +57,6 @@ const record: SectionConfigOverrides = {
"ui:options": {
suppressMultiSchema: true,
ffmpegPresetField: "hwaccel_args",
ffmpegGlobalFieldPath: "export.hwaccel_args",
},
},
},
@@ -17,4 +17,3 @@ export {
isSubtreeModified,
} from "./overrides";
export { getSizedFieldClassName } from "./fieldSizing";
export { getNumericInputMode } from "./inputMode";
@@ -1,51 +0,0 @@
import type { RJSFSchema } from "@rjsf/utils";
type NumericInputOptions = {
signed?: boolean;
};
/**
* Derive the on-screen keyboard hint for a schema field.
*
* Numeric config fields render as text inputs because RJSF's NumberField
* relies on the widget echoing raw strings back, so that trailing "." and "0"
* characters survive while a value is being typed. That means the numeric
* keypad has to be requested explicitly. Desktop browsers ignore inputMode, so
* this only affects virtual keyboards.
*
* Fields accepting negative values opt out, since the iOS numeric and decimal
* keypads have no minus key. Most numeric fields declare no minimum even
* though they are non-negative, so signed fields are marked explicitly with
* ui:options.signed.
*
* Args:
* schema: The JSON schema for the field being rendered
* options: The resolved ui:options for the field
*
* Returns:
* The inputMode to apply, or undefined to leave the keyboard alone
*/
export function getNumericInputMode(
schema: RJSFSchema,
options: unknown,
): "numeric" | "decimal" | undefined {
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
const isInteger = types.includes("integer");
if (!isInteger && !types.includes("number")) {
return undefined;
}
const numericOptions =
typeof options === "object" && options !== null
? (options as NumericInputOptions)
: undefined;
const minimum = schema.minimum ?? schema.exclusiveMinimum;
if (numericOptions?.signed || (minimum ?? 0) < 0) {
return undefined;
}
return isInteger ? "numeric" : "decimal";
}
@@ -120,12 +120,6 @@ export function FfmpegArgsWidget(props: WidgetProps) {
id,
} = props;
const presetField = options?.ffmpegPresetField as PresetField | undefined;
// Path to this field within its config section. This is usually the same as
// the preset field, but the two diverge when the field sits below the
// section root: record.export.hwaccel_args uses the hwaccel_args preset list
// while living at export.hwaccel_args inside the record section.
const globalFieldPath =
(options?.ffmpegGlobalFieldPath as string | undefined) ?? presetField;
const allowInherit = options?.allowInherit === true;
const hideDescription = options?.hideDescription === true;
const useSplitLayout = options?.splitLayout !== false;
@@ -137,18 +131,11 @@ export function FfmpegArgsWidget(props: WidgetProps) {
// Extract the global value for this specific field to detect inheritance
const globalFieldValue = useMemo(() => {
if (
!showUseGlobalSetting ||
!formContext?.globalValue ||
!globalFieldPath
) {
if (!showUseGlobalSetting || !formContext?.globalValue || !presetField) {
return undefined;
}
return get(
formContext.globalValue as Record<string, unknown>,
globalFieldPath,
);
}, [showUseGlobalSetting, formContext?.globalValue, globalFieldPath]);
return get(formContext.globalValue as Record<string, unknown>, presetField);
}, [showUseGlobalSetting, formContext?.globalValue, presetField]);
const { data } = useSWR<FfmpegPresetResponse>("ffmpeg/presets");
@@ -2,7 +2,7 @@
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { getNumericInputMode, getSizedFieldClassName } from "../utils";
import { getSizedFieldClassName } from "../utils";
export function TextWidget(props: WidgetProps) {
const {
@@ -28,7 +28,6 @@ export function TextWidget(props: WidgetProps) {
id={id}
className={cn(fieldClassName)}
type="text"
inputMode={getNumericInputMode(schema, options)}
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || (options.placeholder as string) || ""}
@@ -127,12 +127,8 @@ export default function ObjectTrackOverlay({
},
);
const getZonesFriendlyNames = (
zones: string[],
config: FrigateConfig,
cameraId?: string,
) => {
return zones?.map((zone) => resolveZoneName(config, zone, cameraId)) ?? [];
const getZonesFriendlyNames = (zones: string[], config: FrigateConfig) => {
return zones?.map((zone) => resolveZoneName(config, zone)) ?? [];
};
const timelineResults = useMemo(() => {
@@ -155,7 +151,7 @@ export default function ObjectTrackOverlay({
data: {
...event.data,
zones_friendly_names: config
? getZonesFriendlyNames(event.data?.zones, config, event.camera)
? getZonesFriendlyNames(event.data?.zones, config)
: [],
},
}));
@@ -61,11 +61,7 @@ export function ObjectPath({
...pos.lifecycle_item?.data,
zones_friendly_names: pos.lifecycle_item?.data.zones.map(
(zone) => {
return resolveZoneName(
config,
zone,
pos.lifecycle_item?.camera,
);
return resolveZoneName(config, zone);
},
),
},
@@ -301,19 +301,11 @@ export function TrackingDetails({
[recordings, actualVideoStart],
);
const sequence = useMemo(
() =>
eventSequence?.map((item) => ({
...item,
data: {
...item.data,
zones_friendly_names: item.data?.zones?.map((zone) =>
resolveZoneName(config, zone, item.camera),
),
},
})),
[eventSequence, config],
);
eventSequence?.map((event) => {
event.data.zones_friendly_names = event.data?.zones?.map((zone) => {
return resolveZoneName(config, zone);
});
});
// Use manualOverride (set when seeking in image mode) if present so
// lifecycle rows and overlays follow image-mode seeks. Otherwise fall
@@ -857,9 +849,9 @@ export function TrackingDetails({
</div>
<div className="mt-2">
{!sequence ? (
{!eventSequence ? (
<ActivityIndicator className="size-2" size={2} />
) : sequence.length === 0 ? (
) : eventSequence.length === 0 ? (
<div className="py-2 text-muted-foreground">
{t("detail.noObjectDetailData", { ns: "views/events" })}
</div>
@@ -879,7 +871,7 @@ export function TrackingDetails({
/>
)}
<div className="space-y-2">
{sequence.map((item, idx) => {
{eventSequence.map((item, idx) => {
return (
<div
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
+1 -1
View File
@@ -1032,7 +1032,7 @@ function ObjectTimeline({
data: {
...event.data,
zones_friendly_names: event.data?.zones?.map((zone) =>
resolveZoneName(config, zone, event.camera),
resolveZoneName(config, zone),
),
},
}));
+8 -117
View File
@@ -102,91 +102,6 @@ function stripAutoDerivedMissingFromGlobal(
return cloned;
}
/**
* Sections carrying a `filters` map that the backend materializes per-camera:
* every label in the camera's label list (`objects.track`, `audio.listen`)
* without an explicit filter gets a default entry. The global map gets no
* equivalent treatment for those labels, so a label the camera picks up exists
* on the camera side alone.
*/
const AUTO_POPULATED_FILTER_SECTIONS = ["objects", "audio"];
/**
* Resolve the schema defaults for a single `<section>.filters` entry, in the
* normalized/collapsed shape config values are compared in. Returns undefined
* when the schema hasn't loaded or doesn't describe the filters map.
*/
function getDefaultFilter(
sectionPath: string,
schema?: RJSFSchema,
): JsonValue | undefined {
if (!schema) return undefined;
const sectionSchema = extractSectionSchema(schema, sectionPath, "camera");
const filtersSchema = sectionSchema?.properties?.filters as
| RJSFSchema
| undefined;
if (!filtersSchema) return undefined;
// An optional map (`dict[str, X] | None`, as `audio.filters` is declared)
// becomes an anyOf, so the entry schema can sit in a branch rather than on
// the map schema itself.
const candidates = [
filtersSchema,
...((filtersSchema.anyOf ?? filtersSchema.oneOf ?? []) as RJSFSchema[]),
];
const filterSchema = candidates.find((candidate) =>
isJsonObject(candidate?.additionalProperties as JsonValue),
)?.additionalProperties;
if (!filterSchema || typeof filterSchema !== "object") return undefined;
return collapseEmpty(
normalizeConfigValue(applySchemaDefaults(filterSchema as RJSFSchema, {})),
);
}
/**
* Complete a global baseline with the filter entries the backend only
* materializes per-camera.
*
* The effective global value for a label with no explicit global filter is the
* filter's schema defaults, not "absent": that is exactly what a camera
* inherits for it. Filling those in keeps an untouched label from reading as a
* camera override, and lets a label the camera does filter report the specific
* changed field rather than the whole filter.
*/
function withDefaultFilters(
sectionPath: string,
globalValue: JsonValue,
cameraValue: JsonValue,
schema?: RJSFSchema,
): JsonValue {
if (
!AUTO_POPULATED_FILTER_SECTIONS.includes(sectionPath) ||
!isJsonObject(globalValue) ||
!isJsonObject(cameraValue)
) {
return globalValue;
}
const cameraFilters = cameraValue.filters;
if (!isJsonObject(cameraFilters)) return globalValue;
const globalFilters = isJsonObject(globalValue.filters)
? globalValue.filters
: undefined;
const missing = Object.keys(cameraFilters).filter(
(label) => globalFilters?.[label] === undefined,
);
if (missing.length === 0) return globalValue;
const defaultFilter = getDefaultFilter(sectionPath, schema);
if (defaultFilter === undefined) return globalValue;
const filters: JsonObject = { ...globalFilters };
for (const label of missing) {
filters[label] = defaultFilter;
}
return { ...globalValue, filters };
}
/**
* Whether the given field is auto-derived for `sectionPath` and the global
* value at that path is missing — in which case a per-camera value should
@@ -375,7 +290,7 @@ export function useConfigOverride({
"camera",
buildHiddenFieldContext(config, "camera", cameraName),
);
const collapsedGlobalRaw = stripHiddenPaths(
const collapsedGlobal = stripHiddenPaths(
collapseEmpty(normalizedGlobalValue),
hiddenFields,
);
@@ -385,15 +300,9 @@ export function useConfigOverride({
);
const collapsedCamera = stripAutoDerivedMissingFromGlobal(
sectionPath,
collapsedGlobalRaw,
collapsedGlobal,
collapsedCameraRaw,
);
const collapsedGlobal = withDefaultFilters(
sectionPath,
collapsedGlobalRaw,
collapsedCamera,
schema,
);
const comparisonGlobal = compareFields
? pickFields(collapsedGlobal, compareFields)
@@ -537,7 +446,7 @@ export function useAllCameraOverrides(
"camera",
buildHiddenFieldContext(config, "camera", cameraName),
);
const collapsedGlobalRaw = stripHiddenPaths(
const collapsedGlobal = stripHiddenPaths(
collapseEmpty(globalValue),
hiddenFields,
);
@@ -547,15 +456,9 @@ export function useAllCameraOverrides(
);
const collapsedCamera = stripAutoDerivedMissingFromGlobal(
key,
collapsedGlobalRaw,
collapsedGlobal,
collapsedCameraRaw,
);
const collapsedGlobal = withDefaultFilters(
key,
collapsedGlobalRaw,
collapsedCamera,
schema,
);
const comparisonGlobal = compareFields
? pickFields(collapsedGlobal, compareFields)
: collapsedGlobal;
@@ -811,14 +714,8 @@ export function useCamerasOverridingSection(
globalValue,
collapseEmpty(cameraSectionValues[idx]),
);
const effectiveGlobalValue = withDefaultFilters(
sectionPath,
globalValue,
cameraValue,
schema,
);
for (const delta of collectFieldDeltas(
effectiveGlobalValue,
globalValue,
cameraValue,
compareFields,
)) {
@@ -841,7 +738,7 @@ export function useCamerasOverridingSection(
if (deltasByPath.has(path)) continue;
if (isCrossCameraIgnoredPath(path)) continue;
if (!isPathAllowed(path, compareFields)) continue;
const g = get(effectiveGlobalValue, path);
const g = get(globalValue, path);
const p = get(normalizedProfile, path);
if (!isEqual(g, p)) {
deltasByPath.set(path, {
@@ -894,24 +791,18 @@ export function useCameraSectionDeltas(
const sectionMeta = OVERRIDABLE_SECTIONS.find((s) => s.key === sectionPath);
const compareFields = sectionMeta?.compareFields;
const rawGlobalValue = collapseEmpty(
const globalValue = collapseEmpty(
getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema),
);
const cameraValue = stripAutoDerivedMissingFromGlobal(
sectionPath,
rawGlobalValue,
globalValue,
collapseEmpty(
normalizeConfigValue(
getBaseCameraSectionValue(config, cameraName, sectionPath),
),
),
);
const globalValue = withDefaultFilters(
sectionPath,
rawGlobalValue,
cameraValue,
schema,
);
const hiddenFields = getEffectiveHiddenFields(
sectionPath,