diff --git a/frigate/debug_replay.py b/frigate/debug_replay.py index 17a727c873..f8e801c3f3 100644 --- a/frigate/debug_replay.py +++ b/frigate/debug_replay.py @@ -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): diff --git a/frigate/ffmpeg_presets.py b/frigate/ffmpeg_presets.py index d005c80980..445cfd4df3 100644 --- a/frigate/ffmpeg_presets.py +++ b/frigate/ffmpeg_presets.py @@ -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}", diff --git a/frigate/genai/plugins/llama_cpp.py b/frigate/genai/plugins/llama_cpp.py index 5b338fcfd3..af3ecc9b18 100644 --- a/frigate/genai/plugins/llama_cpp.py +++ b/frigate/genai/plugins/llama_cpp.py @@ -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(): diff --git a/frigate/test/test_genai_providers.py b/frigate/test/test_genai_providers.py index 77a948a74c..d73d632f0a 100644 --- a/frigate/test/test_genai_providers.py +++ b/frigate/test/test_genai_providers.py @@ -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) diff --git a/web/src/hooks/use-config-override.ts b/web/src/hooks/use-config-override.ts index 9b28dc98c7..8b4e4d2da9 100644 --- a/web/src/hooks/use-config-override.ts +++ b/web/src/hooks/use-config-override.ts @@ -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 `
.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,