mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-18 18:01:14 +03:00
Miscellaneous fixes (0.18 beta) (#23718)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* Cleanup llama.cpp and use api key when configured * don't report auto-populated object and audio filters as camera overrides * derive stale replay cameras from bounded directory listings to avoid scanning all clips at startup * fix tests * add -vaapi_device to the birdseye vaapi encode preset so hwupload can initialize on ffmpeg 8 --------- Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
parent
81b53b7835
commit
a8eca68438
@ -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():
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user