Compare commits

..

No commits in common. "3a09d01bbe110cff4f25ea36f73a203292c92e1d" and "a4a592b4e633d526513909e46854d31deb846655" have entirely different histories.

24 changed files with 172 additions and 838 deletions

View File

@ -49,14 +49,15 @@ You should have at least 8 GB of RAM available (or VRAM if running on GPU) to ru
### Model Types: Instruct vs Thinking
Vision-language models come in **instruct** variants (fine-tuned to follow instructions and respond concisely), **thinking** variants (fine-tuned for free-form, speculative reasoning), and **hybrid** variants that support both modes per request. Most modern vision-language models are hybrid.
Most vision-language models are available as **instruct** models, which are fine-tuned to follow instructions and respond concisely to prompts. However, some models (such as certain Qwen-VL or minigpt variants) offer both **instruct** and **thinking** versions.
Frigate manages reasoning per task automatically:
- **Instruct models** are always recommended for use with Frigate. These models generate direct, relevant, actionable descriptions that best fit Frigate's object and event summary use case.
- **Reasoning / Thinking models** are fine-tuned for more free-form, open-ended, and speculative outputs, which are typically not concise and may not provide the practical summaries Frigate expects. For this reason, Frigate does **not** recommend or support using thinking models.
- **Description tasks** (object descriptions, review descriptions, review summaries) are synthesis-only and benefit from concise, direct output, so Frigate disables thinking for these calls when the model exposes a per-request toggle.
- **Chat** lets you toggle thinking on or off from the composer when the configured model supports it.
Some models are labeled as **hybrid** (capable of both thinking and instruct tasks). In these cases, it is recommended to disable reasoning / thinking, which is generally model specific (see your models documentation).
You can use a pure instruct, hybrid, or thinking-capable model with Frigate — no extra configuration is required to disable thinking for descriptions.
**Recommendation:**
Always select the `-instruct` or documented instruct/tagged variant of any model you use in your Frigate configuration. If in doubt, refer to your model provider's documentation or model library for guidance on the correct model variant to use.
### llama.cpp

View File

@ -23,7 +23,7 @@ In 0.14 and later, all of that is bundled into a single review item which starts
## Alerts and Detections
Not every segment of video captured by Frigate may be of the same level of interest to you. Video of people who enter your property may be a different priority than those walking by on the sidewalk. For this reason, Frigate categorizes review items as _alerts_ and _detections_. By default, all person and car objects are considered alerts. You can refine categorization of your review items by configuring required zones for them.
Not every segment of video captured by Frigate may be of the same level of interest to you. Video of people who enter your property may be a different priority than those walking by on the sidewalk. For this reason, Frigate 0.14 categorizes review items as _alerts_ and _detections_. By default, all person and car objects are considered alerts. You can refine categorization of your review items by configuring required zones for them.
:::note

View File

@ -56,7 +56,6 @@ Only one replay session can be active at a time. If a session is already running
- The replay will not always produce identical results to the original run. Different frames may be selected on replay, which can change detections and tracking.
- Motion detection depends on the exact frames used; small frame shifts can change motion regions and therefore what gets passed to the detector.
- Object detection is not fully deterministic: models and post-processing can yield slightly different results across runs.
- In cases where a detection is short and a replay may only be a small number of frames, it is recommended to manually add some padding before and after the detection so that the motion and object detectors have time to settle into the scene. Rather than starting Debug Replay from Explore, navigate to History for your camera, choose Debug Replay from the Actions menu, and click the "From Timeline" or "Custom" option.
Treat the replay as a close approximation rather than an exact reproduction. Run multiple loops and examine the debug overlays and logs to understand the behavior.

View File

@ -750,33 +750,6 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
settings,
)
# detect resize also republishes motion + objects so other
# processes pick up the rebuilt masks, and fires refresh so
# the camera maintainer recycles the camera process to pick
# up the new ffmpeg cmd / SHM sizing
if field == "detect":
cam_cfg = config.cameras.get(camera)
if cam_cfg is not None:
if cam_cfg.motion is not None:
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(
CameraConfigUpdateEnum.motion, camera
),
cam_cfg.motion,
)
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(
CameraConfigUpdateEnum.objects, camera
),
cam_cfg.objects,
)
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(
CameraConfigUpdateEnum.refresh, camera
),
cam_cfg,
)
return JSONResponse(
content={"success": True, "message": "Config applied in-memory"},
status_code=200,

View File

@ -14,7 +14,6 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber,
)
from frigate.const import REPLAY_CAMERA_PREFIX
from frigate.models import Regions
from frigate.util.builtin import empty_and_close_queue
from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory
@ -51,7 +50,6 @@ class CameraMaintainer(threading.Thread):
[
CameraConfigUpdateEnum.add,
CameraConfigUpdateEnum.remove,
CameraConfigUpdateEnum.refresh,
],
)
self.shm_count = self.__calculate_shm_frame_count()
@ -204,25 +202,6 @@ class CameraMaintainer(threading.Thread):
capture_process.terminate()
capture_process.join()
def __unlink_camera_frame_slots(self, camera: str) -> None:
"""Drop the camera's per-frame YUV SHM segments from this
process's frame_manager and unlink them at the OS level.
Safe to call after the camera's capture/processor subprocesses
have been joined they no longer hold mappings, so unlink frees
the segments immediately. Other long-lived processes that opened
these slots will continue using their existing mappings until
they call frame_manager.get with a shape that no longer fits
(the get path drops and reopens stale refs).
"""
prefix = f"{camera}_frame"
names = [n for n in list(self.frame_manager.shm_store) if n.startswith(prefix)]
for name in names:
try:
self.frame_manager.delete(name)
except Exception as exc:
logger.debug("Could not unlink SHM %s: %s", name, exc)
def __stop_camera_process(self, camera: str) -> None:
camera_process = self.camera_processes.get(camera)
if camera_process is not None:
@ -274,45 +253,12 @@ class CameraMaintainer(threading.Thread):
for camera in updated_cameras:
self.__stop_camera_capture_process(camera)
self.__stop_camera_process(camera)
self.__unlink_camera_frame_slots(camera)
self.capture_processes.pop(camera, None)
self.camera_processes.pop(camera, None)
self.camera_stop_events.pop(camera, None)
self.region_grids.pop(camera, None)
self.camera_metrics.pop(camera, None)
self.ptz_metrics.pop(camera, None)
elif update_type == CameraConfigUpdateEnum.refresh.name:
# Recycle replay cameras so detect width/height/fps
# propagate through ffmpeg args, SHM sizing, and the
# region grid. Regular cameras detect change still
# requires a full restart.
for camera in updated_cameras:
if not camera.startswith(REPLAY_CAMERA_PREFIX):
continue
new_config = self.update_subscriber.camera_configs.get(camera)
if new_config is None:
# remove arrived in the same batch
continue
if (
camera not in self.camera_processes
and camera not in self.capture_processes
):
continue
# rebuild ffmpeg cmds on the shared config so the
# new subprocesses spawn with current args
new_config.recreate_ffmpeg_cmds()
self.__stop_camera_capture_process(camera)
self.__stop_camera_process(camera)
self.__unlink_camera_frame_slots(camera)
self.capture_processes.pop(camera, None)
self.camera_processes.pop(camera, None)
self.__start_camera_processor(camera, new_config, runtime=True)
self.__start_camera_capture(camera, new_config, runtime=True)
# ensure the capture processes are done
for camera in self.capture_processes.keys():

View File

@ -45,7 +45,6 @@ class CameraState:
self.frame_cache: dict[float, dict[str, Any]] = {}
self.zone_objects: defaultdict[str, list[Any]] = defaultdict(list)
self._current_frame = np.zeros(self.camera_config.frame_shape_yuv, np.uint8)
self._last_frame_shape: tuple[int, int] = self.camera_config.frame_shape_yuv
self.current_frame_lock = threading.Lock()
self.current_frame_time = 0.0
self.motion_boxes: list[tuple[int, int, int, int]] = []
@ -304,42 +303,6 @@ class CameraState:
def on(self, event_type: str, callback: Callable[..., Any]) -> None:
self.callbacks[event_type].append(callback)
def _discard_stale_resolution_state(
self, current_detections: dict[str, dict[str, Any]]
) -> bool:
"""Drop tracked state when the camera's detect resolution has
changed, and signal the caller to skip this batch if it contains
out-of-bounds boxes from the pre-recycle detect process.
Returns True when the batch should be skipped entirely.
"""
# detect resolution changed — drop tracked state so old-grid
# boxes don't leak through end-callbacks
current_shape = self.camera_config.frame_shape_yuv
if current_shape != self._last_frame_shape:
logger.debug(
f"{self.name}: detect resolution changed {self._last_frame_shape} -> {current_shape}, dropping tracked state"
)
with self.current_frame_lock:
self.tracked_objects.clear()
self.motion_boxes = []
self.regions = []
self._last_frame_shape = current_shape
# drop in-flight batches from the pre-recycle detect process
# whose boxes exceed the current detect resolution
detect = self.camera_config.detect
if detect.width is not None and detect.height is not None:
for obj in current_detections.values():
box = obj.get("box")
if box and (box[2] > detect.width or box[3] > detect.height):
logger.debug(
f"{self.name}: dropping stale-resolution detection batch (box {box} exceeds {detect.width}x{detect.height})"
)
return True
return False
def update(
self,
frame_name: str,
@ -348,9 +311,6 @@ class CameraState:
motion_boxes: list[tuple[int, int, int, int]],
regions: list[tuple[int, int, int, int]],
) -> None:
if self._discard_stale_resolution_state(current_detections):
return
current_frame = self.frame_manager.get(
frame_name, self.camera_config.frame_shape_yuv
)
@ -372,18 +332,14 @@ class CameraState:
current_detections[id],
)
# Skip caching when the frame buffer isn't readable — e.g.
# frame_manager.get returned None because the SHM segment was
# unlinked or hasn't been recreated yet during a camera
# add/remove cycle.
if current_frame is not None:
logger.debug(
f"{self.name}: New object, adding {frame_time} to frame cache for {id}"
)
self.frame_cache[frame_time] = {
"frame": np.copy(current_frame),
"object_id": id,
}
# add initial frame to frame cache
logger.debug(
f"{self.name}: New object, adding {frame_time} to frame cache for {id}"
)
self.frame_cache[frame_time] = {
"frame": np.copy(current_frame), # type: ignore[arg-type]
"object_id": id,
}
# save initial thumbnail data and best object
thumbnail_data = {

View File

@ -26,7 +26,6 @@ class CameraConfigUpdateEnum(str, Enum):
object_genai = "object_genai"
onvif = "onvif"
record = "record"
refresh = "refresh" # signals the camera maintainer to recycle the camera process
remove = "remove" # for removing a camera
review = "review"
review_genai = "review_genai"
@ -85,8 +84,8 @@ class CameraConfigUpdateSubscriber:
self, camera: str, update_type: CameraConfigUpdateEnum, updated_config: Any
) -> None:
if update_type == CameraConfigUpdateEnum.add:
shared = self.config.cameras.setdefault(camera, updated_config)
self.camera_configs[camera] = shared
self.config.cameras[camera] = updated_config
self.camera_configs[camera] = updated_config
return
elif update_type == CameraConfigUpdateEnum.remove:
self.config.cameras.pop(camera, None)

View File

@ -238,10 +238,6 @@ class DebugReplayManager:
zone_dump.setdefault("coordinates", zone_config.coordinates)
zones_dict[zone_name] = zone_dump
# Extract LPR and face recognition configs
lpr_dict = source_config.lpr.model_dump()
face_recognition_dict = source_config.face_recognition.model_dump()
# Extract motion config (exclude runtime fields)
motion_dict = {}
if source_config.motion is not None:
@ -291,8 +287,8 @@ class DebugReplayManager:
},
"birdseye": {"enabled": False},
"audio": {"enabled": False},
"lpr": lpr_dict,
"face_recognition": face_recognition_dict,
"lpr": {"enabled": False},
"face_recognition": {"enabled": False},
}
def _cleanup_db(self, camera_name: str) -> None:

View File

@ -1,7 +1,5 @@
"""Gemini Provider for Frigate AI."""
import base64
import binascii
import json
import logging
from typing import Any, AsyncGenerator, Optional
@ -16,27 +14,6 @@ from frigate.genai import GenAIClient, register_genai_provider
logger = logging.getLogger(__name__)
def _decode_thought_signature(value: Any) -> Optional[bytes]:
"""Decode a base64-encoded thought_signature carried across conversation turns."""
if not value:
return None
if isinstance(value, bytes):
return value
if isinstance(value, str):
try:
return base64.b64decode(value)
except (binascii.Error, ValueError):
return None
return None
def _encode_thought_signature(signature: Optional[bytes]) -> Optional[str]:
"""Encode bytes thought_signature as base64 so it survives JSON-friendly transport."""
if not signature:
return None
return base64.b64encode(signature).decode("ascii")
def _stats_from_gemini_usage(usage: Any) -> Optional[dict[str, Any]]:
"""Build a stats dict from a Gemini usage_metadata object."""
prompt_tokens = getattr(usage, "prompt_token_count", None)
@ -192,17 +169,11 @@ class GeminiClient(GenAIClient):
if not isinstance(tc_args, dict):
tc_args = {}
if tc_name:
fc_part = types.Part.from_function_call(
name=tc_name, args=tc_args
parts.append(
types.Part.from_function_call(
name=tc_name, args=tc_args
)
)
# Thinking-capable Gemini models require the original
# thought_signature to be echoed back on functionCall
# parts after a tool response, or the next request
# fails with INVALID_ARGUMENT.
sig = _decode_thought_signature(tc.get("thought_signature"))
if sig:
fc_part.thought_signature = sig
parts.append(fc_part)
if not parts:
parts.append(types.Part.from_text(text=" "))
gemini_messages.append(types.Content(role="model", parts=parts))
@ -339,9 +310,6 @@ class GeminiClient(GenAIClient):
"id": part.function_call.name or "",
"name": part.function_call.name or "",
"arguments": arguments,
"thought_signature": _encode_thought_signature(
getattr(part, "thought_signature", None)
),
}
)
@ -450,17 +418,11 @@ class GeminiClient(GenAIClient):
if not isinstance(tc_args, dict):
tc_args = {}
if tc_name:
fc_part = types.Part.from_function_call(
name=tc_name, args=tc_args
parts.append(
types.Part.from_function_call(
name=tc_name, args=tc_args
)
)
# Thinking-capable Gemini models require the original
# thought_signature to be echoed back on functionCall
# parts after a tool response, or the next request
# fails with INVALID_ARGUMENT.
sig = _decode_thought_signature(tc.get("thought_signature"))
if sig:
fc_part.thought_signature = sig
parts.append(fc_part)
if not parts:
parts.append(types.Part.from_text(text=" "))
gemini_messages.append(types.Content(role="model", parts=parts))
@ -626,7 +588,6 @@ class GeminiClient(GenAIClient):
"id": tool_call_id,
"name": tool_call_name,
"arguments": "",
"thought_signature": None,
}
# Accumulate arguments
@ -637,13 +598,6 @@ class GeminiClient(GenAIClient):
else str(arguments)
)
# Capture latest thought_signature for this call
chunk_sig = getattr(part, "thought_signature", None)
if chunk_sig:
tool_calls_by_index[found_index][
"thought_signature"
] = chunk_sig
# Build final message
full_content = "".join(content_parts).strip() or None
full_reasoning = "".join(reasoning_parts).strip() or None
@ -664,9 +618,6 @@ class GeminiClient(GenAIClient):
"id": tc["id"],
"name": tc["name"],
"arguments": parsed_args,
"thought_signature": _encode_thought_signature(
tc.get("thought_signature")
),
}
)
finish_reason = "tool_calls"

View File

@ -69,14 +69,6 @@ def build_assistant_message_for_conversation(
"name": tc["name"],
"arguments": json.dumps(tc.get("arguments") or {}),
},
# Gemini-only: opaque signature that must be echoed back on
# the same functionCall part in the next turn. Other providers
# do not set or read this.
**(
{"thought_signature": tc["thought_signature"]}
if tc.get("thought_signature")
else {}
),
}
for tc in tool_calls_raw
]

View File

@ -167,9 +167,8 @@ class DetectorRunner(FrigateProcess):
# detect and send the output
self.start_time.value = datetime.datetime.now().timestamp()
mono_start = time.monotonic()
detections = object_detector.detect_raw(input_frame)
duration = time.monotonic() - mono_start
duration = datetime.datetime.now().timestamp() - self.start_time.value
frame_manager.close(connection_id)
if connection_id not in self.outputs:

View File

@ -1331,8 +1331,6 @@ class PtzAutoTracker:
return self.tracked_object[camera]["region"]
def autotrack_object(self, camera: str, obj: TrackedObject):
if camera not in self.config.cameras:
return
camera_config = self.config.cameras[camera]
if camera_config.onvif.autotracking.enabled:

View File

@ -1,79 +0,0 @@
"""Tests for CameraMaintainer SHM cleanup on camera remove.
Regression coverage for the case where a camera is removed and then a
new camera is added with the same name. Without unlinking the per-frame
YUV SHM slots, the maintainer's frame_manager.create call hits
FileExistsError and falls back to reopening the existing segment at the
*old* size, which the new ffmpeg process then writes mismatched-size
frames into.
"""
import unittest
from unittest.mock import MagicMock, patch
from frigate.camera.maintainer import CameraMaintainer
class TestMaintainerUnlinkFrameSlotsOnRemove(unittest.TestCase):
def _make_maintainer(self) -> CameraMaintainer:
"""Build a maintainer without invoking __init__ (avoids needing real
FrigateConfig, queues, multiprocessing manager, etc.). We're only
exercising the SHM-cleanup helper, so the surrounding init is
irrelevant."""
maintainer = CameraMaintainer.__new__(CameraMaintainer)
maintainer.frame_manager = MagicMock()
return maintainer
def test_unlinks_only_segments_with_matching_prefix(self) -> None:
maintainer = self._make_maintainer()
maintainer.frame_manager.shm_store = {
"front_frame0": object(),
"front_frame1": object(),
"front_frame2": object(),
# Different camera; must not be touched.
"side_frame0": object(),
# Detector input/output buffers are sized by the model and
# cached by the long-lived DetectorRunner — must not be
# touched even when their owning camera is removed.
"front": object(),
"out-front": object(),
}
# __name-mangled access from outside the class.
maintainer._CameraMaintainer__unlink_camera_frame_slots("front")
deleted = [c.args[0] for c in maintainer.frame_manager.delete.call_args_list]
self.assertEqual(
sorted(deleted),
["front_frame0", "front_frame1", "front_frame2"],
)
def test_handles_camera_with_no_slots(self) -> None:
"""Cameras that were removed before any frame slot was ever
created (e.g. cancelled during preparing_clip) should be a no-op."""
maintainer = self._make_maintainer()
maintainer.frame_manager.shm_store = {"other_frame0": object()}
maintainer._CameraMaintainer__unlink_camera_frame_slots("front")
maintainer.frame_manager.delete.assert_not_called()
def test_swallows_delete_errors(self) -> None:
"""Unlink failures shouldn't abort the remove loop — best-effort."""
maintainer = self._make_maintainer()
maintainer.frame_manager.shm_store = {
"front_frame0": object(),
"front_frame1": object(),
}
maintainer.frame_manager.delete.side_effect = OSError("simulated")
# Both slots are attempted; the OSError on the first doesn't
# prevent the second from being tried.
with patch("frigate.camera.maintainer.logger"):
maintainer._CameraMaintainer__unlink_camera_frame_slots("front")
self.assertEqual(maintainer.frame_manager.delete.call_count, 2)
if __name__ == "__main__":
unittest.main()

View File

@ -1,156 +0,0 @@
"""Tests for SharedMemoryFrameManager cache invalidation.
Covers the case where a SHM segment is unlinked and recreated at a
different size across a camera add/remove cycle while a long-lived
in-process cache (e.g. TrackedObjectProcessor) still holds a ref to
the old, smaller segment.
"""
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
from frigate.util.image import SharedMemoryFrameManager
def _fake_shm(size: int) -> SimpleNamespace:
"""A minimal stand-in for UntrackedSharedMemory with .size and .buf."""
return SimpleNamespace(size=size, buf=bytearray(size), close=lambda: None)
class TestSharedMemoryFrameManagerGet(unittest.TestCase):
def test_get_reopens_when_cached_segment_is_smaller_than_shape(self) -> None:
"""A cached ref to an older smaller segment must be dropped and the
current (correctly sized) segment reopened. Without this, np.ndarray
would raise "buffer is too small for requested array" when the
in-memory cache pointed at an old SHM after a same-name resize."""
manager = SharedMemoryFrameManager()
small = _fake_shm(size=100)
current = _fake_shm(size=2_500)
manager.shm_store["cam_frame0"] = small
with patch("frigate.util.image.UntrackedSharedMemory", return_value=current):
arr = manager.get("cam_frame0", (50, 50))
self.assertIsNotNone(arr)
self.assertEqual(arr.shape, (50, 50))
self.assertIs(manager.shm_store["cam_frame0"], current)
def test_get_reopens_when_cached_segment_is_larger_than_shape(self) -> None:
"""Symmetric to the smaller-cache case: when detect resolution drops,
the SHM is unlinked and recreated at a smaller size. A cached ref to
the old, larger segment still satisfies any size check but points at
an orphaned inode whose stale bytes get reinterpreted at the new
shape producing miscolored, distorted YUV frames downstream. Drop
the cache so we reopen by name and bind to the current segment."""
manager = SharedMemoryFrameManager()
old_large = _fake_shm(size=10_000)
current = _fake_shm(size=2_500)
manager.shm_store["cam_frame0"] = old_large
with patch("frigate.util.image.UntrackedSharedMemory", return_value=current):
arr = manager.get("cam_frame0", (50, 50))
self.assertIsNotNone(arr)
self.assertEqual(arr.shape, (50, 50))
self.assertIs(manager.shm_store["cam_frame0"], current)
def test_get_keeps_cached_segment_when_size_matches(self) -> None:
"""Don't pay the reopen cost when the cached ref is the right size."""
manager = SharedMemoryFrameManager()
cached = _fake_shm(size=2_500)
manager.shm_store["cam_frame0"] = cached
with patch("frigate.util.image.UntrackedSharedMemory") as untracked_shm_cls:
arr = manager.get("cam_frame0", (50, 50))
untracked_shm_cls.assert_not_called()
self.assertIsNotNone(arr)
self.assertIs(manager.shm_store["cam_frame0"], cached)
def test_get_opens_fresh_when_no_cache_entry(self) -> None:
manager = SharedMemoryFrameManager()
fresh = _fake_shm(size=2_500)
with patch("frigate.util.image.UntrackedSharedMemory", return_value=fresh):
arr = manager.get("cam_frame0", (50, 50))
self.assertIsNotNone(arr)
self.assertIs(manager.shm_store["cam_frame0"], fresh)
def test_get_returns_none_when_segment_missing(self) -> None:
manager = SharedMemoryFrameManager()
with patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=FileNotFoundError,
):
arr = manager.get("cam_frame0", (50, 50))
self.assertIsNone(arr)
def test_get_returns_none_when_reopened_segment_is_still_too_small(self) -> None:
"""Race during a same-name SHM recreate: cache is stale, we reopen
by name, but the maintainer hasn't allocated the new segment yet —
the reopened ref is also too small. Skip the frame (return None)
rather than crash on np.ndarray."""
manager = SharedMemoryFrameManager()
small_cached = _fake_shm(size=100)
still_small_after_reopen = _fake_shm(size=100)
manager.shm_store["cam_frame0"] = small_cached
with patch(
"frigate.util.image.UntrackedSharedMemory",
return_value=still_small_after_reopen,
):
arr = manager.get("cam_frame0", (50, 50))
self.assertIsNone(arr)
# Don't cache the too-small reopened ref — next call will re-open
# once the maintainer has finished recreating the segment.
self.assertNotIn("cam_frame0", manager.shm_store)
def test_get_handles_n_dimensional_shape(self) -> None:
"""np.prod must be used (not raw multiplication) for tuple shapes."""
manager = SharedMemoryFrameManager()
# YUV-shaped frame: (height * 3/2, width) for 1920x1080 = 3,110,400
big_enough = _fake_shm(size=3_110_400)
manager.shm_store["cam_frame0"] = big_enough
with patch("frigate.util.image.UntrackedSharedMemory") as untracked_shm_cls:
arr = manager.get("cam_frame0", (1620, 1920))
untracked_shm_cls.assert_not_called()
self.assertIsNotNone(arr)
self.assertEqual(arr.shape, (1620, 1920))
class TestSharedMemoryFrameManagerGetRecreatesLargerSegment(unittest.TestCase):
"""End-to-end-style: simulates the full unlink-and-recreate cycle."""
def test_segment_grows_then_get_succeeds(self) -> None:
manager = SharedMemoryFrameManager()
# Phase 1: existing camera at 320x240 YUV — 320 * 240 * 1.5 = 115_200
small = _fake_shm(size=115_200)
manager.shm_store["cam_frame0"] = small
arr_small = np.ndarray((360, 320), dtype=np.uint8, buffer=small.buf)
self.assertEqual(arr_small.shape, (360, 320))
# Phase 2: restart at 1920x1080 — new SHM segment, larger size.
large = _fake_shm(size=3_110_400)
with patch("frigate.util.image.UntrackedSharedMemory", return_value=large):
arr_large = manager.get("cam_frame0", (1620, 1920))
self.assertIsNotNone(arr_large)
self.assertEqual(arr_large.shape, (1620, 1920))
if __name__ == "__main__":
unittest.main()

View File

@ -788,34 +788,6 @@ def apply_section_update(camera_config, section: str, update: dict) -> Optional[
)
camera_config.objects = new_objects
elif section == "detect":
# apply detect first so frame_shape reflects the new resolution
# before we rebuild mask-dependent runtime configs below
merged = deep_merge(current.model_dump(), update, override=True)
camera_config.detect = current.__class__.model_validate(merged)
new_frame_shape = camera_config.frame_shape
# rebuild motion's rasterized_mask at the new frame_shape
if camera_config.motion is not None:
camera_config.motion = RuntimeMotionConfig(
frame_shape=new_frame_shape,
**camera_config.motion.model_dump(exclude_unset=True),
)
# rebuild per-object filter masks at the new frame_shape
for obj_name, filt in camera_config.objects.filters.items():
merged_mask = dict(filt.mask)
if camera_config.objects.mask:
for gid, gmask in camera_config.objects.mask.items():
merged_mask[f"global_{gid}"] = gmask
camera_config.objects.filters[obj_name] = RuntimeFilterConfig(
frame_shape=new_frame_shape,
mask=merged_mask,
**filt.model_dump(exclude_unset=True, exclude={"mask", "raw_mask"}),
)
else:
merged = deep_merge(current.model_dump(), update, override=True)
setattr(camera_config, section, current.__class__.model_validate(merged))

View File

@ -1089,25 +1089,10 @@ class SharedMemoryFrameManager(FrameManager):
def get(self, name: str, shape) -> Optional[np.ndarray]:
try:
required = int(np.prod(shape))
shm = self.shm_store.get(name)
if shm is not None and shm.size != required:
# stale cached ref from a same-name recreate — drop and reopen
try:
shm.close()
except Exception:
pass
self.shm_store.pop(name, None)
shm = None
if shm is None:
if name in self.shm_store:
shm = self.shm_store[name]
else:
shm = UntrackedSharedMemory(name=name)
if shm.size != required:
# mid-recreate: OS segment doesn't match shape yet; skip
try:
shm.close()
except Exception:
pass
return None
self.shm_store[name] = shm
return np.ndarray(shape, dtype=np.uint8, buffer=shm.buf)
except FileNotFoundError:

View File

@ -484,15 +484,11 @@
"reorderHandle": "Drag to reorder",
"saving": "Saving…",
"saved": "Saved",
"details": {
"edit": "Edit camera details",
"title": "Edit Camera Details",
"description": "Update the display name and external URL used for this camera throughout the Frigate UI.",
"friendlyNameLabel": "Display Name",
"friendlyNameHelp": "Friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.",
"webuiUrlLabel": "Camera Web UI URL",
"webuiUrlHelp": "URL to visit the camera's web UI directly from the Debug view. Leave blank to disable the link.",
"webuiUrlInvalid": "Must be a valid URL (e.g., https://example.com)."
"friendlyName": {
"edit": "Edit camera display name",
"title": "Edit Display Name",
"description": "Set the friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.",
"rename": "Rename"
}
},
"cameraConfig": {
@ -1820,8 +1816,7 @@
"mixedTypesSuggestion": "All detectors must use the same type. Remove existing detectors or select {{type}}."
},
"semanticSearch": {
"jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended.",
"modelSizeIgnoredForProvider": "Model size only applies to the built-in Jina models. This value will be ignored when using a GenAI embedding provider."
"jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended."
}
}
}

View File

@ -1,13 +0,0 @@
import { createContext } from "react";
import type { FieldConditionalMessage } from "./section-configs/types";
// Provides currently-active field messages to FieldTemplate without going
// through RJSF's per-field uiSchema. RJSF caches state.uiSchema across renders
// in a way that can leave stale ui:messages attached to a field when the
// triggering condition flips back to false (see processPendingChange in
// @rjsf/core Form.js — formData is updated immediately, uiSchema is not).
// useContext re-runs consumers directly on provider value change, sidestepping
// that staleness.
export const FieldMessagesContext = createContext<FieldConditionalMessage[]>(
[],
);

View File

@ -72,25 +72,6 @@ const detect: SectionConfigOverrides = {
"max_disappeared",
],
},
replay: {
restartRequired: [],
fieldOrder: ["width", "height", "fps"],
fieldGroups: {
resolution: ["width", "height", "fps"],
},
hiddenFields: [
"enabled",
"enabled_in_config",
"min_initialized",
"max_disappeared",
"annotation_offset",
"stationary",
"interval",
"threshold",
"max_frames",
],
advancedFields: [],
},
};
export default detect;

View File

@ -29,22 +29,6 @@ const semanticSearch: SectionConfigOverrides = {
ctx.formData?.model === "jinav2" &&
ctx.formData?.model_size === "small",
},
{
key: "model-size-ignored-for-provider",
field: "model_size",
messageKey: "configMessages.semanticSearch.modelSizeIgnoredForProvider",
severity: "info",
position: "after",
condition: (ctx) => {
const model = ctx.formData?.model;
return (
typeof model === "string" &&
model !== "" &&
model !== "jinav1" &&
model !== "jinav2"
);
},
},
],
uiSchema: {
model: {

View File

@ -86,7 +86,6 @@ import type {
} from "../section-configs/types";
import { useConfigMessages } from "@/hooks/use-config-messages";
import { ConfigMessageBanner } from "../ConfigMessageBanner";
import { FieldMessagesContext } from "../FieldMessagesContext";
export interface SectionConfig {
/** Field ordering within the section */
@ -628,6 +627,44 @@ export function ConfigSection({
messageContext,
);
// Merge field-level conditional messages into uiSchema
const effectiveUiSchema = useMemo(() => {
if (activeFieldMessages.length === 0) return sectionConfig.uiSchema;
const merged = { ...(sectionConfig.uiSchema ?? {}) };
for (const msg of activeFieldMessages) {
const segments = msg.field.split(".");
// Navigate to the nested uiSchema node, shallow-cloning along the way
let node = merged;
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i];
node[seg] = { ...(node[seg] as Record<string, unknown>) };
node = node[seg] as Record<string, unknown>;
}
const leafKey = segments[segments.length - 1];
const existing = node[leafKey] as Record<string, unknown> | undefined;
const existingMessages = ((existing?.["ui:messages"] as unknown[]) ??
[]) as Array<{
key: string;
messageKey: string;
severity: string;
position?: string;
}>;
node[leafKey] = {
...existing,
"ui:messages": [
...existingMessages,
{
key: msg.key,
messageKey: msg.messageKey,
severity: msg.severity,
position: msg.position ?? "before",
},
],
};
}
return merged;
}, [sectionConfig.uiSchema, activeFieldMessages]);
const currentOverrides = useMemo(() => {
if (!currentFormData || typeof currentFormData !== "object") {
return undefined;
@ -997,61 +1034,59 @@ export function ConfigSection({
const sectionContent = (
<div className="space-y-6">
<ConfigMessageBanner messages={activeMessages} />
<FieldMessagesContext.Provider value={activeFieldMessages}>
<ConfigForm
key={formKey}
schema={modifiedSchema}
formData={currentFormData}
onChange={handleChange}
onValidationChange={setHasValidationErrors}
fieldOrder={sectionConfig.fieldOrder}
fieldGroups={sectionConfig.fieldGroups}
hiddenFields={effectiveHiddenFields}
advancedFields={sectionConfig.advancedFields}
liveValidate={sectionConfig.liveValidate}
uiSchema={sectionConfig.uiSchema}
disabled={disabled || isSaving}
readonly={readonly}
showSubmit={false}
i18nNamespace={configNamespace}
customValidate={customValidate}
formContext={{
level: effectiveLevel,
cameraName,
globalValue,
cameraValue,
hasChanges,
extraHasChanges,
setExtraHasChanges,
overrides: uiOverrides as JsonValue | undefined,
formData: currentFormData as ConfigSectionData,
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
pendingDataBySection,
onPendingDataChange,
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
// For widgets that need access to full camera config (e.g., zone names)
fullCameraConfig:
effectiveLevel === "camera" && cameraName
? config?.cameras?.[cameraName]
: undefined,
fullConfig: config,
// When rendering camera-level sections, provide the section path so
// field templates can look up keys under the `config/cameras` namespace
// When using a consolidated global namespace, keys are nested
// under the section name (e.g., `audio.label`) so provide the
// section prefix to templates so they can attempt `${section}.${field}` lookups.
sectionI18nPrefix: sectionPath,
t,
renderers: wrappedRenderers,
sectionDocs: sectionConfig.sectionDocs,
fieldDocs: sectionConfig.fieldDocs,
hiddenFields: effectiveHiddenFields,
restartRequired: sectionConfig.restartRequired,
requiresRestart,
isProfile: !!profileName,
}}
/>
</FieldMessagesContext.Provider>
<ConfigForm
key={formKey}
schema={modifiedSchema}
formData={currentFormData}
onChange={handleChange}
onValidationChange={setHasValidationErrors}
fieldOrder={sectionConfig.fieldOrder}
fieldGroups={sectionConfig.fieldGroups}
hiddenFields={effectiveHiddenFields}
advancedFields={sectionConfig.advancedFields}
liveValidate={sectionConfig.liveValidate}
uiSchema={effectiveUiSchema}
disabled={disabled || isSaving}
readonly={readonly}
showSubmit={false}
i18nNamespace={configNamespace}
customValidate={customValidate}
formContext={{
level: effectiveLevel,
cameraName,
globalValue,
cameraValue,
hasChanges,
extraHasChanges,
setExtraHasChanges,
overrides: uiOverrides as JsonValue | undefined,
formData: currentFormData as ConfigSectionData,
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
pendingDataBySection,
onPendingDataChange,
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
// For widgets that need access to full camera config (e.g., zone names)
fullCameraConfig:
effectiveLevel === "camera" && cameraName
? config?.cameras?.[cameraName]
: undefined,
fullConfig: config,
// When rendering camera-level sections, provide the section path so
// field templates can look up keys under the `config/cameras` namespace
// When using a consolidated global namespace, keys are nested
// under the section name (e.g., `audio.label`) so provide the
// section prefix to templates so they can attempt `${section}.${field}` lookups.
sectionI18nPrefix: sectionPath,
t,
renderers: wrappedRenderers,
sectionDocs: sectionConfig.sectionDocs,
fieldDocs: sectionConfig.fieldDocs,
hiddenFields: effectiveHiddenFields,
restartRequired: sectionConfig.restartRequired,
requiresRestart,
isProfile: !!profileName,
}}
/>
{!embedded && (
<div
@ -1253,12 +1288,7 @@ export function ConfigSection({
<CollapsibleTrigger asChild>
<div className="flex cursor-pointer items-center justify-between">
<div className="flex items-center gap-3">
<Heading
as="h4"
className={level === "replay" ? "text-base" : undefined}
>
{title}
</Heading>
<Heading as="h4">{title}</Heading>
{showOverrideIndicator &&
effectiveLevel === "camera" &&
(profileOverridesSection || isOverridden) &&

View File

@ -5,9 +5,8 @@ import {
getUiOptions,
ADDITIONAL_PROPERTY_FLAG,
} from "@rjsf/utils";
import { ComponentType, ReactNode, useContext } from "react";
import { ComponentType, ReactNode } from "react";
import { isValidElement } from "react";
import { FieldMessagesContext } from "../../FieldMessagesContext";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
@ -96,7 +95,6 @@ export function FieldTemplate(props: FieldTemplateProps) {
"views/settings",
]);
const { getLocaleDocUrl } = useDocDomain();
const allFieldMessages = useContext(FieldMessagesContext);
if (hidden) {
return <div className="hidden">{children}</div>;
@ -386,15 +384,21 @@ export function FieldTemplate(props: FieldTemplateProps) {
const beforeContent = renderCustom(beforeSpec);
const afterContent = renderCustom(afterSpec);
// Read field-level conditional messages from FieldMessagesContext
const fieldPathStr = pathSegments.join(".");
const fieldMessageSpecs = allFieldMessages.filter(
(m) => m.field === fieldPathStr,
);
const beforeMessages = fieldMessageSpecs.filter(
// Render conditional field messages from ui:messages
const fieldMessageSpecs = uiSchema?.["ui:messages"] as
| Array<{
key: string;
messageKey: string;
severity: string;
position?: string;
}>
| undefined;
const beforeMessages = fieldMessageSpecs?.filter(
(m) => (m.position ?? "before") === "before",
);
const afterMessages = fieldMessageSpecs.filter((m) => m.position === "after");
const afterMessages = fieldMessageSpecs?.filter(
(m) => m.position === "after",
);
const beforeMessagesContent =
beforeMessages && beforeMessages.length > 0 ? (
<div className="space-y-2">

View File

@ -354,18 +354,6 @@ export default function Replay() {
</div>
) : (
<div className="space-y-6">
<ConfigSectionTemplate
sectionKey="detect"
level="replay"
cameraName={status.replay_camera ?? undefined}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
<ConfigSectionTemplate
sectionKey="motion"
level="replay"

View File

@ -36,15 +36,7 @@ import axios from "axios";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
import {
Tooltip,
TooltipContent,
@ -61,17 +53,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const REORDER_SAVED_INDICATOR_MS = 1500;
@ -501,7 +482,7 @@ function EnabledCameraRow({
<LuGripVertical className="size-4" />
</button>
<CameraNameLabel camera={camera} />
<CameraDetailsEditor
<CameraFriendlyNameEditor
cameraName={camera}
onConfigChanged={onConfigChanged}
/>
@ -538,91 +519,25 @@ function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) {
);
}
type CameraDetailsEditorProps = {
type CameraFriendlyNameEditorProps = {
cameraName: string;
onConfigChanged: () => Promise<unknown>;
};
type CameraDetailsFormValues = {
friendlyName: string;
webuiUrl: string;
};
function CameraDetailsEditor({
function CameraFriendlyNameEditor({
cameraName,
onConfigChanged,
}: CameraDetailsEditorProps) {
}: CameraFriendlyNameEditorProps) {
const { t } = useTranslation(["views/settings", "common"]);
const { data: config } = useSWR<FrigateConfig>("config");
const [open, setOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const currentFriendlyName = config?.cameras?.[cameraName]?.friendly_name;
const currentWebuiUrl = config?.cameras?.[cameraName]?.webui_url;
const formSchema = useMemo(
() =>
z.object({
friendlyName: z.string(),
webuiUrl: z.string().refine(
(val) => {
const trimmed = val.trim();
if (!trimmed) return true;
try {
new URL(trimmed);
return true;
} catch {
return false;
}
},
{
message: t("cameraManagement.streams.details.webuiUrlInvalid", {
ns: "views/settings",
}),
},
),
}),
[t],
);
const form = useForm<CameraDetailsFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
friendlyName: currentFriendlyName ?? "",
webuiUrl: currentWebuiUrl ?? "",
},
});
// Reset form values from config whenever the dialog is opened.
useEffect(() => {
if (open) {
form.reset({
friendlyName: currentFriendlyName ?? "",
webuiUrl: currentWebuiUrl ?? "",
});
}
}, [open, currentFriendlyName, currentWebuiUrl, form]);
const onSubmit = useCallback(
async (values: CameraDetailsFormValues) => {
const onSave = useCallback(
async (text: string) => {
if (isSaving) return;
// only send fields the user actually changed
const newFriendly = values.friendlyName.trim() || null;
const newWebui = values.webuiUrl.trim() || null;
const cameraUpdate: Record<string, string | null> = {};
if (newFriendly !== (currentFriendlyName ?? null)) {
cameraUpdate.friendly_name = newFriendly;
}
if (newWebui !== (currentWebuiUrl ?? null)) {
cameraUpdate.webui_url = newWebui;
}
if (Object.keys(cameraUpdate).length === 0) {
setOpen(false);
return;
}
setIsSaving(true);
try {
@ -630,7 +545,9 @@ function CameraDetailsEditor({
requires_restart: 0,
config_data: {
cameras: {
[cameraName]: cameraUpdate,
[cameraName]: {
friendly_name: text.trim() || null,
},
},
},
});
@ -656,17 +573,10 @@ function CameraDetailsEditor({
setIsSaving(false);
}
},
[
cameraName,
currentFriendlyName,
currentWebuiUrl,
isSaving,
onConfigChanged,
t,
],
[cameraName, isSaving, onConfigChanged, t],
);
const editLabel = t("cameraManagement.streams.details.edit", {
const renameLabel = t("cameraManagement.streams.friendlyName.rename", {
ns: "views/settings",
});
@ -678,107 +588,30 @@ function CameraDetailsEditor({
variant="ghost"
size="icon"
className="size-7"
aria-label={editLabel}
aria-label={renameLabel}
onClick={() => setOpen(true)}
disabled={isSaving}
>
<LuPencil className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{editLabel}</TooltipContent>
<TooltipContent>{renameLabel}</TooltipContent>
</Tooltip>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("cameraManagement.streams.details.title", {
ns: "views/settings",
})}
</DialogTitle>
<DialogDescription>
{t("cameraManagement.streams.details.description", {
ns: "views/settings",
})}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="friendlyName"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("cameraManagement.streams.details.friendlyNameLabel", {
ns: "views/settings",
})}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={cameraName}
disabled={isSaving}
autoFocus
/>
</FormControl>
<p className="text-xs text-muted-foreground">
{t("cameraManagement.streams.details.friendlyNameHelp", {
ns: "views/settings",
})}
</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="webuiUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("cameraManagement.streams.details.webuiUrlLabel", {
ns: "views/settings",
})}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://"
disabled={isSaving}
/>
</FormControl>
<p className="text-xs text-muted-foreground">
{t("cameraManagement.streams.details.webuiUrlHelp", {
ns: "views/settings",
})}
</p>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="pt-2">
<Button
type="button"
disabled={isSaving}
onClick={() => setOpen(false)}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button variant="select" type="submit" disabled={isSaving}>
{isSaving ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
<TextEntryDialog
open={open}
setOpen={setOpen}
title={t("cameraManagement.streams.friendlyName.title", {
ns: "views/settings",
})}
description={t("cameraManagement.streams.friendlyName.description", {
ns: "views/settings",
})}
defaultValue={currentFriendlyName ?? ""}
placeholder={currentFriendlyName ? undefined : cameraName}
allowEmpty
isSaving={isSaving}
onSave={onSave}
/>
</>
);
}