mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-22 11:49:03 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ab9239461 | ||
|
|
a8eca68438 | ||
|
|
81b53b7835 | ||
|
|
b5a360be39 | ||
|
|
54a7c5015e |
+28
-7
@@ -7,7 +7,7 @@ import operator
|
||||
import time
|
||||
from datetime import datetime
|
||||
from functools import reduce
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
import cv2
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
@@ -37,6 +37,7 @@ 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,
|
||||
@@ -86,10 +87,23 @@ 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,
|
||||
@@ -98,11 +112,14 @@ 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"). 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.
|
||||
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.
|
||||
"""
|
||||
if not zones:
|
||||
return zones
|
||||
@@ -112,8 +129,11 @@ def _resolve_zones(
|
||||
camera_config = config.cameras.get(camera_id)
|
||||
if camera_config is None:
|
||||
continue
|
||||
for zone_name in camera_config.zones.keys():
|
||||
for zone_name, zone_config in camera_config.zones.items():
|
||||
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]
|
||||
|
||||
@@ -1134,6 +1154,7 @@ async def chat_completion(
|
||||
tools = get_tool_definitions(
|
||||
semantic_search_enabled=semantic_search_enabled,
|
||||
attribute_classifications=attribute_classifications,
|
||||
embeddings_language=_embeddings_language(config),
|
||||
)
|
||||
conversation = []
|
||||
|
||||
|
||||
@@ -21,8 +21,6 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.const import (
|
||||
CLIPS_DIR,
|
||||
RECORD_DIR,
|
||||
REPLAY_CAMERA_PREFIX,
|
||||
REPLAY_DIR,
|
||||
THUMB_DIR,
|
||||
@@ -331,12 +329,14 @@ def cleanup_replay_cameras() -> None:
|
||||
"""
|
||||
stale_cameras: set[str] = set()
|
||||
|
||||
# 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)
|
||||
# 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)
|
||||
|
||||
if os.path.isdir(REPLAY_DIR):
|
||||
for entry in os.listdir(REPLAY_DIR):
|
||||
|
||||
@@ -150,7 +150,11 @@ 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}",
|
||||
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}",
|
||||
# -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}",
|
||||
"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}",
|
||||
|
||||
@@ -76,29 +76,6 @@ 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:
|
||||
@@ -133,6 +110,43 @@ 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 = {
|
||||
@@ -216,7 +230,7 @@ class LlamaCppClient(GenAIClient):
|
||||
|
||||
model_entry: dict[str, Any] | None = None
|
||||
try:
|
||||
response = requests.get(f"{base_url}/v1/models", timeout=10)
|
||||
response = self._get(f"{base_url}/v1/models", timeout=10)
|
||||
response.raise_for_status()
|
||||
models_data = response.json()
|
||||
|
||||
@@ -277,7 +291,7 @@ class LlamaCppClient(GenAIClient):
|
||||
info["supports_tools"] = True
|
||||
|
||||
try:
|
||||
props = _fetch_llama_props(base_url, configured_model)
|
||||
props = self._fetch_llama_props(base_url, configured_model)
|
||||
|
||||
if info["context_size"] is None:
|
||||
default_settings = props.get("default_generation_settings", {})
|
||||
@@ -363,7 +377,7 @@ class LlamaCppClient(GenAIClient):
|
||||
if self.supports_toggleable_thinking:
|
||||
payload["chat_template_kwargs"] = {"enable_thinking": enable_thinking}
|
||||
|
||||
response = requests.post(
|
||||
response = self._post(
|
||||
f"{self.provider}/v1/chat/completions",
|
||||
json=payload,
|
||||
timeout=self.timeout,
|
||||
@@ -413,7 +427,7 @@ class LlamaCppClient(GenAIClient):
|
||||
if base_url is None:
|
||||
return []
|
||||
try:
|
||||
response = requests.get(f"{base_url}/v1/models", timeout=10)
|
||||
response = self._get(f"{base_url}/v1/models", timeout=10)
|
||||
response.raise_for_status()
|
||||
models = []
|
||||
for m in response.json().get("data", []):
|
||||
@@ -516,7 +530,7 @@ class LlamaCppClient(GenAIClient):
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
"max_tokens": 1,
|
||||
}
|
||||
response = requests.post(
|
||||
response = self._post(
|
||||
f"{self.provider}/v1/chat/completions",
|
||||
json=payload,
|
||||
timeout=60,
|
||||
@@ -626,7 +640,7 @@ class LlamaCppClient(GenAIClient):
|
||||
if self.provider is None:
|
||||
return False
|
||||
try:
|
||||
props = _fetch_llama_props(self.provider, self.genai_config.model)
|
||||
props = self._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
|
||||
@@ -687,7 +701,7 @@ class LlamaCppClient(GenAIClient):
|
||||
return content
|
||||
|
||||
def post_embeddings() -> requests.Response:
|
||||
return requests.post(
|
||||
return self._post(
|
||||
f"{self.provider}/embeddings",
|
||||
json={"model": self.genai_config.model, "content": build_content()},
|
||||
timeout=self.timeout,
|
||||
@@ -791,7 +805,7 @@ class LlamaCppClient(GenAIClient):
|
||||
stream=False,
|
||||
enable_thinking=enable_thinking,
|
||||
)
|
||||
response = requests.post(
|
||||
response = self._post(
|
||||
f"{self.provider}/v1/chat/completions",
|
||||
json=payload,
|
||||
timeout=self.timeout,
|
||||
@@ -872,6 +886,7 @@ 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,7 +6,7 @@ transport.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
@@ -249,6 +249,7 @@ 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.
|
||||
@@ -258,7 +259,9 @@ 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.
|
||||
`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.
|
||||
"""
|
||||
search_objects_properties: dict[str, Any] = {
|
||||
"camera": {
|
||||
@@ -349,6 +352,14 @@ 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 ""
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
@@ -682,14 +693,17 @@ def build_chat_system_prompt(
|
||||
if camera_config.friendly_name
|
||||
else camera_id.replace("_", " ").title()
|
||||
)
|
||||
zone_names = list(camera_config.zones.keys())
|
||||
zone_descriptors = [
|
||||
f"{zone_config.get_formatted_name(zone_name)} (ID: {zone_name})"
|
||||
for zone_name, zone_config in camera_config.zones.items()
|
||||
]
|
||||
if not has_speed_zone:
|
||||
has_speed_zone = any(
|
||||
zone.distances for zone in camera_config.zones.values()
|
||||
)
|
||||
if zone_names:
|
||||
if zone_descriptors:
|
||||
cameras_info.append(
|
||||
f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_names)})"
|
||||
f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_descriptors)})"
|
||||
)
|
||||
else:
|
||||
cameras_info.append(f" - {friendly_name} (ID: {camera_id})")
|
||||
@@ -699,7 +713,7 @@ def build_chat_system_prompt(
|
||||
cameras_section = (
|
||||
"\n\nAvailable cameras:\n"
|
||||
+ "\n".join(cameras_info)
|
||||
+ "\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."
|
||||
+ "\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."
|
||||
)
|
||||
|
||||
speed_units_section = ""
|
||||
|
||||
+83
-103
@@ -595,112 +595,92 @@ class BirdsEyeFrameManager:
|
||||
) -> list[list[Any]] | None:
|
||||
"""Calculate the optimal layout for 2+ cameras."""
|
||||
|
||||
def map_layout(
|
||||
camera_layout: list[list[Any]], row_height: int
|
||||
) -> tuple[int, int, list[list[Any]] | None]:
|
||||
"""Map the calculated layout."""
|
||||
candidate_layout = []
|
||||
starting_x = 0
|
||||
x = 0
|
||||
max_width = 0
|
||||
y = 0
|
||||
def find_available_x(
|
||||
current_x: int,
|
||||
width: int,
|
||||
reserved_ranges: list[tuple[int, int]],
|
||||
max_width: int,
|
||||
) -> int | None:
|
||||
"""Find the first horizontal slot that does not collide with reservations."""
|
||||
x = current_x
|
||||
|
||||
for row in camera_layout:
|
||||
final_row = []
|
||||
max_width = max(max_width, x)
|
||||
x = starting_x
|
||||
for cameras in row:
|
||||
camera_dims = self.cameras[cameras[0]]["dimensions"].copy()
|
||||
camera_aspect = cameras[1]
|
||||
for reserved_start, reserved_end in sorted(reserved_ranges):
|
||||
if x >= reserved_end:
|
||||
continue
|
||||
|
||||
if camera_dims[1] > camera_dims[0]:
|
||||
scaled_height = int(row_height * 2)
|
||||
scaled_width = int(scaled_height * camera_aspect)
|
||||
starting_x = scaled_width
|
||||
else:
|
||||
scaled_height = row_height
|
||||
scaled_width = int(scaled_height * camera_aspect)
|
||||
if x + width <= reserved_start:
|
||||
return x
|
||||
|
||||
# layout is too large
|
||||
if (
|
||||
x + scaled_width > self.canvas.width
|
||||
or y + scaled_height > self.canvas.height
|
||||
):
|
||||
return x + scaled_width, y + scaled_height, None
|
||||
x = max(x, reserved_end)
|
||||
|
||||
final_row.append((cameras[0], (x, y, scaled_width, scaled_height)))
|
||||
x += scaled_width
|
||||
if x + width <= max_width:
|
||||
return x
|
||||
|
||||
y += row_height
|
||||
candidate_layout.append(final_row)
|
||||
|
||||
if max_width == 0:
|
||||
max_width = x
|
||||
|
||||
return max_width, y, candidate_layout
|
||||
|
||||
canvas_aspect_x, canvas_aspect_y = self.canvas.get_aspect(coefficient)
|
||||
camera_layout: list[list[Any]] = []
|
||||
camera_layout.append([])
|
||||
starting_x = 0
|
||||
x = starting_x
|
||||
y = 0
|
||||
y_i = 0
|
||||
max_y = 0
|
||||
for camera in cameras_to_add:
|
||||
camera_dims = self.cameras[camera]["dimensions"].copy()
|
||||
camera_aspect_x, camera_aspect_y = self.canvas.get_camera_aspect(
|
||||
camera, camera_dims[0], camera_dims[1]
|
||||
)
|
||||
|
||||
if camera_dims[1] > camera_dims[0]:
|
||||
portrait = True
|
||||
else:
|
||||
portrait = False
|
||||
|
||||
if (x + camera_aspect_x) <= canvas_aspect_x:
|
||||
# insert if camera can fit on current row
|
||||
camera_layout[y_i].append(
|
||||
(
|
||||
camera,
|
||||
camera_aspect_x / camera_aspect_y,
|
||||
)
|
||||
)
|
||||
|
||||
if portrait:
|
||||
starting_x = camera_aspect_x
|
||||
else:
|
||||
max_y = max(
|
||||
max_y,
|
||||
camera_aspect_y,
|
||||
)
|
||||
|
||||
x += camera_aspect_x
|
||||
else:
|
||||
# move on to the next row and insert
|
||||
y += max_y
|
||||
y_i += 1
|
||||
camera_layout.append([])
|
||||
x = starting_x
|
||||
|
||||
if x + camera_aspect_x > canvas_aspect_x:
|
||||
return None
|
||||
|
||||
camera_layout[y_i].append(
|
||||
(
|
||||
camera,
|
||||
camera_aspect_x / camera_aspect_y,
|
||||
)
|
||||
)
|
||||
x += camera_aspect_x
|
||||
|
||||
if y + max_y > canvas_aspect_y:
|
||||
return None
|
||||
|
||||
row_height = int(self.canvas.height / coefficient)
|
||||
total_width, total_height, standard_candidate_layout = map_layout(
|
||||
camera_layout, row_height
|
||||
)
|
||||
def map_layout(row_height: int) -> tuple[int, int, list[list[Any]] | None]:
|
||||
"""Lay out cameras row by row while reserving portrait spans for the next row."""
|
||||
candidate_layout: list[list[Any]] = []
|
||||
reserved_ranges: dict[int, list[tuple[int, int]]] = {}
|
||||
current_row: list[Any] = []
|
||||
row_index = 0
|
||||
row_y = 0
|
||||
row_x = 0
|
||||
max_width = 0
|
||||
max_height = 0
|
||||
|
||||
for camera in cameras_to_add:
|
||||
camera_dims = self.cameras[camera]["dimensions"].copy()
|
||||
camera_aspect_x, camera_aspect_y = self.canvas.get_camera_aspect(
|
||||
camera, camera_dims[0], camera_dims[1]
|
||||
)
|
||||
portrait = camera_dims[1] > camera_dims[0]
|
||||
scaled_height = row_height * 2 if portrait else row_height
|
||||
scaled_width = int(scaled_height * (camera_aspect_x / camera_aspect_y))
|
||||
|
||||
while True:
|
||||
x = find_available_x(
|
||||
row_x,
|
||||
scaled_width,
|
||||
reserved_ranges.get(row_index, []),
|
||||
self.canvas.width,
|
||||
)
|
||||
|
||||
if x is not None and row_y + scaled_height <= self.canvas.height:
|
||||
current_row.append(
|
||||
(camera, (x, row_y, scaled_width, scaled_height))
|
||||
)
|
||||
row_x = x + scaled_width
|
||||
max_width = max(max_width, row_x)
|
||||
max_height = max(max_height, row_y + scaled_height)
|
||||
|
||||
if portrait:
|
||||
reserved_ranges.setdefault(row_index + 1, []).append(
|
||||
(x, row_x)
|
||||
)
|
||||
|
||||
break
|
||||
|
||||
if current_row:
|
||||
candidate_layout.append(current_row)
|
||||
current_row = []
|
||||
|
||||
row_index += 1
|
||||
row_y = row_index * row_height
|
||||
row_x = 0
|
||||
|
||||
if row_y + scaled_height > self.canvas.height:
|
||||
overflow_width = max(max_width, scaled_width)
|
||||
overflow_height = row_y + scaled_height
|
||||
return overflow_width, overflow_height, None
|
||||
|
||||
if current_row:
|
||||
candidate_layout.append(current_row)
|
||||
|
||||
return max_width, max_height, candidate_layout
|
||||
|
||||
row_height = max(1, int(self.canvas.height / coefficient))
|
||||
total_width, total_height, standard_candidate_layout = map_layout(row_height)
|
||||
|
||||
if not standard_candidate_layout:
|
||||
# if standard layout didn't work
|
||||
@@ -709,9 +689,9 @@ class BirdsEyeFrameManager:
|
||||
total_width / self.canvas.width,
|
||||
total_height / self.canvas.height,
|
||||
)
|
||||
row_height = int(row_height / scale_down_percent)
|
||||
row_height = max(1, int(row_height / scale_down_percent))
|
||||
total_width, total_height, standard_candidate_layout = map_layout(
|
||||
camera_layout, row_height
|
||||
row_height
|
||||
)
|
||||
|
||||
if not standard_candidate_layout:
|
||||
@@ -725,8 +705,8 @@ class BirdsEyeFrameManager:
|
||||
1 / (total_width / self.canvas.width),
|
||||
1 / (total_height / self.canvas.height),
|
||||
)
|
||||
row_height = int(row_height * scale_up_percent)
|
||||
_, _, scaled_layout = map_layout(camera_layout, row_height)
|
||||
row_height = max(1, int(row_height * scale_up_percent))
|
||||
_, _, scaled_layout = map_layout(row_height)
|
||||
|
||||
if scaled_layout:
|
||||
return scaled_layout
|
||||
|
||||
@@ -1,11 +1,64 @@
|
||||
"""Test camera user and password cleanup."""
|
||||
"""Tests for Birdseye canvas sizing and layout behavior."""
|
||||
|
||||
import unittest
|
||||
from multiprocessing import Event
|
||||
|
||||
from frigate.output.birdseye import get_canvas_shape
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.output.birdseye import BirdsEyeFrameManager, get_canvas_shape
|
||||
|
||||
|
||||
class TestBirdseye(unittest.TestCase):
|
||||
def _build_manager(
|
||||
self, camera_dimensions: dict[str, tuple[int, int]]
|
||||
) -> BirdsEyeFrameManager:
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"birdseye": {"width": 1280, "height": 720},
|
||||
"cameras": {},
|
||||
}
|
||||
|
||||
for order, (camera, dimensions) in enumerate(
|
||||
camera_dimensions.items(), start=1
|
||||
):
|
||||
config["cameras"][camera] = {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": f"rtsp://10.0.0.1:554/{camera}",
|
||||
"roles": ["detect"],
|
||||
}
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"width": dimensions[0],
|
||||
"height": dimensions[1],
|
||||
"fps": 5,
|
||||
},
|
||||
"birdseye": {"order": order},
|
||||
}
|
||||
|
||||
return BirdsEyeFrameManager(FrigateConfig(**config), Event())
|
||||
|
||||
def _assert_no_overlaps(
|
||||
self, layout: list[list[tuple[str, tuple[int, int, int, int]]]]
|
||||
):
|
||||
rectangles = [position for row in layout for _, position in row]
|
||||
|
||||
for index, rect in enumerate(rectangles):
|
||||
x1, y1, width1, height1 = rect
|
||||
for other in rectangles[index + 1 :]:
|
||||
x2, y2, width2, height2 = other
|
||||
overlap = (
|
||||
x1 < x2 + width2
|
||||
and x2 < x1 + width1
|
||||
and y1 < y2 + height2
|
||||
and y2 < y1 + height1
|
||||
)
|
||||
self.assertFalse(
|
||||
overlap,
|
||||
msg=f"Overlapping rectangles found: {rect} and {other}",
|
||||
)
|
||||
|
||||
def test_16x9(self):
|
||||
"""Test 16x9 aspect ratio works as expected for birdseye."""
|
||||
width = 1280
|
||||
@@ -45,3 +98,104 @@ class TestBirdseye(unittest.TestCase):
|
||||
canvas_width, canvas_height = get_canvas_shape(width, height)
|
||||
assert canvas_width == width # width will be the same
|
||||
assert canvas_height != height
|
||||
|
||||
def test_portrait_camera_does_not_overlap_next_row(self):
|
||||
"""Portrait cameras should reserve their real horizontal position on the next row."""
|
||||
manager = self._build_manager(
|
||||
{
|
||||
"cam_a": (1280, 720),
|
||||
"cam_p": (360, 640),
|
||||
"cam_b": (1280, 720),
|
||||
"cam_c": (640, 480),
|
||||
}
|
||||
)
|
||||
|
||||
layout = manager.calculate_layout(["cam_a", "cam_p", "cam_b", "cam_c"], 3)
|
||||
|
||||
self.assertIsNotNone(layout)
|
||||
assert layout is not None
|
||||
self._assert_no_overlaps(layout)
|
||||
|
||||
cam_c = [
|
||||
position for row in layout for camera, position in row if camera == "cam_c"
|
||||
][0]
|
||||
self.assertEqual(cam_c[0], 0)
|
||||
|
||||
def test_portrait_reservation_only_applies_to_next_row(self):
|
||||
"""Portrait reservations should not push later rows after the span ends."""
|
||||
manager = self._build_manager(
|
||||
{
|
||||
"cam_a": (1280, 720),
|
||||
"cam_p": (360, 640),
|
||||
"cam_b": (1280, 720),
|
||||
"cam_c": (1280, 720),
|
||||
"cam_d": (1280, 720),
|
||||
"cam_e": (1280, 720),
|
||||
}
|
||||
)
|
||||
|
||||
layout = manager.calculate_layout(
|
||||
["cam_a", "cam_p", "cam_b", "cam_c", "cam_d", "cam_e"],
|
||||
3,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(layout)
|
||||
assert layout is not None
|
||||
self._assert_no_overlaps(layout)
|
||||
|
||||
cam_e = [
|
||||
position for row in layout for camera, position in row if camera == "cam_e"
|
||||
][0]
|
||||
self.assertEqual(cam_e[0], 0)
|
||||
|
||||
def test_multiple_portraits_reserve_distinct_ranges(self):
|
||||
"""Multiple portrait cameras in one row should reserve separate spans below them."""
|
||||
manager = self._build_manager(
|
||||
{
|
||||
"cam_a": (640, 480),
|
||||
"cam_p1": (360, 640),
|
||||
"cam_p2": (360, 640),
|
||||
"cam_b": (640, 480),
|
||||
"cam_c": (1280, 720),
|
||||
"cam_d": (640, 480),
|
||||
}
|
||||
)
|
||||
|
||||
layout = manager.calculate_layout(
|
||||
["cam_a", "cam_p1", "cam_p2", "cam_b", "cam_c", "cam_d"],
|
||||
4,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(layout)
|
||||
assert layout is not None
|
||||
self._assert_no_overlaps(layout)
|
||||
|
||||
def test_two_landscapes_then_portrait_then_two_landscapes(self):
|
||||
"""A portrait after two landscapes should reserve only its own tail span."""
|
||||
manager = self._build_manager(
|
||||
{
|
||||
"cam_a": (1280, 720),
|
||||
"cam_b": (1280, 720),
|
||||
"cam_p": (360, 640),
|
||||
"cam_c": (1280, 720),
|
||||
"cam_d": (1280, 720),
|
||||
}
|
||||
)
|
||||
|
||||
layout = manager.calculate_layout(
|
||||
["cam_a", "cam_b", "cam_p", "cam_c", "cam_d"],
|
||||
3,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(layout)
|
||||
assert layout is not None
|
||||
self._assert_no_overlaps(layout)
|
||||
|
||||
cam_c = [
|
||||
position for row in layout for camera, position in row if camera == "cam_c"
|
||||
][0]
|
||||
cam_d = [
|
||||
position for row in layout for camera, position in row if camera == "cam_d"
|
||||
][0]
|
||||
self.assertEqual(cam_c[0], 0)
|
||||
self.assertEqual(cam_d[0], cam_c[0] + cam_c[2])
|
||||
|
||||
@@ -401,7 +401,7 @@ class _FakeAsyncClient:
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
def stream(self, method, url, json=None):
|
||||
def stream(self, method, url, json=None, headers=None):
|
||||
return _FakeStreamCtx(self._lines)
|
||||
|
||||
|
||||
|
||||
@@ -169,6 +169,13 @@ 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,6 +57,7 @@ const record: SectionConfigOverrides = {
|
||||
"ui:options": {
|
||||
suppressMultiSchema: true,
|
||||
ffmpegPresetField: "hwaccel_args",
|
||||
ffmpegGlobalFieldPath: "export.hwaccel_args",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -17,3 +17,4 @@ export {
|
||||
isSubtreeModified,
|
||||
} from "./overrides";
|
||||
export { getSizedFieldClassName } from "./fieldSizing";
|
||||
export { getNumericInputMode } from "./inputMode";
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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,6 +120,12 @@ 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;
|
||||
@@ -131,11 +137,18 @@ export function FfmpegArgsWidget(props: WidgetProps) {
|
||||
|
||||
// Extract the global value for this specific field to detect inheritance
|
||||
const globalFieldValue = useMemo(() => {
|
||||
if (!showUseGlobalSetting || !formContext?.globalValue || !presetField) {
|
||||
if (
|
||||
!showUseGlobalSetting ||
|
||||
!formContext?.globalValue ||
|
||||
!globalFieldPath
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return get(formContext.globalValue as Record<string, unknown>, presetField);
|
||||
}, [showUseGlobalSetting, formContext?.globalValue, presetField]);
|
||||
return get(
|
||||
formContext.globalValue as Record<string, unknown>,
|
||||
globalFieldPath,
|
||||
);
|
||||
}, [showUseGlobalSetting, formContext?.globalValue, globalFieldPath]);
|
||||
|
||||
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 { getSizedFieldClassName } from "../utils";
|
||||
import { getNumericInputMode, getSizedFieldClassName } from "../utils";
|
||||
|
||||
export function TextWidget(props: WidgetProps) {
|
||||
const {
|
||||
@@ -28,6 +28,7 @@ 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,8 +127,12 @@ export default function ObjectTrackOverlay({
|
||||
},
|
||||
);
|
||||
|
||||
const getZonesFriendlyNames = (zones: string[], config: FrigateConfig) => {
|
||||
return zones?.map((zone) => resolveZoneName(config, zone)) ?? [];
|
||||
const getZonesFriendlyNames = (
|
||||
zones: string[],
|
||||
config: FrigateConfig,
|
||||
cameraId?: string,
|
||||
) => {
|
||||
return zones?.map((zone) => resolveZoneName(config, zone, cameraId)) ?? [];
|
||||
};
|
||||
|
||||
const timelineResults = useMemo(() => {
|
||||
@@ -151,7 +155,7 @@ export default function ObjectTrackOverlay({
|
||||
data: {
|
||||
...event.data,
|
||||
zones_friendly_names: config
|
||||
? getZonesFriendlyNames(event.data?.zones, config)
|
||||
? getZonesFriendlyNames(event.data?.zones, config, event.camera)
|
||||
: [],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -61,7 +61,11 @@ export function ObjectPath({
|
||||
...pos.lifecycle_item?.data,
|
||||
zones_friendly_names: pos.lifecycle_item?.data.zones.map(
|
||||
(zone) => {
|
||||
return resolveZoneName(config, zone);
|
||||
return resolveZoneName(
|
||||
config,
|
||||
zone,
|
||||
pos.lifecycle_item?.camera,
|
||||
);
|
||||
},
|
||||
),
|
||||
},
|
||||
|
||||
@@ -301,11 +301,19 @@ export function TrackingDetails({
|
||||
[recordings, actualVideoStart],
|
||||
);
|
||||
|
||||
eventSequence?.map((event) => {
|
||||
event.data.zones_friendly_names = event.data?.zones?.map((zone) => {
|
||||
return resolveZoneName(config, zone);
|
||||
});
|
||||
});
|
||||
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],
|
||||
);
|
||||
|
||||
// Use manualOverride (set when seeking in image mode) if present so
|
||||
// lifecycle rows and overlays follow image-mode seeks. Otherwise fall
|
||||
@@ -849,9 +857,9 @@ export function TrackingDetails({
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
{!eventSequence ? (
|
||||
{!sequence ? (
|
||||
<ActivityIndicator className="size-2" size={2} />
|
||||
) : eventSequence.length === 0 ? (
|
||||
) : sequence.length === 0 ? (
|
||||
<div className="py-2 text-muted-foreground">
|
||||
{t("detail.noObjectDetailData", { ns: "views/events" })}
|
||||
</div>
|
||||
@@ -871,7 +879,7 @@ export function TrackingDetails({
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{eventSequence.map((item, idx) => {
|
||||
{sequence.map((item, idx) => {
|
||||
return (
|
||||
<div
|
||||
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
|
||||
|
||||
@@ -1032,7 +1032,7 @@ function ObjectTimeline({
|
||||
data: {
|
||||
...event.data,
|
||||
zones_friendly_names: event.data?.zones?.map((zone) =>
|
||||
resolveZoneName(config, zone),
|
||||
resolveZoneName(config, zone, event.camera),
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -102,6 +102,91 @@ 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
|
||||
@@ -290,7 +375,7 @@ export function useConfigOverride({
|
||||
"camera",
|
||||
buildHiddenFieldContext(config, "camera", cameraName),
|
||||
);
|
||||
const collapsedGlobal = stripHiddenPaths(
|
||||
const collapsedGlobalRaw = stripHiddenPaths(
|
||||
collapseEmpty(normalizedGlobalValue),
|
||||
hiddenFields,
|
||||
);
|
||||
@@ -300,9 +385,15 @@ export function useConfigOverride({
|
||||
);
|
||||
const collapsedCamera = stripAutoDerivedMissingFromGlobal(
|
||||
sectionPath,
|
||||
collapsedGlobal,
|
||||
collapsedGlobalRaw,
|
||||
collapsedCameraRaw,
|
||||
);
|
||||
const collapsedGlobal = withDefaultFilters(
|
||||
sectionPath,
|
||||
collapsedGlobalRaw,
|
||||
collapsedCamera,
|
||||
schema,
|
||||
);
|
||||
|
||||
const comparisonGlobal = compareFields
|
||||
? pickFields(collapsedGlobal, compareFields)
|
||||
@@ -446,7 +537,7 @@ export function useAllCameraOverrides(
|
||||
"camera",
|
||||
buildHiddenFieldContext(config, "camera", cameraName),
|
||||
);
|
||||
const collapsedGlobal = stripHiddenPaths(
|
||||
const collapsedGlobalRaw = stripHiddenPaths(
|
||||
collapseEmpty(globalValue),
|
||||
hiddenFields,
|
||||
);
|
||||
@@ -456,9 +547,15 @@ export function useAllCameraOverrides(
|
||||
);
|
||||
const collapsedCamera = stripAutoDerivedMissingFromGlobal(
|
||||
key,
|
||||
collapsedGlobal,
|
||||
collapsedGlobalRaw,
|
||||
collapsedCameraRaw,
|
||||
);
|
||||
const collapsedGlobal = withDefaultFilters(
|
||||
key,
|
||||
collapsedGlobalRaw,
|
||||
collapsedCamera,
|
||||
schema,
|
||||
);
|
||||
const comparisonGlobal = compareFields
|
||||
? pickFields(collapsedGlobal, compareFields)
|
||||
: collapsedGlobal;
|
||||
@@ -714,9 +811,15 @@ export function useCamerasOverridingSection(
|
||||
globalValue,
|
||||
collapseEmpty(cameraSectionValues[idx]),
|
||||
);
|
||||
for (const delta of collectFieldDeltas(
|
||||
const effectiveGlobalValue = withDefaultFilters(
|
||||
sectionPath,
|
||||
globalValue,
|
||||
cameraValue,
|
||||
schema,
|
||||
);
|
||||
for (const delta of collectFieldDeltas(
|
||||
effectiveGlobalValue,
|
||||
cameraValue,
|
||||
compareFields,
|
||||
)) {
|
||||
if (isCrossCameraIgnoredPath(delta.fieldPath)) continue;
|
||||
@@ -738,7 +841,7 @@ export function useCamerasOverridingSection(
|
||||
if (deltasByPath.has(path)) continue;
|
||||
if (isCrossCameraIgnoredPath(path)) continue;
|
||||
if (!isPathAllowed(path, compareFields)) continue;
|
||||
const g = get(globalValue, path);
|
||||
const g = get(effectiveGlobalValue, path);
|
||||
const p = get(normalizedProfile, path);
|
||||
if (!isEqual(g, p)) {
|
||||
deltasByPath.set(path, {
|
||||
@@ -791,18 +894,24 @@ export function useCameraSectionDeltas(
|
||||
const sectionMeta = OVERRIDABLE_SECTIONS.find((s) => s.key === sectionPath);
|
||||
const compareFields = sectionMeta?.compareFields;
|
||||
|
||||
const globalValue = collapseEmpty(
|
||||
const rawGlobalValue = collapseEmpty(
|
||||
getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema),
|
||||
);
|
||||
const cameraValue = stripAutoDerivedMissingFromGlobal(
|
||||
sectionPath,
|
||||
globalValue,
|
||||
rawGlobalValue,
|
||||
collapseEmpty(
|
||||
normalizeConfigValue(
|
||||
getBaseCameraSectionValue(config, cameraName, sectionPath),
|
||||
),
|
||||
),
|
||||
);
|
||||
const globalValue = withDefaultFilters(
|
||||
sectionPath,
|
||||
rawGlobalValue,
|
||||
cameraValue,
|
||||
schema,
|
||||
);
|
||||
|
||||
const hiddenFields = getEffectiveHiddenFields(
|
||||
sectionPath,
|
||||
|
||||
Reference in New Issue
Block a user