mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-26 22:31:54 +03:00
Merge df829dc395 into a4a592b4e6
This commit is contained in:
commit
c510d2a8a1
@ -49,15 +49,14 @@ You should have at least 8 GB of RAM available (or VRAM if running on GPU) to ru
|
||||
|
||||
### Model Types: Instruct vs Thinking
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
- **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.
|
||||
Frigate manages reasoning per task automatically:
|
||||
|
||||
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).
|
||||
- **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.
|
||||
|
||||
**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.
|
||||
You can use a pure instruct, hybrid, or thinking-capable model with Frigate — no extra configuration is required to disable thinking for descriptions.
|
||||
|
||||
### llama.cpp
|
||||
|
||||
|
||||
@ -202,6 +202,25 @@ 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:
|
||||
@ -253,6 +272,7 @@ 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)
|
||||
|
||||
@ -332,14 +332,18 @@ class CameraState:
|
||||
current_detections[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,
|
||||
}
|
||||
# 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,
|
||||
}
|
||||
|
||||
# save initial thumbnail data and best object
|
||||
thumbnail_data = {
|
||||
|
||||
@ -238,6 +238,10 @@ 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:
|
||||
@ -287,8 +291,8 @@ class DebugReplayManager:
|
||||
},
|
||||
"birdseye": {"enabled": False},
|
||||
"audio": {"enabled": False},
|
||||
"lpr": {"enabled": False},
|
||||
"face_recognition": {"enabled": False},
|
||||
"lpr": lpr_dict,
|
||||
"face_recognition": face_recognition_dict,
|
||||
}
|
||||
|
||||
def _cleanup_db(self, camera_name: str) -> None:
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
"""Gemini Provider for Frigate AI."""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
@ -14,6 +16,27 @@ 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)
|
||||
@ -169,11 +192,17 @@ class GeminiClient(GenAIClient):
|
||||
if not isinstance(tc_args, dict):
|
||||
tc_args = {}
|
||||
if tc_name:
|
||||
parts.append(
|
||||
types.Part.from_function_call(
|
||||
name=tc_name, args=tc_args
|
||||
)
|
||||
fc_part = 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))
|
||||
@ -310,6 +339,9 @@ 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)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@ -418,11 +450,17 @@ class GeminiClient(GenAIClient):
|
||||
if not isinstance(tc_args, dict):
|
||||
tc_args = {}
|
||||
if tc_name:
|
||||
parts.append(
|
||||
types.Part.from_function_call(
|
||||
name=tc_name, args=tc_args
|
||||
)
|
||||
fc_part = 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))
|
||||
@ -588,6 +626,7 @@ class GeminiClient(GenAIClient):
|
||||
"id": tool_call_id,
|
||||
"name": tool_call_name,
|
||||
"arguments": "",
|
||||
"thought_signature": None,
|
||||
}
|
||||
|
||||
# Accumulate arguments
|
||||
@ -598,6 +637,13 @@ 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
|
||||
@ -618,6 +664,9 @@ 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"
|
||||
|
||||
@ -69,6 +69,14 @@ 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
|
||||
]
|
||||
|
||||
@ -167,8 +167,9 @@ 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 = datetime.datetime.now().timestamp() - self.start_time.value
|
||||
duration = time.monotonic() - mono_start
|
||||
frame_manager.close(connection_id)
|
||||
|
||||
if connection_id not in self.outputs:
|
||||
|
||||
@ -1331,6 +1331,8 @@ 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:
|
||||
|
||||
79
frigate/test/test_camera_maintainer.py
Normal file
79
frigate/test/test_camera_maintainer.py
Normal file
@ -0,0 +1,79 @@
|
||||
"""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()
|
||||
137
frigate/test/test_shared_memory_frame_manager.py
Normal file
137
frigate/test/test_shared_memory_frame_manager.py
Normal file
@ -0,0 +1,137 @@
|
||||
"""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 (larger) 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)
|
||||
large = _fake_shm(size=10_000)
|
||||
manager.shm_store["cam_frame0"] = small
|
||||
|
||||
with patch("frigate.util.image.UntrackedSharedMemory", return_value=large):
|
||||
arr = manager.get("cam_frame0", (50, 50))
|
||||
|
||||
self.assertIsNotNone(arr)
|
||||
# Numpy now reports against the large segment, not the small one.
|
||||
self.assertEqual(arr.shape, (50, 50))
|
||||
self.assertIs(manager.shm_store["cam_frame0"], large)
|
||||
|
||||
def test_get_keeps_cached_segment_when_size_sufficient(self) -> None:
|
||||
"""Don't pay the reopen cost when the cached ref is fine."""
|
||||
manager = SharedMemoryFrameManager()
|
||||
|
||||
cached = _fake_shm(size=10_000)
|
||||
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=10_000)
|
||||
|
||||
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()
|
||||
@ -1089,10 +1089,32 @@ class SharedMemoryFrameManager(FrameManager):
|
||||
|
||||
def get(self, name: str, shape) -> Optional[np.ndarray]:
|
||||
try:
|
||||
if name in self.shm_store:
|
||||
shm = self.shm_store[name]
|
||||
else:
|
||||
required = int(np.prod(shape))
|
||||
shm = self.shm_store.get(name)
|
||||
if shm is not None and shm.size < required:
|
||||
# Cached reference points at an older, smaller segment
|
||||
# that was unlinked and recreated at a larger size in a
|
||||
# camera add/remove cycle. Drop the stale ref so we
|
||||
# reopen the current segment.
|
||||
try:
|
||||
shm.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.shm_store.pop(name, None)
|
||||
shm = None
|
||||
if shm is None:
|
||||
shm = UntrackedSharedMemory(name=name)
|
||||
if shm.size < required:
|
||||
# Transient mid-recreate state: the OS-level segment
|
||||
# is still at the previous (smaller) size because the
|
||||
# maintainer hasn't allocated the new one yet. Don't
|
||||
# cache it (so the next call re-opens once the
|
||||
# maintainer has caught up) and skip this frame.
|
||||
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:
|
||||
|
||||
@ -484,11 +484,15 @@
|
||||
"reorderHandle": "Drag to reorder",
|
||||
"saving": "Saving…",
|
||||
"saved": "Saved",
|
||||
"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"
|
||||
"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)."
|
||||
}
|
||||
},
|
||||
"cameraConfig": {
|
||||
|
||||
@ -36,7 +36,15 @@ 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 TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@ -53,6 +61,17 @@ 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;
|
||||
|
||||
@ -482,7 +501,7 @@ function EnabledCameraRow({
|
||||
<LuGripVertical className="size-4" />
|
||||
</button>
|
||||
<CameraNameLabel camera={camera} />
|
||||
<CameraFriendlyNameEditor
|
||||
<CameraDetailsEditor
|
||||
cameraName={camera}
|
||||
onConfigChanged={onConfigChanged}
|
||||
/>
|
||||
@ -519,25 +538,91 @@ function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type CameraFriendlyNameEditorProps = {
|
||||
type CameraDetailsEditorProps = {
|
||||
cameraName: string;
|
||||
onConfigChanged: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
function CameraFriendlyNameEditor({
|
||||
type CameraDetailsFormValues = {
|
||||
friendlyName: string;
|
||||
webuiUrl: string;
|
||||
};
|
||||
|
||||
function CameraDetailsEditor({
|
||||
cameraName,
|
||||
onConfigChanged,
|
||||
}: CameraFriendlyNameEditorProps) {
|
||||
}: CameraDetailsEditorProps) {
|
||||
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 onSave = useCallback(
|
||||
async (text: string) => {
|
||||
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) => {
|
||||
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 {
|
||||
@ -545,9 +630,7 @@ function CameraFriendlyNameEditor({
|
||||
requires_restart: 0,
|
||||
config_data: {
|
||||
cameras: {
|
||||
[cameraName]: {
|
||||
friendly_name: text.trim() || null,
|
||||
},
|
||||
[cameraName]: cameraUpdate,
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -573,10 +656,17 @@ function CameraFriendlyNameEditor({
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[cameraName, isSaving, onConfigChanged, t],
|
||||
[
|
||||
cameraName,
|
||||
currentFriendlyName,
|
||||
currentWebuiUrl,
|
||||
isSaving,
|
||||
onConfigChanged,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const renameLabel = t("cameraManagement.streams.friendlyName.rename", {
|
||||
const editLabel = t("cameraManagement.streams.details.edit", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
|
||||
@ -588,30 +678,107 @@ function CameraFriendlyNameEditor({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label={renameLabel}
|
||||
aria-label={editLabel}
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<LuPencil className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{renameLabel}</TooltipContent>
|
||||
<TooltipContent>{editLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user