diff --git a/docs/docs/configuration/face_recognition.md b/docs/docs/configuration/face_recognition.md index 035e4f4e8..192f4b4c5 100644 --- a/docs/docs/configuration/face_recognition.md +++ b/docs/docs/configuration/face_recognition.md @@ -171,7 +171,7 @@ When choosing images to include in the face training set it is recommended to al - If it is difficult to make out details in a persons face it will not be helpful in training. - Avoid images with extreme under/over-exposure. - Avoid blurry / pixelated images. -- Avoid training on infrared (gray-scale). The models are trained on color images and will be able to extract features from gray-scale images. +- Avoid training on infrared (gray-scale). The models are trained on color images and will not be able to extract features from gray-scale images. - Using images of people wearing hats / sunglasses may confuse the model. - Do not upload too many similar images at the same time, it is recommended to train no more than 4-6 similar images for each person to avoid over-fitting. diff --git a/frigate/embeddings/__init__.py b/frigate/embeddings/__init__.py index 5e14d0d8c..7e54d9703 100644 --- a/frigate/embeddings/__init__.py +++ b/frigate/embeddings/__init__.py @@ -4,6 +4,7 @@ import base64 import json import logging import os +import sys import threading from json.decoder import JSONDecodeError from multiprocessing.synchronize import Event as MpEvent @@ -52,6 +53,14 @@ class EmbeddingProcess(FrigateProcess): self.stop_event, ) maintainer.start() + maintainer.join() + + # If the maintainer thread exited but no shutdown was requested, it + # crashed. Surface as a non-zero exit so the watchdog restarts us + # instead of treating the silent thread death as a clean shutdown. + if not self.stop_event.is_set(): + logger.error("Embeddings maintainer thread exited unexpectedly") + sys.exit(1) class EmbeddingsContext: diff --git a/frigate/genai/gemini.py b/frigate/genai/gemini.py index cfa9cb802..c4befbe90 100644 --- a/frigate/genai/gemini.py +++ b/frigate/genai/gemini.py @@ -143,15 +143,17 @@ class GeminiClient(GenAIClient): ) elif role == "tool": # Handle tool response - function_response = { - "name": msg.get("name", ""), - "response": content, - } + response_payload = ( + content if isinstance(content, dict) else {"result": content} + ) gemini_messages.append( types.Content( role="function", parts=[ - types.Part.from_function_response(function_response) # type: ignore[misc,call-arg,arg-type] + types.Part.from_function_response( + name=msg.get("name", ""), + response=response_payload, + ) ], ) ) @@ -350,15 +352,17 @@ class GeminiClient(GenAIClient): ) elif role == "tool": # Handle tool response - function_response = { - "name": msg.get("name", ""), - "response": content, - } + response_payload = ( + content if isinstance(content, dict) else {"result": content} + ) gemini_messages.append( types.Content( role="function", parts=[ - types.Part.from_function_response(function_response) # type: ignore[misc,call-arg,arg-type] + types.Part.from_function_response( + name=msg.get("name", ""), + response=response_payload, + ) ], ) ) diff --git a/frigate/genai/llama_cpp.py b/frigate/genai/llama_cpp.py index 58c5a707d..11060e537 100644 --- a/frigate/genai/llama_cpp.py +++ b/frigate/genai/llama_cpp.py @@ -44,6 +44,7 @@ class LlamaCppClient(GenAIClient): _supports_tools: bool _image_token_cache: dict[tuple[int, int], int] _text_baseline_tokens: int | None + _media_marker: str def _init_provider(self) -> str | None: """Initialize the client and query model metadata from the server.""" @@ -56,6 +57,7 @@ class LlamaCppClient(GenAIClient): self._supports_tools = False self._image_token_cache = {} self._text_baseline_tokens = None + self._media_marker = "<__media__>" base_url = ( self.genai_config.base_url.rstrip("/") @@ -141,6 +143,13 @@ class LlamaCppClient(GenAIClient): chat_caps = props.get("chat_template_caps", {}) self._supports_tools = chat_caps.get("supports_tools", False) + # Media marker for multimodal embeddings; the server randomizes this + # per startup unless LLAMA_MEDIA_MARKER is set, so we must read it + # from /props rather than hardcoding "<__media__>". + media_marker = props.get("media_marker") + if isinstance(media_marker, str) and media_marker: + self._media_marker = media_marker + logger.info( "llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s", configured_model, @@ -465,10 +474,11 @@ class LlamaCppClient(GenAIClient): jpeg_bytes = _to_jpeg(img) to_encode = jpeg_bytes if jpeg_bytes is not None else img encoded = base64.b64encode(to_encode).decode("utf-8") - # prompt_string must contain <__media__> placeholder for image tokenization + # prompt_string must contain the server's media marker placeholder. + # The marker is randomized per server startup (read from /props). content.append( { - "prompt_string": "<__media__>\n", + "prompt_string": f"{self._media_marker}\n", "multimodal_data": [encoded], # type: ignore[dict-item] } ) diff --git a/frigate/video/ffmpeg.py b/frigate/video/ffmpeg.py index cc5b9a32b..3d8b18105 100644 --- a/frigate/video/ffmpeg.py +++ b/frigate/video/ffmpeg.py @@ -24,7 +24,7 @@ from frigate.config.camera.updater import ( ) from frigate.const import PROCESS_PRIORITY_HIGH from frigate.log import LogPipe -from frigate.util.builtin import EventsPerSecond +from frigate.util.builtin import EventsPerSecond, get_ffmpeg_arg_list from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg from frigate.util.image import ( FrameManager, @@ -34,6 +34,23 @@ from frigate.util.process import FrigateProcess logger = logging.getLogger(__name__) +# all built-in record presets use this segment_time +DEFAULT_RECORD_SEGMENT_TIME = 10 + + +def _get_record_segment_time(config: CameraConfig) -> int: + """Extract -segment_time from the camera's record output args.""" + record_args = get_ffmpeg_arg_list(config.ffmpeg.output_args.record) + + if record_args and record_args[0].startswith("preset"): + return DEFAULT_RECORD_SEGMENT_TIME + + try: + idx = record_args.index("-segment_time") + return int(record_args[idx + 1]) + except (ValueError, IndexError): + return DEFAULT_RECORD_SEGMENT_TIME + def capture_frames( ffmpeg_process: sp.Popen[Any], @@ -164,6 +181,12 @@ class CameraWatchdog(threading.Thread): self.latest_cache_segment_time: float = 0 self.record_enable_time: datetime | None = None + # `valid` segments are published with the segment's start time, so the + # gap between consecutive publishes can reach 2 * segment_time. Pad the + # staleness threshold so it's never tighter than that worst case. + segment_time = _get_record_segment_time(self.config) + self.record_stale_threshold = max(120, 2 * segment_time + 30) + # Stall tracking (based on last processed frame) self._stall_timestamps: deque[float] = deque() self._stall_active: bool = False @@ -413,16 +436,17 @@ class CameraWatchdog(threading.Thread): # ensure segments are still being created and that they have valid video data # Skip checks during grace period to allow segments to start being created + stale_window = timedelta(seconds=self.record_stale_threshold) cache_stale = not in_grace_period and now_utc > ( - latest_cache_dt + timedelta(seconds=120) + latest_cache_dt + stale_window ) valid_stale = not in_grace_period and now_utc > ( - latest_valid_dt + timedelta(seconds=120) + latest_valid_dt + stale_window ) invalid_stale_condition = ( self.latest_invalid_segment_time > 0 and not in_grace_period - and now_utc > (latest_invalid_dt + timedelta(seconds=120)) + and now_utc > (latest_invalid_dt + stale_window) and self.latest_valid_segment_time <= self.latest_invalid_segment_time ) @@ -439,7 +463,7 @@ class CameraWatchdog(threading.Thread): ) self.logger.error( - f"{reason} for {self.config.name} in the last 120s. Restarting the ffmpeg record process..." + f"{reason} for {self.config.name} in the last {self.record_stale_threshold}s. Restarting the ffmpeg record process..." ) p["process"] = start_or_restart_ffmpeg( p["cmd"], diff --git a/frigate/watchdog.py b/frigate/watchdog.py index 63fd16629..7ae42d988 100644 --- a/frigate/watchdog.py +++ b/frigate/watchdog.py @@ -28,6 +28,7 @@ class MonitoredProcess: restart_timestamps: deque[float] = field( default_factory=lambda: deque(maxlen=MAX_RESTARTS) ) + clean_exit_logged: bool = False def is_restarting_too_fast(self, now: float) -> bool: while ( @@ -72,7 +73,9 @@ class FrigateWatchdog(threading.Thread): exitcode = entry.process.exitcode if exitcode == 0: - logger.info("Process %s exited cleanly, not restarting", entry.name) + if not entry.clean_exit_logged: + logger.info("Process %s exited cleanly, not restarting", entry.name) + entry.clean_exit_logged = True return logger.warning( diff --git a/web/src/views/settings/MediaSyncSettingsView.tsx b/web/src/views/settings/MediaSyncSettingsView.tsx index 57f3f9685..e03f9cd73 100644 --- a/web/src/views/settings/MediaSyncSettingsView.tsx +++ b/web/src/views/settings/MediaSyncSettingsView.tsx @@ -10,13 +10,16 @@ import axios from "axios"; import { toast } from "sonner"; import { useJobStatus } from "@/api/ws"; import { Switch } from "@/components/ui/switch"; -import { LuCheck, LuX } from "react-icons/lu"; +import { LuCheck, LuExternalLink, LuX } from "react-icons/lu"; import { cn } from "@/lib/utils"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { MediaSyncResults, MediaSyncStats } from "@/types/ws"; +import { useDocDomain } from "@/hooks/use-doc-domain"; +import { Link } from "react-router-dom"; export default function MediaSyncSettingsView() { const { t } = useTranslation("views/settings"); + const { getLocaleDocUrl } = useDocDomain(); const [selectedMediaTypes, setSelectedMediaTypes] = useState([ "all", ]); @@ -109,13 +112,25 @@ export default function MediaSyncSettingsView() { {t("maintenance.sync.title")} -

{t("maintenance.sync.desc")}

+ +
+ + {t("readTheDocumentation", { ns: "common" })} + + +
-
{/* Media Types Selection */}