diff --git a/frigate/app.py b/frigate/app.py
index 488f121e6..b6a94ed66 100644
--- a/frigate/app.py
+++ b/frigate/app.py
@@ -428,18 +428,11 @@ class FrigateApp:
self.camera_maintainer.start()
def start_audio_processor(self) -> None:
- audio_cameras = [
- c
- for c in self.config.cameras.values()
- if c.enabled and c.audio.enabled_in_config
- ]
-
- if audio_cameras:
- self.audio_process = AudioProcessor(
- self.config, audio_cameras, self.camera_metrics, self.stop_event
- )
- self.audio_process.start()
- self.processes["audio_detector"] = self.audio_process.pid or 0
+ self.audio_process = AudioProcessor(
+ self.config, self.camera_metrics, self.stop_event
+ )
+ self.audio_process.start()
+ self.processes["audio_detector"] = self.audio_process.pid or 0
def start_timeline_processor(self) -> None:
self.timeline_processor = TimelineProcessor(
diff --git a/frigate/data_processing/post/object_descriptions.py b/frigate/data_processing/post/object_descriptions.py
index babdb7252..6404b3851 100644
--- a/frigate/data_processing/post/object_descriptions.py
+++ b/frigate/data_processing/post/object_descriptions.py
@@ -269,7 +269,9 @@ class ObjectDescriptionProcessor(PostProcessorApi):
if event.has_snapshot and camera_config.objects.genai.use_snapshot:
snapshot_image = self._read_and_crop_snapshot(event)
+
if not snapshot_image:
+ self.cleanup_event(event_id)
return
num_thumbnails = len(self.tracked_events.get(event_id, []))
diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py
index 1a998febe..96a44a8c6 100644
--- a/frigate/embeddings/maintainer.py
+++ b/frigate/embeddings/maintainer.py
@@ -60,7 +60,11 @@ from frigate.data_processing.real_time.license_plate import (
)
from frigate.data_processing.types import DataProcessorMetrics, PostProcessDataEnum
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
-from frigate.events.types import EventTypeEnum, RegenerateDescriptionEnum
+from frigate.events.types import (
+ EventStateEnum,
+ EventTypeEnum,
+ RegenerateDescriptionEnum,
+)
from frigate.genai import GenAIClientManager
from frigate.models import Event, Recordings, ReviewSegment, Trigger
from frigate.types import TrackedObjectUpdateTypesEnum
@@ -435,7 +439,7 @@ class EmbeddingMaintainer(threading.Thread):
if update is None:
return
- source_type, _, camera, frame_name, data = update
+ source_type, event_type, camera, frame_name, data = update
logger.debug(
f"Received update - source_type: {source_type}, camera: {camera}, data label: {data.get('label') if data else 'None'}"
@@ -485,6 +489,12 @@ class EmbeddingMaintainer(threading.Thread):
for processor in self.post_processors:
if isinstance(processor, ObjectDescriptionProcessor):
+ # skip end events — _process_finalized handles them via event_end_subscriber.
+ # processing them here can re-create tracked_events entries after cleanup
+ # when the event_subscriber queue is backlogged behind event_end_subscriber.
+ if event_type == EventStateEnum.end:
+ continue
+
processor.process_data(
{
"camera": camera,
diff --git a/frigate/events/audio.py b/frigate/events/audio.py
index 6a22b2251..c5e35a8d5 100644
--- a/frigate/events/audio.py
+++ b/frigate/events/audio.py
@@ -84,7 +84,6 @@ class AudioProcessor(FrigateProcess):
def __init__(
self,
config: FrigateConfig,
- cameras: list[CameraConfig],
camera_metrics: DictProxy,
stop_event: MpEvent,
):
@@ -93,12 +92,11 @@ class AudioProcessor(FrigateProcess):
)
self.camera_metrics = camera_metrics
- self.cameras = cameras
self.config = config
def run(self) -> None:
self.pre_run_setup(self.config.logger)
- audio_threads: list[AudioEventMaintainer] = []
+ audio_threads: dict[str, AudioEventMaintainer] = {}
threading.current_thread().name = "process:audio_manager"
@@ -112,32 +110,56 @@ class AudioProcessor(FrigateProcess):
else:
self.transcription_model_runner = None
- if len(self.cameras) == 0:
- return
+ config_subscriber = CameraConfigUpdateSubscriber(
+ self.config,
+ self.config.cameras,
+ [
+ CameraConfigUpdateEnum.add,
+ CameraConfigUpdateEnum.audio,
+ CameraConfigUpdateEnum.ffmpeg,
+ ],
+ )
- for camera in self.cameras:
- audio_thread = AudioEventMaintainer(
+ def spawn_if_needed(camera: CameraConfig) -> None:
+ name = camera.name
+ if name is None or name in audio_threads:
+ return
+ if not camera.enabled or not camera.audio.enabled:
+ return
+ # ffmpeg update may not have arrived yet; wait for next poll
+ if not any("audio" in i.roles for i in camera.ffmpeg.inputs):
+ return
+ thread = AudioEventMaintainer(
camera,
self.config,
self.camera_metrics,
self.transcription_model_runner,
self.stop_event, # type: ignore[arg-type]
)
- audio_threads.append(audio_thread)
- audio_thread.start()
+ audio_threads[name] = thread
+ thread.start()
+ self.logger.info(f"Audio maintainer started for {name}")
+
+ for camera in self.config.cameras.values():
+ spawn_if_needed(camera)
self.logger.info(f"Audio processor started (pid: {self.pid})")
- while not self.stop_event.wait():
- pass
+ # poll for newly added cameras or cameras flipped to audio.enabled at runtime
+ while not self.stop_event.wait(timeout=1.0):
+ config_subscriber.check_for_updates()
+ for camera in self.config.cameras.values():
+ spawn_if_needed(camera)
- for thread in audio_threads:
+ config_subscriber.stop()
+
+ for thread in audio_threads.values():
thread.join(1)
if thread.is_alive():
self.logger.info(f"Waiting for thread {thread.name:s} to exit")
thread.join(10)
- for thread in audio_threads:
+ for thread in audio_threads.values():
if thread.is_alive():
self.logger.warning(f"Thread {thread.name} is still alive")
diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py
index 867db8d97..477e9a0db 100644
--- a/frigate/output/birdseye.py
+++ b/frigate/output/birdseye.py
@@ -62,8 +62,10 @@ def get_canvas_shape(width: int, height: int) -> tuple[int, int]:
if round(a_w / a_h, 2) != round(width / height, 2):
canvas_width = int(width // 4 * 4)
canvas_height = int((canvas_width / a_w * a_h) // 4 * 4)
- logger.warning(
- f"The birdseye resolution is a non-standard aspect ratio, forcing birdseye resolution to {canvas_width} x {canvas_height}"
+ logger.error(
+ f"Birdseye resolution {width}x{height} is not a supported aspect ratio "
+ f"and may cause visual distortion; falling back to {canvas_width}x{canvas_height}. "
+ f"Set width and height to a supported aspect ratio (16:9, 20:10, 16:6, 32:9, 12:9, 22:15, 9:16, 9:12, 16:3, or 1:1)"
)
return (canvas_width, canvas_height)
@@ -796,15 +798,18 @@ class Birdseye:
websocket_server: Any,
) -> None:
self.config = config
+ canvas_width, canvas_height = get_canvas_shape(
+ config.birdseye.width, config.birdseye.height
+ )
self.input: queue.Queue[bytes] = queue.Queue(maxsize=10)
self.converter = FFMpegConverter(
config.ffmpeg,
self.input,
stop_event,
- config.birdseye.width,
- config.birdseye.height,
- config.birdseye.width,
- config.birdseye.height,
+ canvas_width,
+ canvas_height,
+ canvas_width,
+ canvas_height,
config.birdseye.quality,
config.birdseye.restream,
)
diff --git a/frigate/util/config.py b/frigate/util/config.py
index 60d440339..71e2af809 100644
--- a/frigate/util/config.py
+++ b/frigate/util/config.py
@@ -492,7 +492,7 @@ def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
genai = new_config.get("genai")
if genai and genai.get("provider"):
- genai["roles"] = ["embeddings", "vision", "tools"]
+ genai["roles"] = ["embeddings", "descriptions", "chat"]
new_config["genai"] = {"default": genai}
# Remove deprecated sync_recordings from global record config
diff --git a/generate_config_translations.py b/generate_config_translations.py
index 032edb232..9bc830855 100644
--- a/generate_config_translations.py
+++ b/generate_config_translations.py
@@ -150,29 +150,51 @@ def extract_translations_from_schema(
# Handle anyOf cases
elif "anyOf" in field_schema:
for item in field_schema["anyOf"]:
+ nested = None
+ if item.get("type") == "null":
+ continue
if "properties" in item:
nested = extract_translations_from_schema(item, defs=defs)
+ elif "$ref" in item:
+ ref_path = item["$ref"]
+ if ref_path.startswith("#/$defs/"):
+ ref_name = ref_path.split("/")[-1]
+ if ref_name in defs:
+ nested = extract_translations_from_schema(
+ defs[ref_name], defs=defs
+ )
+ elif (
+ "additionalProperties" in item
+ and isinstance(item["additionalProperties"], dict)
+ and "$ref" in item["additionalProperties"]
+ ):
+ ref_path = item["additionalProperties"]["$ref"]
+ if ref_path.startswith("#/$defs/"):
+ ref_name = ref_path.split("/")[-1]
+ if ref_name in defs:
+ nested = extract_translations_from_schema(
+ defs[ref_name], defs=defs
+ )
+ elif (
+ "items" in item
+ and isinstance(item["items"], dict)
+ and ("$ref" in item["items"])
+ ):
+ ref_path = item["items"]["$ref"]
+ if ref_path.startswith("#/$defs/"):
+ ref_name = ref_path.split("/")[-1]
+ if ref_name in defs:
+ nested = extract_translations_from_schema(
+ defs[ref_name], defs=defs
+ )
+
+ if nested:
nested_without_root = {
k: v
for k, v in nested.items()
if k not in ("label", "description")
}
field_translations.update(nested_without_root)
- elif "$ref" in item:
- ref_path = item["$ref"]
- if ref_path.startswith("#/$defs/"):
- ref_name = ref_path.split("/")[-1]
- if ref_name in defs:
- ref_schema = defs[ref_name]
- nested = extract_translations_from_schema(
- ref_schema, defs=defs
- )
- nested_without_root = {
- k: v
- for k, v in nested.items()
- if k not in ("label", "description")
- }
- field_translations.update(nested_without_root)
if field_translations:
translations[field_name] = field_translations
diff --git a/web/public/locales/en/config/cameras.json b/web/public/locales/en/config/cameras.json
index 65f981575..4f2c0ea01 100644
--- a/web/public/locales/en/config/cameras.json
+++ b/web/public/locales/en/config/cameras.json
@@ -33,7 +33,11 @@
},
"filters": {
"label": "Audio filters",
- "description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives."
+ "description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives.",
+ "threshold": {
+ "label": "Minimum audio confidence",
+ "description": "Minimum confidence threshold for the audio event to be counted."
+ }
},
"enabled_in_config": {
"label": "Original audio state",
diff --git a/web/public/locales/en/config/global.json b/web/public/locales/en/config/global.json
index 8efecd0ce..61f0e41cc 100644
--- a/web/public/locales/en/config/global.json
+++ b/web/public/locales/en/config/global.json
@@ -559,7 +559,11 @@
},
"filters": {
"label": "Audio filters",
- "description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives."
+ "description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives.",
+ "threshold": {
+ "label": "Minimum audio confidence",
+ "description": "Minimum confidence threshold for the audio event to be counted."
+ }
},
"enabled_in_config": {
"label": "Original audio state",
diff --git a/web/public/locales/en/views/faceLibrary.json b/web/public/locales/en/views/faceLibrary.json
index 4f8a9cf2d..27e545460 100644
--- a/web/public/locales/en/views/faceLibrary.json
+++ b/web/public/locales/en/views/faceLibrary.json
@@ -32,7 +32,11 @@
"title": "Recent Recognitions",
"titleShort": "Recent",
"aria": "Select recent recognitions",
- "empty": "There are no recent face recognition attempts"
+ "empty": "There are no recent face recognition attempts",
+ "emptyNoLibrary": {
+ "title": "Upload a face",
+ "description": "You must add at least one face to the library for face recognition to function."
+ }
},
"deleteFaceLibrary": {
"title": "Delete Name",
diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json
index b47b6ba39..b73534d5d 100644
--- a/web/public/locales/en/views/settings.json
+++ b/web/public/locales/en/views/settings.json
@@ -446,6 +446,7 @@
},
"cameraManagement": {
"title": "Manage Cameras",
+ "description": "Add, edit, and delete cameras, control which cameras are enabled, and configure per-profile and camera type overrides. To configure streams, detection, motion, and other camera-specific settings, choose the specific section under Camera Configuration.",
"addCamera": "Add New Camera",
"deleteCamera": "Delete Camera",
"deleteCameraDialog": {
@@ -1127,8 +1128,16 @@
"cameras": "Cameras",
"loading": "Loading model information…",
"error": "Failed to load model information",
+ "noModelLoaded": "No Frigate+ model is currently loaded.",
"availableModels": "Available Models",
"loadingAvailableModels": "Loading available models…",
+ "selectModel": "Select a model",
+ "noModelsAvailable": "No models available",
+ "filter": {
+ "ariaLabel": "Filter models by type",
+ "baseModels": "Base Models",
+ "fineTunedModels": "Fine-tuned Models"
+ },
"modelSelect": "Your available models on Frigate+ can be selected here. Note that only models compatible with your current detector configuration can be selected."
},
"unsavedChanges": "Unsaved Frigate+ settings changes",
@@ -1744,4 +1753,4 @@
"jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended."
}
}
-}
\ No newline at end of file
+}
diff --git a/web/public/locales/nb_NO/views/chat.json b/web/public/locales/nb-NO/views/chat.json
similarity index 100%
rename from web/public/locales/nb_NO/views/chat.json
rename to web/public/locales/nb-NO/views/chat.json
diff --git a/web/public/locales/nb_NO/views/motionSearch.json b/web/public/locales/nb-NO/views/motionSearch.json
similarity index 100%
rename from web/public/locales/nb_NO/views/motionSearch.json
rename to web/public/locales/nb-NO/views/motionSearch.json
diff --git a/web/public/locales/nb_NO/views/replay.json b/web/public/locales/nb-NO/views/replay.json
similarity index 100%
rename from web/public/locales/nb_NO/views/replay.json
rename to web/public/locales/nb-NO/views/replay.json
diff --git a/web/public/locales/pt_BR/views/chat.json b/web/public/locales/pt-BR/views/chat.json
similarity index 100%
rename from web/public/locales/pt_BR/views/chat.json
rename to web/public/locales/pt-BR/views/chat.json
diff --git a/web/public/locales/pt_BR/views/motionSearch.json b/web/public/locales/pt-BR/views/motionSearch.json
similarity index 100%
rename from web/public/locales/pt_BR/views/motionSearch.json
rename to web/public/locales/pt-BR/views/motionSearch.json
diff --git a/web/public/locales/pt_BR/views/replay.json b/web/public/locales/pt-BR/views/replay.json
similarity index 100%
rename from web/public/locales/pt_BR/views/replay.json
rename to web/public/locales/pt-BR/views/replay.json
diff --git a/web/public/locales/yue_Hant/views/chat.json b/web/public/locales/yue-Hant/views/chat.json
similarity index 100%
rename from web/public/locales/yue_Hant/views/chat.json
rename to web/public/locales/yue-Hant/views/chat.json
diff --git a/web/public/locales/yue_Hant/views/motionSearch.json b/web/public/locales/yue-Hant/views/motionSearch.json
similarity index 100%
rename from web/public/locales/yue_Hant/views/motionSearch.json
rename to web/public/locales/yue-Hant/views/motionSearch.json
diff --git a/web/public/locales/yue_Hant/views/replay.json b/web/public/locales/yue-Hant/views/replay.json
similarity index 100%
rename from web/public/locales/yue_Hant/views/replay.json
rename to web/public/locales/yue-Hant/views/replay.json
diff --git a/web/public/locales/zh_Hans/views/chat.json b/web/public/locales/zh-CN/views/chat.json
similarity index 100%
rename from web/public/locales/zh_Hans/views/chat.json
rename to web/public/locales/zh-CN/views/chat.json
diff --git a/web/public/locales/zh-Hans/views/motionSearch.json b/web/public/locales/zh-CN/views/motionSearch.json
similarity index 100%
rename from web/public/locales/zh-Hans/views/motionSearch.json
rename to web/public/locales/zh-CN/views/motionSearch.json
diff --git a/web/public/locales/zh-Hans/views/replay.json b/web/public/locales/zh-CN/views/replay.json
similarity index 100%
rename from web/public/locales/zh-Hans/views/replay.json
rename to web/public/locales/zh-CN/views/replay.json
diff --git a/web/public/locales/zh_Hant/views/chat.json b/web/public/locales/zh-Hant/views/chat.json
similarity index 100%
rename from web/public/locales/zh_Hant/views/chat.json
rename to web/public/locales/zh-Hant/views/chat.json
diff --git a/web/public/locales/zh_Hant/views/motionSearch.json b/web/public/locales/zh-Hant/views/motionSearch.json
similarity index 100%
rename from web/public/locales/zh_Hant/views/motionSearch.json
rename to web/public/locales/zh-Hant/views/motionSearch.json
diff --git a/web/public/locales/zh_Hant/views/replay.json b/web/public/locales/zh-Hant/views/replay.json
similarity index 100%
rename from web/public/locales/zh_Hant/views/replay.json
rename to web/public/locales/zh-Hant/views/replay.json
diff --git a/web/src/components/card/EmptyCard.tsx b/web/src/components/card/EmptyCard.tsx
index b9943b31a..5495a6e50 100644
--- a/web/src/components/card/EmptyCard.tsx
+++ b/web/src/components/card/EmptyCard.tsx
@@ -12,6 +12,7 @@ type EmptyCardProps = {
description?: string;
buttonText?: string;
link?: string;
+ onClick?: () => void;
};
export function EmptyCard({
className,
@@ -21,6 +22,7 @@ export function EmptyCard({
description,
buttonText,
link,
+ onClick,
}: EmptyCardProps) {
let TitleComponent;
@@ -39,11 +41,16 @@ export function EmptyCard({
{description}
)}
- {buttonText?.length && (
-
- )}
+ {buttonText?.length &&
+ (onClick ? (
+
+ ) : (
+
+ ))}
);
}
diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx
index 0affaff07..2760550b8 100644
--- a/web/src/components/settings/ZoneEditPane.tsx
+++ b/web/src/components/settings/ZoneEditPane.tsx
@@ -528,7 +528,7 @@ export default function ZoneEditPane({
);
updateConfig();
// Only publish WS state for base config when zone has a name
- if (!editingProfile && zoneName) {
+ if (!editingProfile && polygon?.name) {
sendZoneState(enabled ? "ON" : "OFF");
}
} else {
diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx
index 20763261c..a57196327 100644
--- a/web/src/pages/FaceLibrary.tsx
+++ b/web/src/pages/FaceLibrary.tsx
@@ -1,5 +1,6 @@
import AddFaceIcon from "@/components/icons/AddFaceIcon";
import ActivityIndicator from "@/components/indicators/activity-indicator";
+import { EmptyCard } from "@/components/card/EmptyCard";
import CreateFaceWizardDialog from "@/components/overlay/detail/FaceCreateWizardDialog";
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
import UploadImageDialog from "@/components/overlay/dialog/UploadImageDialog";
@@ -473,7 +474,9 @@ export default function FaceLibrary() {
attemptImages={trainImages}
faceNames={faces}
selectedFaces={selectedFaces}
+ isLoading={faceData === undefined}
onClickFaces={onClickFaces}
+ onAddFace={() => setAddFace(true)}
onRefresh={refreshFaces}
/>
) : (
@@ -691,7 +694,9 @@ type TrainingGridProps = {
attemptImages: string[];
faceNames: string[];
selectedFaces: string[];
+ isLoading: boolean;
onClickFaces: (images: string[], ctrl: boolean) => void;
+ onAddFace: () => void;
onRefresh: (
data?:
| FaceLibraryData
@@ -708,7 +713,9 @@ function TrainingGrid({
attemptImages,
faceNames,
selectedFaces,
+ isLoading,
onClickFaces,
+ onAddFace,
onRefresh,
}: TrainingGridProps) {
const { t } = useTranslation(["views/faceLibrary"]);
@@ -762,6 +769,25 @@ function TrainingGrid({
]);
if (attemptImages.length == 0) {
+ if (isLoading) {
+ return (
+
+ {t("cameraManagement.description")} +
- {t("frigatePlus.description")} -
-+ {t("frigatePlus.description")} +
+{t("frigatePlus.apiKey.desc")}
- {!config?.model.plus && ( -
+
| + {t("frigatePlus.snapshotConfig.table.camera")} + | ++ {t("frigatePlus.snapshotConfig.table.snapshots")} + | +
|---|---|
|
+ |
+
+ {camera.snapshots.enabled ? (
+ |
+