mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-10 08:42:49 +03:00
Compare commits
8
Commits
03d4e16768
...
6acadf660f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6acadf660f | ||
|
|
a8eca68438 | ||
|
|
81b53b7835 | ||
|
|
5c38f6ef96 | ||
|
|
38cabaa6aa | ||
|
|
f7528937e0 | ||
|
|
f4e0781ea5 | ||
|
|
a9e6f3b2e3 |
@@ -25,7 +25,6 @@ peewee_migrate == 1.14.*
|
||||
psutil == 7.1.*
|
||||
pydantic == 2.10.*
|
||||
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||
pytz == 2025.*
|
||||
pyzmq == 26.2.*
|
||||
ruamel.yaml == 0.18.*
|
||||
tzlocal == 5.2
|
||||
|
||||
Vendored
+7
-7
@@ -2013,7 +2013,7 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
default: utc
|
||||
default: UTC
|
||||
title: Timezone
|
||||
responses:
|
||||
'200':
|
||||
@@ -3055,7 +3055,7 @@ paths:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
default: utc
|
||||
default: UTC
|
||||
title: Timezone
|
||||
responses:
|
||||
'200':
|
||||
@@ -4153,7 +4153,7 @@ paths:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
default: utc
|
||||
default: UTC
|
||||
title: Timezone
|
||||
responses:
|
||||
'200':
|
||||
@@ -4405,7 +4405,7 @@ paths:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
default: utc
|
||||
default: UTC
|
||||
title: Timezone
|
||||
- name: min_score
|
||||
in: query
|
||||
@@ -4485,7 +4485,7 @@ paths:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
default: utc
|
||||
default: UTC
|
||||
title: Timezone
|
||||
- name: has_clip
|
||||
in: query
|
||||
@@ -6780,7 +6780,7 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
default: utc
|
||||
default: UTC
|
||||
title: Timezone
|
||||
- name: cameras
|
||||
in: query
|
||||
@@ -6830,7 +6830,7 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
default: utc
|
||||
default: UTC
|
||||
title: Timezone
|
||||
responses:
|
||||
'200':
|
||||
|
||||
+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 = []
|
||||
|
||||
|
||||
@@ -7,4 +7,4 @@ class AppTimelineHourlyQueryParameters(BaseModel):
|
||||
after: float | None = None
|
||||
before: float | None = None
|
||||
limit: int | None = 200
|
||||
timezone: str | None = "utc"
|
||||
timezone: str | None = "UTC"
|
||||
|
||||
@@ -39,7 +39,7 @@ class EventsQueryParams(BaseModel):
|
||||
max_length: float | None = None
|
||||
event_id: str | None = None
|
||||
sort: str | None = None
|
||||
timezone: str | None = "utc"
|
||||
timezone: str | None = "UTC"
|
||||
|
||||
|
||||
class EventsSearchQueryParams(BaseModel):
|
||||
@@ -66,7 +66,7 @@ class EventsSearchQueryParams(BaseModel):
|
||||
has_clip: bool | None = None
|
||||
has_snapshot: bool | None = None
|
||||
is_submitted: bool | None = None
|
||||
timezone: str | None = "utc"
|
||||
timezone: str | None = "UTC"
|
||||
min_score: float | None = None
|
||||
max_score: float | None = None
|
||||
min_speed: float | None = None
|
||||
@@ -76,6 +76,6 @@ class EventsSearchQueryParams(BaseModel):
|
||||
|
||||
|
||||
class EventsSummaryQueryParams(BaseModel):
|
||||
timezone: str | None = "utc"
|
||||
timezone: str | None = "UTC"
|
||||
has_clip: int | None = None
|
||||
has_snapshot: int | None = None
|
||||
|
||||
@@ -3,7 +3,7 @@ from pydantic.json_schema import SkipJsonSchema
|
||||
|
||||
|
||||
class MediaRecordingsSummaryQueryParams(BaseModel):
|
||||
timezone: str = "utc"
|
||||
timezone: str = "UTC"
|
||||
cameras: str | None = "all"
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class ReviewSummaryQueryParams(BaseModel):
|
||||
cameras: str = "all"
|
||||
labels: str = "all"
|
||||
zones: str = "all"
|
||||
timezone: str = "utc"
|
||||
timezone: str = "UTC"
|
||||
|
||||
|
||||
class ReviewActivityMotionQueryParams(BaseModel):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -75,9 +76,20 @@ def create_fastapi_app(
|
||||
profile_manager: ProfileManager | None = None,
|
||||
enforce_default_admin: bool = True,
|
||||
):
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("FastAPI started")
|
||||
asyncio.create_task(
|
||||
debug_replay_auto_stop_watchdog(
|
||||
replay_manager, frigate_config, config_publisher
|
||||
)
|
||||
)
|
||||
yield
|
||||
|
||||
logger.info("Starting FastAPI app")
|
||||
app = FastAPI(
|
||||
debug=False,
|
||||
lifespan=lifespan,
|
||||
swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"},
|
||||
dependencies=[Depends(require_admin_by_default())]
|
||||
if enforce_default_admin
|
||||
@@ -113,15 +125,6 @@ def create_fastapi_app(
|
||||
database.close()
|
||||
return response
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
logger.info("FastAPI started")
|
||||
asyncio.create_task(
|
||||
debug_replay_auto_stop_watchdog(
|
||||
replay_manager, frigate_config, config_publisher
|
||||
)
|
||||
)
|
||||
|
||||
# Rate limiter (used for login endpoint)
|
||||
if frigate_config.auth.failed_login_rate_limit is None:
|
||||
limiter.enabled = False
|
||||
|
||||
@@ -11,10 +11,10 @@ from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path as FilePath
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytz
|
||||
from fastapi import APIRouter, Depends, Path, Query, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from pathvalidate import sanitize_filename
|
||||
@@ -53,6 +53,7 @@ from frigate.util.file import (
|
||||
)
|
||||
from frigate.util.image import get_image_from_recording, get_image_quality_params
|
||||
from frigate.util.media import get_keyframe_before
|
||||
from frigate.util.time import get_normalized_tz_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -713,7 +714,9 @@ async def vod_hour(
|
||||
parts = year_month.split("-")
|
||||
start_date = (
|
||||
datetime(int(parts[0]), int(parts[1]), day, hour, tzinfo=UTC)
|
||||
- datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
|
||||
- datetime.now(
|
||||
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
|
||||
).utcoffset()
|
||||
)
|
||||
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
|
||||
start_ts = start_date.timestamp()
|
||||
|
||||
@@ -5,8 +5,8 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytz
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
@@ -22,6 +22,7 @@ from frigate.api.defs.response.preview_response import (
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE
|
||||
from frigate.models import Previews
|
||||
from frigate.util.time import get_normalized_tz_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -126,7 +127,9 @@ def preview_hour(
|
||||
parts = year_month.split("-")
|
||||
start_date = (
|
||||
datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=UTC)
|
||||
- datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
|
||||
- datetime.now(
|
||||
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
|
||||
).utcoffset()
|
||||
)
|
||||
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
|
||||
start_ts = start_date.timestamp()
|
||||
|
||||
@@ -120,7 +120,7 @@ def all_recordings_summary(
|
||||
@router.get(
|
||||
"/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)]
|
||||
)
|
||||
async def recordings_summary(camera_name: str, timezone: str = "utc"):
|
||||
async def recordings_summary(camera_name: str, timezone: str = "UTC"):
|
||||
"""Returns hourly summary for recordings of given camera"""
|
||||
|
||||
time_range_query = (
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -12,8 +12,8 @@ import threading
|
||||
from collections.abc import Callable
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
import pytz # type: ignore[import-untyped]
|
||||
from peewee import DoesNotExist
|
||||
|
||||
from frigate.config import FfmpegConfig, FrigateConfig
|
||||
@@ -31,7 +31,7 @@ from frigate.ffmpeg_presets import (
|
||||
)
|
||||
from frigate.models import Export, Previews, Recordings, ReviewSegment
|
||||
from frigate.util.ffmpeg import run_ffmpeg_with_progress
|
||||
from frigate.util.time import is_current_hour
|
||||
from frigate.util.time import get_normalized_tz_name, is_current_hour
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -371,8 +371,8 @@ class RecordingExporter(threading.Thread):
|
||||
tz_name = self.config.ui.timezone
|
||||
if tz_name:
|
||||
try:
|
||||
tz = pytz.timezone(tz_name)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
tz = ZoneInfo(get_normalized_tz_name(tz_name))
|
||||
except (ValueError, ZoneInfoNotFoundError):
|
||||
tz = None
|
||||
if tz is not None:
|
||||
return datetime.datetime.fromtimestamp(timestamp, tz=tz).strftime(
|
||||
@@ -533,8 +533,8 @@ class RecordingExporter(threading.Thread):
|
||||
tz: datetime.tzinfo | None = None
|
||||
if tz_name:
|
||||
try:
|
||||
tz = pytz.timezone(tz_name)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
tz = ZoneInfo(get_normalized_tz_name(tz_name))
|
||||
except (ValueError, ZoneInfoNotFoundError):
|
||||
tz = None
|
||||
if tz is None:
|
||||
tz = datetime.UTC
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Unit tests for recordings/media API endpoints."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytz
|
||||
from fastapi import Request
|
||||
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||
@@ -51,16 +51,16 @@ class TestHttpMedia(BaseTestHttp):
|
||||
In 2024, DST in America/New_York transitions on March 10, 2024 at 2:00 AM
|
||||
Clocks spring forward from 2:00 AM to 3:00 AM (EST to EDT)
|
||||
"""
|
||||
tz = pytz.timezone("America/New_York")
|
||||
tz = ZoneInfo("America/New_York")
|
||||
|
||||
# March 9, 2024 at 12:00 PM EST (before DST)
|
||||
march_9_noon = tz.localize(datetime(2024, 3, 9, 12, 0, 0)).timestamp()
|
||||
march_9_noon = datetime(2024, 3, 9, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
# March 10, 2024 at 12:00 PM EDT (after DST transition)
|
||||
march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp()
|
||||
march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
# March 11, 2024 at 12:00 PM EDT (after DST)
|
||||
march_11_noon = tz.localize(datetime(2024, 3, 11, 12, 0, 0)).timestamp()
|
||||
march_11_noon = datetime(2024, 3, 11, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Insert recordings for each day
|
||||
@@ -124,19 +124,16 @@ class TestHttpMedia(BaseTestHttp):
|
||||
In 2024, DST in America/New_York transitions on November 3, 2024 at 2:00 AM
|
||||
Clocks fall back from 2:00 AM to 1:00 AM (EDT to EST)
|
||||
"""
|
||||
tz = pytz.timezone("America/New_York")
|
||||
tz = ZoneInfo("America/New_York")
|
||||
|
||||
# November 2, 2024 at 12:00 PM EDT (before DST transition)
|
||||
nov_2_noon = tz.localize(datetime(2024, 11, 2, 12, 0, 0)).timestamp()
|
||||
nov_2_noon = datetime(2024, 11, 2, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
# November 3, 2024 at 12:00 PM EST (after DST transition)
|
||||
# Need to specify is_dst=False to get the time after fall back
|
||||
nov_3_noon = tz.localize(
|
||||
datetime(2024, 11, 3, 12, 0, 0), is_dst=False
|
||||
).timestamp()
|
||||
nov_3_noon = datetime(2024, 11, 3, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
# November 4, 2024 at 12:00 PM EST (after DST)
|
||||
nov_4_noon = tz.localize(datetime(2024, 11, 4, 12, 0, 0)).timestamp()
|
||||
nov_4_noon = datetime(2024, 11, 4, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Insert recordings for each day
|
||||
@@ -197,13 +194,13 @@ class TestHttpMedia(BaseTestHttp):
|
||||
"""
|
||||
Test recordings summary with multiple cameras across DST boundary.
|
||||
"""
|
||||
tz = pytz.timezone("America/New_York")
|
||||
tz = ZoneInfo("America/New_York")
|
||||
|
||||
# March 9, 2024 at 10:00 AM EST (before DST)
|
||||
march_9_morning = tz.localize(datetime(2024, 3, 9, 10, 0, 0)).timestamp()
|
||||
march_9_morning = datetime(2024, 3, 9, 10, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
# March 10, 2024 at 3:00 PM EDT (after DST transition)
|
||||
march_10_afternoon = tz.localize(datetime(2024, 3, 10, 15, 0, 0)).timestamp()
|
||||
march_10_afternoon = datetime(2024, 3, 10, 15, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Override allowed cameras for this test to include both
|
||||
@@ -266,15 +263,15 @@ class TestHttpMedia(BaseTestHttp):
|
||||
"""
|
||||
Test recordings that span the exact DST transition time.
|
||||
"""
|
||||
tz = pytz.timezone("America/New_York")
|
||||
tz = ZoneInfo("America/New_York")
|
||||
|
||||
# March 10, 2024 at 1:00 AM EST (1 hour before DST transition)
|
||||
# At 2:00 AM, clocks jump to 3:00 AM
|
||||
before_transition = tz.localize(datetime(2024, 3, 10, 1, 0, 0)).timestamp()
|
||||
before_transition = datetime(2024, 3, 10, 1, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
# Recording that spans the transition (1:00 AM to 3:30 AM EDT)
|
||||
# This is 1.5 hours of actual time but spans the "missing" hour
|
||||
after_transition = tz.localize(datetime(2024, 3, 10, 3, 30, 0)).timestamp()
|
||||
after_transition = datetime(2024, 3, 10, 3, 30, 0, tzinfo=tz).timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
Recordings.insert(
|
||||
@@ -334,7 +331,7 @@ class TestHttpMedia(BaseTestHttp):
|
||||
|
||||
# Test with UTC timezone
|
||||
response = client.get(
|
||||
"/recordings/summary", params={"timezone": "utc", "cameras": "all"}
|
||||
"/recordings/summary", params={"timezone": "UTC", "cameras": "all"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -365,8 +362,8 @@ class TestHttpMedia(BaseTestHttp):
|
||||
"""
|
||||
Test recordings summary filtered to a single camera.
|
||||
"""
|
||||
tz = pytz.timezone("America/New_York")
|
||||
march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp()
|
||||
tz = ZoneInfo("America/New_York")
|
||||
march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Insert recordings for both cameras
|
||||
|
||||
@@ -250,7 +250,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
"cameras": "front_door",
|
||||
"labels": "all",
|
||||
"zones": "all",
|
||||
"timezone": "utc",
|
||||
"timezone": "UTC",
|
||||
}
|
||||
response = client.get("/review/summary", params=params)
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import numpy as np
|
||||
from pydantic import parse_obj_as
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import frigate.detectors as detectors
|
||||
import frigate.object_detection.base
|
||||
@@ -19,8 +19,8 @@ class TestLocalObjectDetector(unittest.TestCase):
|
||||
"frigate.detectors.api_types",
|
||||
{det_type: Mock() for det_type in DetectorTypeEnum},
|
||||
):
|
||||
test_cfg = parse_obj_as(
|
||||
DetectorConfig, ({"type": det_type, "model": {}})
|
||||
test_cfg = TypeAdapter(DetectorConfig).validate_python(
|
||||
{"type": det_type, "model": {}}
|
||||
)
|
||||
test_cfg.model.path = "/test/modelpath"
|
||||
test_obj = frigate.object_detection.base.LocalObjectDetector(
|
||||
@@ -44,7 +44,9 @@ class TestLocalObjectDetector(unittest.TestCase):
|
||||
TEST_DATA = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||
TEST_DETECT_RESULT = np.ndarray([1, 2, 4, 8, 16, 32])
|
||||
test_obj_detect = frigate.object_detection.base.LocalObjectDetector(
|
||||
detector_config=parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}})
|
||||
detector_config=TypeAdapter(DetectorConfig).validate_python(
|
||||
{"type": "cpu", "model": {}}
|
||||
)
|
||||
)
|
||||
|
||||
mock_det_api = mock_cputfl.return_value
|
||||
@@ -67,7 +69,9 @@ class TestLocalObjectDetector(unittest.TestCase):
|
||||
TEST_DATA = np.zeros((1, 32, 32, 3), np.uint8)
|
||||
TEST_DETECT_RESULT = np.ndarray([1, 2, 4, 8, 16, 32])
|
||||
|
||||
test_cfg = parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}})
|
||||
test_cfg = TypeAdapter(DetectorConfig).validate_python(
|
||||
{"type": "cpu", "model": {}}
|
||||
)
|
||||
test_cfg.model.input_tensor = InputTensorEnum.nchw
|
||||
|
||||
test_obj_detect = frigate.object_detection.base.LocalObjectDetector(
|
||||
@@ -116,7 +120,9 @@ class TestLocalObjectDetector(unittest.TestCase):
|
||||
"label-5",
|
||||
]
|
||||
|
||||
test_cfg = parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}})
|
||||
test_cfg = TypeAdapter(DetectorConfig).validate_python(
|
||||
{"type": "cpu", "model": {}}
|
||||
)
|
||||
test_cfg.model = ModelConfig()
|
||||
test_obj_detect = frigate.object_detection.base.LocalObjectDetector(
|
||||
detector_config=test_cfg,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import unittest
|
||||
|
||||
from frigate.util.time import get_normalized_tz_name
|
||||
|
||||
|
||||
class TestGetNormalizedTzName(unittest.TestCase):
|
||||
def test_valid(self):
|
||||
cases = [
|
||||
("utc", "UTC"),
|
||||
("america/new_york", "America/New_York"),
|
||||
]
|
||||
|
||||
for tz_name, expected in cases:
|
||||
with self.subTest(tz_name=tz_name):
|
||||
self.assertEqual(get_normalized_tz_name(tz_name), expected)
|
||||
|
||||
def test_invalid(self):
|
||||
with self.assertRaisesRegex(ValueError, r"Unknown timezone: foo/bar"):
|
||||
get_normalized_tz_name("foo/bar")
|
||||
+23
-9
@@ -2,17 +2,31 @@
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from zoneinfo import ZoneInfoNotFoundError
|
||||
from typing import Final
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones
|
||||
|
||||
import pytz
|
||||
from tzlocal import get_localzone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TIMEZONE_CASEFOLD_INDEX: Final = {
|
||||
timezone.casefold(): timezone for timezone in available_timezones()
|
||||
}
|
||||
|
||||
|
||||
def get_normalized_tz_name(tz_name: str) -> str:
|
||||
tz = TIMEZONE_CASEFOLD_INDEX.get(tz_name.casefold())
|
||||
if tz:
|
||||
return tz
|
||||
|
||||
raise ValueError(f"Unknown timezone: {tz_name}")
|
||||
|
||||
|
||||
def get_tz_modifiers(tz_name: str) -> tuple[str, str, float]:
|
||||
seconds_offset = (
|
||||
datetime.datetime.now(pytz.timezone(tz_name)).utcoffset().total_seconds()
|
||||
datetime.datetime.now(ZoneInfo(get_normalized_tz_name(tz_name)))
|
||||
.utcoffset()
|
||||
.total_seconds()
|
||||
)
|
||||
hours_offset = int(seconds_offset / 60 / 60)
|
||||
minutes_offset = int(seconds_offset / 60 - hours_offset * 60)
|
||||
@@ -25,7 +39,7 @@ def get_tomorrow_at_time(hour: int) -> datetime.datetime:
|
||||
"""Returns the datetime of the following day at 2am."""
|
||||
try:
|
||||
tomorrow = datetime.datetime.now(get_localzone()) + datetime.timedelta(days=1)
|
||||
except ZoneInfoNotFoundError:
|
||||
except (ValueError, ZoneInfoNotFoundError):
|
||||
tomorrow = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1)
|
||||
logger.warning(
|
||||
"Using utc for maintenance due to missing or incorrect timezone set"
|
||||
@@ -45,7 +59,7 @@ def is_current_hour(timestamp: int) -> bool:
|
||||
|
||||
def get_dst_transitions(
|
||||
tz_name: str, start_time: float, end_time: float
|
||||
) -> list[tuple[float, float]]:
|
||||
) -> list[tuple[float, float, int]]:
|
||||
"""
|
||||
Find DST transition points and return time periods with consistent offsets.
|
||||
|
||||
@@ -59,8 +73,8 @@ def get_dst_transitions(
|
||||
continuous periods with the same UTC offset
|
||||
"""
|
||||
try:
|
||||
tz = pytz.timezone(tz_name)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
tz = ZoneInfo(get_normalized_tz_name(tz_name))
|
||||
except (ValueError, ZoneInfoNotFoundError):
|
||||
# If timezone is invalid, return single period with no offset
|
||||
return [(start_time, end_time, 0)]
|
||||
|
||||
@@ -68,14 +82,14 @@ def get_dst_transitions(
|
||||
current = start_time
|
||||
|
||||
# Get initial offset
|
||||
dt = datetime.datetime.utcfromtimestamp(current).replace(tzinfo=pytz.UTC)
|
||||
dt = datetime.datetime.fromtimestamp(current, tz=datetime.UTC)
|
||||
local_dt = dt.astimezone(tz)
|
||||
prev_offset = local_dt.utcoffset().total_seconds()
|
||||
period_start = start_time
|
||||
|
||||
# Check each day for offset changes
|
||||
while current <= end_time:
|
||||
dt = datetime.datetime.utcfromtimestamp(current).replace(tzinfo=pytz.UTC)
|
||||
dt = datetime.datetime.fromtimestamp(current, tz=datetime.UTC)
|
||||
local_dt = dt.astimezone(tz)
|
||||
current_offset = local_dt.utcoffset().total_seconds()
|
||||
|
||||
|
||||
@@ -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