Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub ccb384cc33 Bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-14 11:32:22 +00:00
20 changed files with 185 additions and 572 deletions
+4 -4
View File
@@ -19,7 +19,7 @@ jobs:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 20.x
- run: npm install
@@ -38,7 +38,7 @@ jobs:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 20.x
- run: npm install
@@ -57,7 +57,7 @@ jobs:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 20.x
- run: npm install
@@ -112,7 +112,7 @@ jobs:
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 20.x
- name: Install devcontainer cli
+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 = []
+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 = ""
+100 -80
View File
@@ -595,92 +595,112 @@ class BirdsEyeFrameManager:
) -> list[list[Any]] | None:
"""Calculate the optimal layout for 2+ cameras."""
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 reserved_start, reserved_end in sorted(reserved_ranges):
if x >= reserved_end:
continue
if x + width <= reserved_start:
return x
x = max(x, reserved_end)
if x + width <= max_width:
return x
return None
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
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
max_height = 0
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]
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]
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)
# 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
final_row.append((cameras[0], (x, y, scaled_width, scaled_height)))
x += scaled_width
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,
)
)
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 portrait:
starting_x = camera_aspect_x
else:
max_y = max(
max_y,
camera_aspect_y,
)
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)
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 portrait:
reserved_ranges.setdefault(row_index + 1, []).append(
(x, row_x)
)
if x + camera_aspect_x > canvas_aspect_x:
return None
break
camera_layout[y_i].append(
(
camera,
camera_aspect_x / camera_aspect_y,
)
)
x += camera_aspect_x
if current_row:
candidate_layout.append(current_row)
current_row = []
if y + max_y > canvas_aspect_y:
return None
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)
row_height = int(self.canvas.height / coefficient)
total_width, total_height, standard_candidate_layout = map_layout(
camera_layout, row_height
)
if not standard_candidate_layout:
# if standard layout didn't work
@@ -689,9 +709,9 @@ class BirdsEyeFrameManager:
total_width / self.canvas.width,
total_height / self.canvas.height,
)
row_height = max(1, int(row_height / scale_down_percent))
row_height = int(row_height / scale_down_percent)
total_width, total_height, standard_candidate_layout = map_layout(
row_height
camera_layout, row_height
)
if not standard_candidate_layout:
@@ -705,8 +725,8 @@ class BirdsEyeFrameManager:
1 / (total_width / self.canvas.width),
1 / (total_height / self.canvas.height),
)
row_height = max(1, int(row_height * scale_up_percent))
_, _, scaled_layout = map_layout(row_height)
row_height = int(row_height * scale_up_percent)
_, _, scaled_layout = map_layout(camera_layout, row_height)
if scaled_layout:
return scaled_layout
+2 -156
View File
@@ -1,64 +1,11 @@
"""Tests for Birdseye canvas sizing and layout behavior."""
"""Test camera user and password cleanup."""
import unittest
from multiprocessing import Event
from frigate.config import FrigateConfig
from frigate.output.birdseye import BirdsEyeFrameManager, get_canvas_shape
from frigate.output.birdseye import 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
@@ -98,104 +45,3 @@ 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])
+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)
@@ -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,