Compare commits

...

7 Commits

Author SHA1 Message Date
dependabot[bot]
4db121f7c1
Merge 9c5fabc96e into b0b00fe1d0 2026-05-19 20:00:12 +01:00
Nicolas Mowen
b0b00fe1d0
GenAI Refactor (#23253)
* Ensure runtime options are passed

* Add attribute info to prompt when configured

* Move GenAI plugins to dedicated directory

* Migrate prompts to dedicated folder

* Move chat prompts to prompts

* Implement reasoning traces in the UI

* Cleanup

* Make azure a subclass of openai

* Implement reasoning for other providers

* mypy

* Cleanup
2026-05-19 13:03:57 -05:00
Josh Hawkins
b1de5e2290
Add attributes to UI filters list (#23250)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* preserve user-set min_score on attribute filters instead of bumping any 0.5 value

use model_fields_set to distinguish "user explicitly set min_score" from "Pydantic applied the generic FilterConfig default of 0.5"

* add config test for attributes

* fix attributes frontend type

* add expanded hidden field context

* extend schema modification

* special case for attributes

* i18n for attributes

* handle dedicated lpr mode

* strip unrendered FilterConfig fields from attribute filter form data to fix validation errors
2026-05-19 08:31:50 -06:00
Josh Hawkins
4fdc107987
Improve go2rtc pane in Settings (#23251)
* improve layout and handling of multiple ffmpeg args in go2rtc pane

* add e2e tests

* fix spacing
2026-05-19 08:30:04 -06:00
GuoQing Liu
a83809de54
fix: fix chat request params miss runtime_options (#23247)
* fix: fix chat request params miss runtime_options

* fix: mypy
2026-05-19 06:29:28 -06:00
Josh Hawkins
43d97acd21
Miscellaneous fixes (#23238)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* start audio transcription post processor when enabled on any camera

* Fetch embed key whenever an error occurs in case the llama server was restarted

* mypy

* add tooltips for colored dots in settings menu

* add ability to reorder cameras from management pane

* add ability to reorder birdseye

* add reordering save text to camera management view

* Include NPU in latency performance hint

* Implement turbo for NPU on object detection

* hide order fields

* drop auto-derived field paths from camera value when unset globally

* use correct field type for export hwaccel args

* add debug replay to detail actions menu

* clarify debug replay in docs

* guard get_current_frame_time against missing camera state

* Implement debug reply from export

* Refactor debug replay to use sources for dynamic playback

* Mypy

* fix debug export replay source timestamp handling

* skip replay cameras in stats immediately

* broadcast debug replay state over ws and buffer pre-OPEN sends

- push debug replay session state over the job_state ws topic so the status bar reacts instantly to start/stop without polling
- fix child-effect-before-parent-effect race in WsProvider that silently dropped initial snapshot requests on cold load

* fix debug replay test hang

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2026-05-18 22:52:40 -05:00
Josh Hawkins
d968f00500
Settings UI fixes (#23237)
Some checks are pending
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* detector UI fixes

- derive detector and model from memo rather than using two drain useeffects
- sanitize save payload through sanitizeSectionData to prevent yaml validation issues

* increase display duration for restart required toasts

* mimic logic in detector section for save all button

also, increase toast duration for restart required toasts

* fixes and tweaks

- use section hidden fields for sanitization instead of duplicating code
- use parent hooks so save all, pending data, and the status dots work correctly
2026-05-18 13:22:54 -06:00
60 changed files with 3841 additions and 1504 deletions

View File

@ -37,6 +37,8 @@ The per-clip variation is typically quite low and is mostly an artifact of keyfr
Debug Replay lets you re-run Frigate's detection pipeline against a section of recorded video without manually configuring a dummy camera. It automatically extracts the recording, creates a temporary camera with the same detection settings as the original, and loops the clip through the pipeline so you can observe detections in real time.
Debug Replay isn't intended to be a one-stop pane for all Frigate diagnostics or a comprehensive debugging environment for every Frigate feature. It merely makes it easier to spin up a "dummy camera" and perform some common adjustments in real-time. You'll still need to use the normal tools (logs, an MQTT client, etc) to debug your feature.
### When to use
- Reproducing a detection or tracking issue from a specific time range

View File

@ -35,9 +35,13 @@ from frigate.api.defs.response.chat_response import (
ToolCall,
)
from frigate.api.defs.tags import Tags
from frigate.api.event import events
from frigate.api.event import _build_attribute_filter_clause, events
from frigate.config import FrigateConfig
from frigate.config.ui import UnitSystemEnum
from frigate.genai.prompts import (
build_chat_system_prompt,
get_attribute_classifications,
get_tool_definitions,
)
from frigate.genai.utils import build_assistant_message_for_conversation
from frigate.jobs.vlm_watch import (
get_vlm_watch_job,
@ -68,390 +72,6 @@ class VLMMonitorRequest(BaseModel):
zones: List[str] = []
def get_tool_definitions(
semantic_search_enabled: bool = False,
) -> List[Dict[str, Any]]:
"""
Get OpenAI-compatible tool definitions for Frigate.
Returns a list of tool definitions that can be used with OpenAI-compatible
function calling APIs. When semantic search is enabled, the search_objects
tool exposes an additional `semantic_query` parameter for descriptive
queries (e.g. "person riding a lawn mower") and find_similar_objects is
included.
"""
search_objects_properties: Dict[str, Any] = {
"camera": {
"type": "string",
"description": "Camera name to filter by (optional).",
},
"label": {
"type": "string",
"description": (
"Generic object class to filter by — one of the tracked detector "
"labels such as 'person', 'package', 'car', 'dog', 'bird'. Use "
"this for broad queries like 'show me all cars today'. Combine "
"with semantic_query when the user also describes appearance or "
"behavior (e.g. label='person', semantic_query='riding a lawn "
"mower')."
),
},
"sub_label": {
"type": "string",
"description": (
"Filter by a DISCRETE NAMED entity recognized in the detection. "
"Use this for: a known person's name ('John'), a delivery "
"company ('Amazon', 'UPS'), a recognized animal species or "
"breed ('blue jay', 'cardinal', 'golden retriever'), or a "
"license plate string. When filtering by a specific name, set "
"only sub_label and leave label unset. Do NOT use sub_label "
"for descriptions of appearance, clothing, or actions — those "
"belong in semantic_query."
),
},
"after": {
"type": "string",
"description": "Start time in ISO 8601 format (e.g., '2024-01-01T00:00:00Z').",
},
"before": {
"type": "string",
"description": "End time in ISO 8601 format (e.g., '2024-01-01T23:59:59Z').",
},
"zones": {
"type": "array",
"items": {"type": "string"},
"description": "List of zone names to filter by.",
},
"limit": {
"type": "integer",
"description": "Maximum number of objects to return (default: 25).",
"default": 25,
},
}
if semantic_search_enabled:
search_objects_properties["semantic_query"] = {
"type": "string",
"description": (
"Optional natural-language description of a PHYSICAL "
"CHARACTERISTIC, APPEARANCE, or ACTIVITY the user mentioned, "
"used to semantically narrow results. Only set this when the "
"user describes something beyond what label and sub_label can "
"express on their own.\n"
"USE for descriptive phrases like: 'riding a lawn mower', "
"'wearing a red jacket', 'carrying a package', 'walking a "
"dog', 'on a bicycle', 'holding an umbrella'.\n"
"DO NOT USE for:\n"
"- specific named people, pets, or delivery companies → use sub_label\n"
"- animal species or breed names like 'blue jay', 'cardinal', "
"'golden retriever' → use sub_label\n"
"- license plate strings → use sub_label\n"
"- generic object queries like 'all cars today' or 'every "
"person' → use label alone with no semantic_query\n"
"When set, combine with label/time/camera/zone filters as "
"usual (e.g. label='person', semantic_query='riding a lawn "
"mower', after='2024-05-01T00:00:00Z')."
),
}
search_objects_description = (
"Search the historical record of detected objects in Frigate. "
"Use this ONLY for questions about the PAST — e.g. 'did anyone come by today?', "
"'when was the last car?', 'show me detections from yesterday'. "
"Do NOT use this for monitoring or alerting requests about future events — "
"use start_camera_watch instead for those. "
"An 'object' in Frigate represents a tracked detection (e.g., a person, package, car).\n\n"
"Choose filters based on what the user is asking for:\n"
"- Generic class query ('show me all cars today'): set `label` only.\n"
"- Specific NAMED entity (known person, delivery company, animal "
"species/breed like 'blue jay' or 'golden retriever', license "
"plate): set `sub_label` only and leave `label` unset.\n"
)
if semantic_search_enabled:
search_objects_description += (
"- Physical CHARACTERISTIC, APPEARANCE, or ACTIVITY that is not a "
"discrete name ('person riding a lawn mower', 'someone in a red "
"jacket', 'person carrying a package'): set `semantic_query` with "
"the descriptive phrase, optionally alongside `label` for the "
"object class. Do NOT put descriptive phrases in sub_label."
)
return [
{
"type": "function",
"function": {
"name": "search_objects",
"description": search_objects_description,
"parameters": {
"type": "object",
"properties": search_objects_properties,
},
"required": [],
},
},
{
"type": "function",
"function": {
"name": "find_similar_objects",
"description": (
"Find tracked objects that are visually and semantically similar "
"to a specific past event. Use this when the user references a "
"particular object they have seen and wants to find other "
"sightings of the same or similar one ('that green car', 'the "
"person in the red jacket', 'the package that was delivered'). "
"Prefer this over search_objects whenever the user's intent is "
"'find more like this specific one.' Use search_objects first "
"only if you need to locate the anchor event. Requires semantic "
"search to be enabled."
),
"parameters": {
"type": "object",
"properties": {
"event_id": {
"type": "string",
"description": "The id of the anchor event to find similar objects to.",
},
"after": {
"type": "string",
"description": "Start time in ISO 8601 format (e.g., '2024-01-01T00:00:00Z').",
},
"before": {
"type": "string",
"description": "End time in ISO 8601 format (e.g., '2024-01-01T23:59:59Z').",
},
"cameras": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of cameras to restrict to. Defaults to all.",
},
"labels": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of labels to restrict to. Defaults to the anchor event's label.",
},
"sub_labels": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of sub_labels (names) to restrict to.",
},
"zones": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of zones. An event matches if any of its zones overlap.",
},
"similarity_mode": {
"type": "string",
"enum": ["visual", "semantic", "fused"],
"description": "Which similarity signal(s) to use. 'fused' (default) combines visual and semantic.",
"default": "fused",
},
"min_score": {
"type": "number",
"description": "Drop matches with a similarity score below this threshold (0.0-1.0).",
},
"limit": {
"type": "integer",
"description": "Maximum number of matches to return (default: 10).",
"default": 10,
},
},
"required": ["event_id"],
},
},
},
{
"type": "function",
"function": {
"name": "set_camera_state",
"description": (
"Change a camera's feature state (e.g., turn detection on/off, enable/disable recordings). "
"Use camera='*' to apply to all cameras at once. "
"Only call this tool when the user explicitly asks to change a camera setting. "
"Requires admin privileges."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": "Camera name to target, or '*' to target all cameras.",
},
"feature": {
"type": "string",
"enum": [
"detect",
"record",
"snapshots",
"audio",
"motion",
"enabled",
"birdseye",
"birdseye_mode",
"improve_contrast",
"ptz_autotracker",
"motion_contour_area",
"motion_threshold",
"notifications",
"audio_transcription",
"review_alerts",
"review_detections",
"object_descriptions",
"review_descriptions",
"profile",
],
"description": (
"The feature to change. Most features accept ON or OFF. "
"birdseye_mode accepts CONTINUOUS, MOTION, or OBJECTS. "
"motion_contour_area and motion_threshold accept a number. "
"profile accepts a profile name or 'none' to deactivate (requires camera='*')."
),
},
"value": {
"type": "string",
"description": "The value to set. ON or OFF for toggles, a number for thresholds, a profile name or 'none' for profile.",
},
},
"required": ["camera", "feature", "value"],
},
},
},
{
"type": "function",
"function": {
"name": "get_live_context",
"description": (
"Get the current live image and detection information for a camera: objects being tracked, "
"zones, timestamps. Use this to understand what is visible in the live view. "
"Call this when answering questions about what is happening right now on a specific camera."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": "Camera name to get live context for.",
},
},
"required": ["camera"],
},
},
},
{
"type": "function",
"function": {
"name": "start_camera_watch",
"description": (
"Start a continuous VLM watch job that monitors a camera and sends a notification "
"when a specified condition is met. Use this when the user wants to be alerted about "
"a future event, e.g. 'tell me when guests arrive' or 'notify me when the package is picked up'. "
"Only one watch job can run at a time. Returns a job ID."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": "Camera ID to monitor.",
},
"condition": {
"type": "string",
"description": (
"Natural-language description of the condition to watch for, "
"e.g. 'a person arrives at the front door'."
),
},
"max_duration_minutes": {
"type": "integer",
"description": "Maximum time to watch before giving up (minutes, default 60).",
"default": 60,
},
"labels": {
"type": "array",
"items": {"type": "string"},
"description": "Object labels that should trigger a VLM check (e.g. ['person', 'car']). If omitted, any detection on the camera triggers a check.",
},
"zones": {
"type": "array",
"items": {"type": "string"},
"description": "Zone names to filter by. If specified, only detections in these zones trigger a VLM check.",
},
},
"required": ["camera", "condition"],
},
},
},
{
"type": "function",
"function": {
"name": "stop_camera_watch",
"description": (
"Cancel the currently running VLM watch job. Use this when the user wants to "
"stop a previously started watch, e.g. 'stop watching the front door'."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_profile_status",
"description": (
"Get the current profile status including the active profile and "
"timestamps of when each profile was last activated. Use this to "
"determine time periods for recap requests — e.g. when the user asks "
"'what happened while I was away?', call this first to find the relevant "
"time window based on profile activation history."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_recap",
"description": (
"Get a recap of all activity (alerts and detections) for a given time period. "
"Use this after calling get_profile_status to retrieve what happened during "
"a specific window — e.g. 'what happened while I was away?'. Returns a "
"chronological list of activity with camera, objects, zones, and GenAI-generated "
"descriptions when available. Summarize the results for the user."
),
"parameters": {
"type": "object",
"properties": {
"after": {
"type": "string",
"description": "Start of the time period in ISO 8601 format (e.g. '2025-03-15T08:00:00').",
},
"before": {
"type": "string",
"description": "End of the time period in ISO 8601 format (e.g. '2025-03-15T17:00:00').",
},
"cameras": {
"type": "string",
"description": "Comma-separated camera IDs to include, or 'all' for all cameras. Default is 'all'.",
},
"severity": {
"type": "string",
"enum": ["alert", "detection"],
"description": "Filter by severity level. Omit to include both alerts and detections.",
},
},
"required": ["after", "before"],
},
},
},
]
@router.get(
"/chat/tools",
dependencies=[Depends(allow_any_authenticated())],
@ -460,10 +80,13 @@ def get_tool_definitions(
)
def get_tools(request: Request) -> JSONResponse:
"""Get list of available tools for LLM function calling."""
semantic_search_enabled = bool(
getattr(request.app.frigate_config.semantic_search, "enabled", False)
config = request.app.frigate_config
semantic_search_enabled = bool(getattr(config.semantic_search, "enabled", False))
attribute_classifications = get_attribute_classifications(config)
tools = get_tool_definitions(
semantic_search_enabled=semantic_search_enabled,
attribute_classifications=attribute_classifications,
)
tools = get_tool_definitions(semantic_search_enabled=semantic_search_enabled)
return JSONResponse(content={"tools": tools})
@ -554,11 +177,14 @@ async def _execute_search_objects(
elif zones is None:
zones = "all"
attribute = arguments.get("attribute")
# Build query parameters compatible with EventsQueryParams
query_params = EventsQueryParams(
cameras=arguments.get("camera", "all"),
labels=arguments.get("label", "all"),
sub_labels=arguments.get("sub_label", "all"), # case-insensitive on the backend
attributes=attribute if attribute else "all",
zones=zones,
zone=zones,
after=after,
@ -626,6 +252,7 @@ async def _execute_search_objects_semantic(
label = arguments.get("label")
sub_label = arguments.get("sub_label")
attribute = arguments.get("attribute")
zones = arguments.get("zones")
if isinstance(zones, list) and zones:
@ -668,6 +295,10 @@ async def _execute_search_objects_semantic(
if sub_label:
# case-insensitive match to mirror events() behavior
clauses.append(fn.LOWER(Event.sub_label.cast("text")) == sub_label.lower())
if attribute:
attribute_clause = _build_attribute_filter_clause(attribute)
if attribute_clause is not None:
clauses.append(attribute_clause)
if zones:
zone_clauses = [Event.zones.cast("text") % f'*"{zone}"*' for zone in zones]
clauses.append(reduce(operator.or_, zone_clauses))
@ -1481,72 +1112,19 @@ async def chat_completion(
config = request.app.frigate_config
semantic_search_enabled = bool(getattr(config.semantic_search, "enabled", False))
tools = get_tool_definitions(semantic_search_enabled=semantic_search_enabled)
attribute_classifications = get_attribute_classifications(config)
tools = get_tool_definitions(
semantic_search_enabled=semantic_search_enabled,
attribute_classifications=attribute_classifications,
)
conversation = []
current_datetime = datetime.now()
current_date_str = current_datetime.strftime("%Y-%m-%d")
current_time_str = current_datetime.strftime("%I:%M:%S %p")
cameras_info = []
has_speed_zone = False
for camera_id in allowed_cameras:
if camera_id not in config.cameras:
continue
camera_config = config.cameras[camera_id]
friendly_name = (
camera_config.friendly_name
if camera_config.friendly_name
else camera_id.replace("_", " ").title()
)
zone_names = list(camera_config.zones.keys())
if not has_speed_zone:
has_speed_zone = any(
zone.distances for zone in camera_config.zones.values()
)
if zone_names:
cameras_info.append(
f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_names)})"
)
else:
cameras_info.append(f" - {friendly_name} (ID: {camera_id})")
cameras_section = ""
if cameras_info:
cameras_section = (
"\n\nAvailable cameras:\n"
+ "\n".join(cameras_info)
+ "\n\nWhen users refer to cameras by their friendly name (e.g., 'Back Deck Camera'), use the corresponding camera ID (e.g., 'back_deck_cam') in tool calls."
)
speed_units_section = ""
if has_speed_zone:
speed_unit = (
"mph" if config.ui.unit_system == UnitSystemEnum.imperial else "km/h"
)
speed_units_section = f"\n\nReport object speeds to the user in {speed_unit}."
semantic_search_section = ""
if semantic_search_enabled:
semantic_search_section = (
"\n\nWhen routing a search_objects call, pick filters by the shape of the user's request:\n"
"- Generic class ('show me all cars today'): set `label` only.\n"
"- Specific named entity — a known person ('John'), delivery company ('Amazon'), animal species/breed ('blue jay', 'cardinal', 'golden retriever'), or license plate: set `sub_label` only and leave `label` unset.\n"
"- Physical characteristic, appearance, or activity that is NOT a discrete name ('find me people riding a lawn mower', 'someone in a red jacket', 'a person carrying a package'): set `semantic_query` with the descriptive phrase, optionally combined with `label` for the object class. Never put descriptive phrases in `sub_label`."
)
system_prompt = f"""You are a helpful assistant for Frigate, a security camera NVR system. You help users answer questions about their cameras, detected objects, and events.
Current server local date and time: {current_date_str} at {current_time_str}
Do not start your response with phrases like "I will check...", "Let me see...", or "Let me look...". Answer directly.
Always present times to the user in the server's local timezone. When tool results include start_time_local and end_time_local, use those exact strings when listing or describing detection times—do not convert or invent timestamps. Do not use UTC or ISO format with Z for the user-facing answer unless the tool result only provides Unix timestamps without local time fields.
When users ask about "today", "yesterday", "this week", etc., use the current date above as reference.
When searching for objects or events, use ISO 8601 format for dates (e.g., {current_date_str}T00:00:00Z for the start of today).
Always be accurate with time calculations based on the current date provided.
When a user refers to a specific object they have seen or describe with identifying details ("that green car", "the person in the red jacket", "a package left today"), prefer the find_similar_objects tool over search_objects. Use search_objects first only to locate the anchor event, then pass its id to find_similar_objects. For generic queries like "show me all cars today", keep using search_objects. If a user message begins with [attached_event:<id>], treat that event id as the anchor for any similarity or "tell me more" request in the same message and call find_similar_objects with that id.{semantic_search_section}{cameras_section}{speed_units_section}"""
system_prompt = build_chat_system_prompt(
config=config,
allowed_cameras=allowed_cameras,
semantic_search_enabled=semantic_search_enabled,
attribute_classifications=attribute_classifications,
)
conversation.append(
{
@ -1607,6 +1185,13 @@ When a user refers to a specific object they have seen or describe with identify
)
+ b"\n"
)
elif kind == "reasoning_delta":
yield (
json.dumps({"type": "reasoning", "delta": value}).encode(
"utf-8"
)
+ b"\n"
)
elif kind == "stats":
yield (
json.dumps({"type": "stats", **value}).encode("utf-8")
@ -1707,6 +1292,7 @@ When a user refers to a specific object they have seen or describe with identify
final_content = response.get("content") or ""
if body.stream:
final_reasoning = response.get("reasoning")
async def stream_body() -> Any:
if tool_calls:
@ -1721,6 +1307,15 @@ When a user refers to a specific object they have seen or describe with identify
).encode("utf-8")
+ b"\n"
)
# Emit the full reasoning trace up front when the
# underlying client did not stream it
if final_reasoning:
yield (
json.dumps(
{"type": "reasoning", "delta": final_reasoning}
).encode("utf-8")
+ b"\n"
)
# Stream content in word-sized chunks for smooth UX
for part in chunk_content(final_content):
yield (
@ -1741,6 +1336,7 @@ When a user refers to a specific object they have seen or describe with identify
message=ChatMessageResponse(
role="assistant",
content=final_content,
reasoning=response.get("reasoning"),
tool_calls=None,
),
finish_reason=response.get("finish_reason", "stop"),

View File

@ -6,11 +6,18 @@ from datetime import datetime
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from peewee import DoesNotExist
from pydantic import BaseModel, Field
from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags
from frigate.jobs.debug_replay import start_debug_replay_job
from frigate.jobs.debug_replay import (
ExportDebugReplaySource,
RecordingDebugReplaySource,
start_debug_replay_job,
)
from frigate.models import Export
from frigate.util.services import get_video_properties
logger = logging.getLogger(__name__)
@ -25,6 +32,12 @@ class DebugReplayStartBody(BaseModel):
end_time: float = Field(title="End timestamp")
class DebugReplayStartFromExportBody(BaseModel):
"""Request body for starting a debug replay session from an export."""
export_id: str = Field(title="Export id")
class DebugReplayStartResponse(BaseModel):
"""Response for starting a debug replay session."""
@ -73,13 +86,95 @@ class DebugReplayStopResponse(BaseModel):
async def start_debug_replay(request: Request, body: DebugReplayStartBody):
"""Start a debug replay session asynchronously."""
replay_manager = request.app.replay_manager
source = RecordingDebugReplaySource(
source_camera=body.camera,
start_ts=body.start_time,
end_ts=body.end_time,
)
try:
job_id = await asyncio.to_thread(
start_debug_replay_job,
source_camera=body.camera,
start_ts=body.start_time,
end_ts=body.end_time,
source=source,
frigate_config=request.app.frigate_config,
config_publisher=request.app.config_publisher,
replay_manager=replay_manager,
)
except RuntimeError:
return JSONResponse(
content={
"success": False,
"message": "A replay session is already active",
},
status_code=409,
)
except ValueError:
logger.exception("Rejected debug replay start request")
return JSONResponse(
content={
"success": False,
"message": "Invalid debug replay parameters",
},
status_code=400,
)
return JSONResponse(
content={
"success": True,
"replay_camera": replay_manager.replay_camera_name,
"job_id": job_id,
},
status_code=202,
)
@router.post(
"/debug_replay/start_from_export",
response_model=DebugReplayStartResponse,
status_code=202,
responses={
400: {"description": "Invalid export, time range, or no recordings"},
404: {"description": "Export not found"},
409: {"description": "A replay session is already active"},
},
dependencies=[Depends(require_role(["admin"]))],
summary="Start debug replay from an export",
description="Start a debug replay session covering an existing export's "
"time range. The end time is derived from the export's video duration.",
)
async def start_debug_replay_from_export(
request: Request, body: DebugReplayStartFromExportBody
):
"""Start a debug replay session from an existing export."""
try:
export: Export = Export.get(Export.id == body.export_id)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export not found"},
status_code=404,
)
properties = await get_video_properties(
request.app.frigate_config.ffmpeg, export.video_path, get_duration=True
)
duration = properties.get("duration", -1)
if duration is None or duration <= 0:
return JSONResponse(
content={
"success": False,
"message": "Could not determine export duration",
},
status_code=400,
)
replay_manager = request.app.replay_manager
source = ExportDebugReplaySource(export=export, duration=float(duration))
try:
job_id = await asyncio.to_thread(
start_debug_replay_job,
source=source,
frigate_config=request.app.frigate_config,
config_publisher=request.app.config_publisher,
replay_manager=replay_manager,

View File

@ -20,6 +20,10 @@ class ChatMessageResponse(BaseModel):
content: Optional[str] = Field(
default=None, description="Message content (None if tool calls present)"
)
reasoning: Optional[str] = Field(
default=None,
description="Separated reasoning/thinking trace if the model emitted one",
)
tool_calls: Optional[list[ToolCallInvocation]] = Field(
default=None, description="Tool calls if LLM wants to call tools"
)

View File

@ -398,7 +398,7 @@ class _StreamingZipBuffer:
def _unique_archive_name(export: Export, used: set[str]) -> str:
base = sanitize_filename(export.name) if export.name else None
if not base:
base = f"{export.camera}_{int(datetime.datetime.timestamp(export.date))}"
base = f"{export.camera}_{int(export.date)}"
candidate = f"{base}.mp4"
counter = 1

View File

@ -629,10 +629,11 @@ class FrigateConfig(FrigateBaseModel):
# set default min_score for object attributes
for attribute in self.model.all_attributes:
if not self.objects.filters.get(attribute):
existing = self.objects.filters.get(attribute)
if existing is None:
self.objects.filters[attribute] = FilterConfig(min_score=0.7)
elif self.objects.filters[attribute].min_score == 0.5:
self.objects.filters[attribute].min_score = 0.7
elif "min_score" not in existing.model_fields_set:
existing.min_score = 0.7
# auto detect hwaccel args
if self.ffmpeg.hwaccel_args == "auto":

View File

@ -9,6 +9,7 @@ import logging
import os
import shutil
import threading
import time
from ruamel.yaml import YAML
@ -25,7 +26,15 @@ from frigate.const import (
REPLAY_DIR,
THUMB_DIR,
)
from frigate.jobs.debug_replay import cancel_debug_replay_job, wait_for_runner
from frigate.jobs.debug_replay import (
JOB_TYPE as DEBUG_REPLAY_JOB_TYPE,
)
from frigate.jobs.debug_replay import (
cancel_debug_replay_job,
wait_for_runner,
)
from frigate.jobs.export import JobStatePublisher
from frigate.types import JobStatusTypesEnum
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
from frigate.util.config import find_config_file
@ -49,6 +58,7 @@ class DebugReplayManager:
self.clip_path: str | None = None
self.start_ts: float | None = None
self.end_ts: float | None = None
self._job_state_publisher = JobStatePublisher()
@property
def active(self) -> bool:
@ -150,6 +160,7 @@ class DebugReplayManager:
return
replay_name = self.replay_camera_name
source_camera = self.source_camera
# Only publish remove if the camera was actually added to the live
# config (i.e. the runner reached the starting_camera phase).
@ -163,6 +174,21 @@ class DebugReplayManager:
self._cleanup_db(replay_name)
self._cleanup_files(replay_name)
self._job_state_publisher.publish(
{
"id": "stopped",
"job_type": DEBUG_REPLAY_JOB_TYPE,
"status": JobStatusTypesEnum.cancelled,
"start_time": None,
"end_time": time.time(),
"error_message": None,
"results": {
"source_camera": source_camera,
"replay_camera_name": replay_name,
},
}
)
self._clear_locked()
logger.info("Debug replay stopped and cleaned up: %s", replay_name)

View File

@ -282,6 +282,13 @@ class OpenVINOModelRunner(BaseModelRunner):
EnrichmentModelTypeEnum.arcface.value,
]
@staticmethod
def is_detection_model(model_type: str) -> bool:
# Import here to avoid circular imports
from frigate.detectors.detector_config import ModelTypeEnum
return model_type in [m.value for m in ModelTypeEnum]
def __init__(self, model_path: str, device: str, model_type: str, **kwargs):
self.model_path = model_path
self.device = device
@ -310,9 +317,15 @@ class OpenVINOModelRunner(BaseModelRunner):
# Apply performance optimization
self.ov_core.set_property(device, {"PERF_COUNT": "NO"})
if device in ["GPU", "AUTO"]:
if device in ["GPU", "AUTO", "NPU"]:
self.ov_core.set_property(device, {"PERFORMANCE_HINT": "LATENCY"})
if device == "NPU" and OpenVINOModelRunner.is_detection_model(model_type):
try:
self.ov_core.set_property(device, {"NPU_TURBO": "YES"})
except Exception as e:
logger.debug(f"NPU_TURBO not supported by driver: {e}")
# Compile model
self.compiled_model = self.ov_core.compile_model(
model=model_path, device_name=device

View File

@ -232,7 +232,7 @@ class EmbeddingMaintainer(threading.Thread):
)
)
if self.config.audio_transcription.enabled and any(
if any(
c.enabled_in_config and c.audio_transcription.enabled
for c in self.config.cameras.values()
):

View File

@ -100,7 +100,10 @@ class AudioProcessor(FrigateProcess):
threading.current_thread().name = "process:audio_manager"
if self.config.audio_transcription.enabled:
if any(
c.enabled_in_config and c.audio_transcription.enabled
for c in self.config.cameras.values()
):
self.transcription_model_runner: AudioTranscriptionModelRunner | None = (
AudioTranscriptionModelRunner(
self.config.audio_transcription.device or "AUTO",
@ -206,7 +209,7 @@ class AudioEventMaintainer(threading.Thread):
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio.value)
if (
self.config.audio_transcription.enabled
self.camera_config.audio_transcription.enabled
and self.audio_transcription_model_runner is not None
):
# init the transcription processor for this camera

View File

@ -1,6 +1,5 @@
"""Generative AI module for Frigate."""
import datetime
import importlib
import json
import logging
@ -9,13 +8,18 @@ import re
from typing import Any, Callable, Optional
import numpy as np
from playhouse.shortcuts import model_to_dict
from pydantic import ValidationError
from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum
from frigate.const import CLIPS_DIR
from frigate.data_processing.post.types import ReviewMetadata
from frigate.genai.manager import GenAIClientManager
from frigate.genai.prompts import (
build_object_description_prompt,
build_review_description_prompt,
build_review_description_response_format,
build_review_summary_prompt,
)
from frigate.models import Event
logger = logging.getLogger(__name__)
@ -61,75 +65,14 @@ class GenAIClient:
activity_context_prompt: str,
) -> ReviewMetadata | None:
"""Generate a description for the review item activity."""
context_prompt = build_review_description_prompt(
review_data,
thumbnails,
concerns,
preferred_language,
activity_context_prompt,
)
def get_concern_prompt() -> str:
if concerns:
concern_list = "\n - ".join(concerns)
return f"""- `other_concerns` (list of strings): Include a list of any of the following concerns that are occurring:
- {concern_list}"""
else:
return ""
def get_language_prompt() -> str:
if preferred_language:
return f"Provide your answer in {preferred_language}"
else:
return ""
def get_objects_list() -> str:
if review_data["unified_objects"]:
return "\n- " + "\n- ".join(review_data["unified_objects"])
else:
return "\n- (No objects detected)"
context_prompt = f"""
Your task is to analyze a sequence of images taken in chronological order from a security camera.
## Normal Activity Patterns for This Property
{activity_context_prompt}
## Task Instructions
Describe the scene based on observable actions and movements, evaluate the activity against the Activity Indicators above, and assign a potential_threat_level (0, 1, or 2) by applying the threat level indicators consistently.
## Analysis Guidelines
When forming your description:
- **CRITICAL: Only describe objects explicitly listed in "Objects in Scene" below.** Do not infer or mention additional people, vehicles, or objects not present in this list, even if visual patterns suggest them. If only a car is listed, do not describe a person interacting with it unless "person" is also in the objects list.
- **Only describe actions actually visible in the frames.** Do not assume or infer actions that you don't observe happening. If someone walks toward furniture but you never see them sit, do not say they sat. Stick to what you can see across the sequence.
- Describe what you observe: actions, movements, interactions with objects and the environment. Include any observable environmental changes (e.g., lighting changes triggered by activity).
- Note visible details such as clothing, items being carried or placed, tools or equipment present, and how they interact with the property or objects.
- Consider the full sequence chronologically: what happens from start to finish, how duration and actions relate to the location and objects involved.
- **Use the actual timestamp provided in "Activity started at"** below for time of day contextdo not infer time from image brightness or darkness. Unusual hours (late night/early morning) should increase suspicion when the observable behavior itself appears questionable. However, recognize that some legitimate activities can occur at any hour.
- **Consider duration as a primary factor**: Apply the duration thresholds defined in the activity patterns above. Brief sequences during normal hours with apparent purpose typically indicate normal activity unless explicit suspicious actions are visible.
- **Weigh all evidence holistically**: Match the activity against the normal and suspicious patterns defined above, then evaluate based on the complete context (zone, objects, time, actions, duration). Apply the threat level indicators consistently. Use your judgment for edge cases.
## Response Field Guidelines
Respond with a JSON object matching the provided schema. Field-specific guidance:
- `observations`: Include the very start of the activity for example, a vehicle entering the frame or pulling into the driveway even if it lasts only a few frames and the rest of the clip is dominated by a longer activity. Include each arrival, departure, object handled, and notable change in position or state. Each item is a single concrete fact written as a complete sentence.
- `scene`: Describe how the sequence begins, then the progression of events all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. For named subjects (those with a `` separator in "Objects in Scene"), always use their name do not replace them with generic terms. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign.
- `title`: Name the primary activity across the observations, together with the location. An activity is what is being done with objects, tools, or surfaces; locomotion through the scene qualifies as the activity only when no other interaction is observed. For named subjects, always use their name. For unnamed objects, refer to them naturally with articles.
- `shortSummary`: Briefly summarize the primary activity across the observations.
- `potential_threat_level`: Must be consistent with your scene description and the activity patterns above.
## Sequence Details
- Camera: {review_data["camera"]}
- Total frames: {len(thumbnails)} (Frame 1 = earliest, Frame {len(thumbnails)} = latest)
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
- Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}
## Objects in Scene
Each line represents a detection state, not necessarily unique individuals. The `` symbol separates a recognized subject's name from their object type — use only the name (before the `←`) in your response, not the type after it. The same subject may appear across multiple lines if detected multiple times.
**Note: Unidentified objects (without names) are NOT indicators of suspicious activitythey simply mean the system hasn't identified that object.**
{get_objects_list()}
{get_language_prompt()}
"""
logger.debug(
f"Sending {len(thumbnails)} images to create review description on {review_data['camera']}"
)
@ -143,25 +86,7 @@ Each line represents a detection state, not necessarily unique individuals. The
) as f:
f.write(context_prompt)
# Build JSON schema for structured output from ReviewMetadata model
schema = ReviewMetadata.model_json_schema()
schema.get("properties", {}).pop("time", None)
if "time" in schema.get("required", []):
schema["required"].remove("time")
if not concerns:
schema.get("properties", {}).pop("other_concerns", None)
if "other_concerns" in schema.get("required", []):
schema["required"].remove("other_concerns")
response_format = {
"type": "json_schema",
"json_schema": {
"name": "review_metadata",
"strict": True,
"schema": schema,
},
}
response_format = build_review_description_response_format(concerns)
response = self._send(context_prompt, thumbnails, response_format)
@ -240,61 +165,9 @@ Each line represents a detection state, not necessarily unique individuals. The
debug_save: bool,
) -> str | None:
"""Generate a summary of review item descriptions over a period of time."""
time_range = f"{datetime.datetime.fromtimestamp(start_ts).strftime('%B %d, %Y at %I:%M %p')} to {datetime.datetime.fromtimestamp(end_ts).strftime('%B %d, %Y at %I:%M %p')}"
timeline_summary_prompt = f"""
You are a security officer writing a concise security report.
Time range: {time_range}
Input format: Each event is a JSON object with:
- "title", "scene", "confidence", "potential_threat_level" (0-2), "other_concerns", "camera", "time", "start_time", "end_time"
- "context": array of related events from other cameras that occurred during overlapping time periods
**Note: Use the "scene" field for event descriptions in the report. Ignore any "shortSummary" field if present.**
Report Structure - Use this EXACT format:
# Security Summary - {time_range}
## Overview
[Write 1-2 sentences summarizing the overall activity pattern during this period.]
---
## Timeline
[Group events by time periods (e.g., "Morning (6:00 AM - 12:00 PM)", "Afternoon (12:00 PM - 5:00 PM)", "Evening (5:00 PM - 9:00 PM)", "Night (9:00 PM - 6:00 AM)"). Use appropriate time blocks based on when events occurred.]
### [Time Block Name]
**HH:MM AM/PM** | [Camera Name] | [Threat Level Indicator]
- [Event title]: [Clear description incorporating contextual information from the "context" array]
- Context: [If context array has items, mention them here, e.g., "Delivery truck present on Front Driveway Cam (HH:MM AM/PM)"]
- Assessment: [Brief assessment incorporating context - if context explains the event, note it here]
[Repeat for each event in chronological order within the time block]
---
## Summary
[One sentence summarizing the period. If all events are normal/explained: "Routine activity observed." If review needed: "Some activity requires review but no security concerns." If security concerns: "Security concerns requiring immediate attention."]
Guidelines:
- List ALL events in chronological order, grouped by time blocks
- Threat level indicators: Normal, Needs review, 🔴 Security concern
- Integrate contextual information naturally - use the "context" array to enrich each event's description
- If context explains the event (e.g., delivery truck explains person at door), describe it accordingly (e.g., "delivery person" not "unidentified person")
- Be concise but informative - focus on what happened and what it means
- If contextual information makes an event clearly normal, reflect that in your assessment
- Only create time blocks that have events - don't create empty sections
"""
timeline_summary_prompt += "\n\nEvents:\n"
for event in events:
timeline_summary_prompt += f"\n{event}\n"
if preferred_language:
timeline_summary_prompt += f"\nProvide your answer in {preferred_language}"
timeline_summary_prompt = build_review_summary_prompt(
start_ts, end_ts, events, preferred_language
)
if debug_save:
with open(
@ -326,10 +199,7 @@ Guidelines:
) -> Optional[str]:
"""Generate a description for the frame."""
try:
prompt = camera_config.objects.genai.object_prompts.get(
str(event.label),
camera_config.objects.genai.prompt,
).format(**model_to_dict(event))
prompt = build_object_description_prompt(camera_config, event)
except KeyError as e:
logger.error(f"Invalid key in GenAI prompt: {e}")
return None
@ -430,6 +300,10 @@ Guidelines:
Returns:
Dictionary with:
- 'content': Optional[str] - The text response from the LLM, None if tool calls
- 'reasoning': Optional[str] - The separated reasoning/thinking trace
if the model emitted one (e.g. via OpenAI-compatible
`reasoning_content`). None when the model does not surface a
trace or the provider does not parse it.
- 'tool_calls': Optional[List[Dict]] - List of tool calls if LLM wants to call tools.
Each tool call dict has:
- 'id': str - Unique identifier for this tool call
@ -441,6 +315,14 @@ Guidelines:
- 'length': Hit token limit
- 'error': An error occurred
Streaming counterpart `chat_with_tools_stream` yields
``(kind, value)`` tuples where ``kind`` is one of:
- 'content_delta': value is a string fragment of the answer
- 'reasoning_delta': value is a string fragment of the reasoning
trace (emitted before content for thinking models)
- 'stats': value is a usage stats dict
- 'message': value is the final dict shape described above
Raises:
NotImplementedError: If the provider doesn't implement this method.
"""
@ -451,14 +333,15 @@ Guidelines:
)
return {
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
def load_providers() -> None:
package_dir = os.path.dirname(__file__)
for filename in os.listdir(package_dir):
plugins_dir = os.path.join(os.path.dirname(__file__), "plugins")
for filename in os.listdir(plugins_dir):
if filename.endswith(".py") and filename != "__init__.py":
module_name = f"frigate.genai.{filename[:-3]}"
module_name = f"frigate.genai.plugins.{filename[:-3]}"
importlib.import_module(module_name)

View File

@ -1,315 +0,0 @@
"""Azure OpenAI Provider for Frigate AI."""
import base64
import json
import logging
from typing import Any, AsyncGenerator, Optional
from urllib.parse import parse_qs, urlparse
from openai import AzureOpenAI
from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider
from frigate.genai.openai import _stats_from_openai_usage
logger = logging.getLogger(__name__)
@register_genai_provider(GenAIProviderEnum.azure_openai)
class OpenAIClient(GenAIClient):
"""Generative AI client for Frigate using Azure OpenAI."""
provider: AzureOpenAI
def _init_provider(self) -> AzureOpenAI | None:
"""Initialize the client."""
try:
parsed_url = urlparse(self.genai_config.base_url or "")
query_params = parse_qs(parsed_url.query)
api_version = query_params.get("api-version", [None])[0]
azure_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}/"
if not api_version:
logger.warning("Azure OpenAI url is missing API version.")
return None
except Exception as e:
logger.warning("Error parsing Azure OpenAI url: %s", str(e))
return None
return AzureOpenAI(
api_key=self.genai_config.api_key,
api_version=api_version,
azure_endpoint=azure_endpoint,
)
def _send(
self,
prompt: str,
images: list[bytes],
response_format: Optional[dict] = None,
) -> Optional[str]:
"""Submit a request to Azure OpenAI."""
encoded_images = [base64.b64encode(image).decode("utf-8") for image in images]
try:
request_params = {
"model": self.genai_config.model,
"messages": [
{
"role": "user",
"content": [{"type": "text", "text": prompt}]
+ [
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{image}",
"detail": "low",
},
}
for image in encoded_images
],
},
],
"timeout": self.timeout,
**self.genai_config.runtime_options,
}
if response_format:
request_params["response_format"] = response_format
result = self.provider.chat.completions.create(**request_params)
except Exception as e:
logger.warning("Azure OpenAI returned an error: %s", str(e))
return None
if len(result.choices) > 0:
return str(result.choices[0].message.content.strip())
return None
def list_models(self) -> list[str]:
"""Return available model IDs from Azure OpenAI."""
try:
return sorted(m.id for m in self.provider.models.list().data)
except Exception as e:
logger.warning("Failed to list Azure OpenAI models: %s", e)
return []
def get_context_size(self) -> int:
"""Get the context window size for Azure OpenAI."""
return 128000
def chat_with_tools(
self,
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
) -> dict[str, Any]:
try:
openai_tool_choice = None
if tool_choice:
if tool_choice == "none":
openai_tool_choice = "none"
elif tool_choice == "auto":
openai_tool_choice = "auto"
elif tool_choice == "required":
openai_tool_choice = "required"
request_params = {
"model": self.genai_config.model,
"messages": messages,
"timeout": self.timeout,
}
if tools:
request_params["tools"] = tools
if openai_tool_choice is not None:
request_params["tool_choice"] = openai_tool_choice
result = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
if (
result is None
or not hasattr(result, "choices")
or len(result.choices) == 0
):
return {
"content": None,
"tool_calls": None,
"finish_reason": "error",
}
choice = result.choices[0]
message = choice.message
content = message.content.strip() if message.content else None
tool_calls = None
if message.tool_calls:
tool_calls = []
for tool_call in message.tool_calls:
try:
arguments = json.loads(tool_call.function.arguments)
except (json.JSONDecodeError, AttributeError) as e:
logger.warning(
f"Failed to parse tool call arguments: {e}, "
f"tool: {tool_call.function.name if hasattr(tool_call.function, 'name') else 'unknown'}"
)
arguments = {}
tool_calls.append(
{
"id": tool_call.id if hasattr(tool_call, "id") else "",
"name": tool_call.function.name
if hasattr(tool_call.function, "name")
else "",
"arguments": arguments,
}
)
finish_reason = "error"
if hasattr(choice, "finish_reason") and choice.finish_reason:
finish_reason = choice.finish_reason
elif tool_calls:
finish_reason = "tool_calls"
elif content:
finish_reason = "stop"
return {
"content": content,
"tool_calls": tool_calls,
"finish_reason": finish_reason,
}
except Exception as e:
logger.warning("Azure OpenAI returned an error: %s", str(e))
return {
"content": None,
"tool_calls": None,
"finish_reason": "error",
}
async def chat_with_tools_stream(
self,
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
) -> AsyncGenerator[tuple[str, Any], None]:
"""
Stream chat with tools; yields content deltas then final message.
Implements streaming function calling/tool usage for Azure OpenAI models.
"""
try:
openai_tool_choice = None
if tool_choice:
if tool_choice == "none":
openai_tool_choice = "none"
elif tool_choice == "auto":
openai_tool_choice = "auto"
elif tool_choice == "required":
openai_tool_choice = "required"
request_params = {
"model": self.genai_config.model,
"messages": messages,
"timeout": self.timeout,
"stream": True,
"stream_options": {"include_usage": True},
}
if tools:
request_params["tools"] = tools
if openai_tool_choice is not None:
request_params["tool_choice"] = openai_tool_choice
# Use streaming API
content_parts: list[str] = []
tool_calls_by_index: dict[int, dict[str, Any]] = {}
finish_reason = "stop"
usage_stats: Optional[dict[str, Any]] = None
stream = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
for chunk in stream:
chunk_usage = getattr(chunk, "usage", None)
if chunk_usage is not None:
usage_stats = _stats_from_openai_usage(chunk_usage)
if not chunk or not chunk.choices:
continue
choice = chunk.choices[0]
delta = choice.delta
# Check for finish reason
if choice.finish_reason:
finish_reason = choice.finish_reason
# Extract content deltas
if delta.content:
content_parts.append(delta.content)
yield ("content_delta", delta.content)
# Extract tool calls
if delta.tool_calls:
for tc in delta.tool_calls:
idx = tc.index
fn = tc.function
if idx not in tool_calls_by_index:
tool_calls_by_index[idx] = {
"id": tc.id or "",
"name": fn.name if fn and fn.name else "",
"arguments": "",
}
t = tool_calls_by_index[idx]
if tc.id:
t["id"] = tc.id
if fn and fn.name:
t["name"] = fn.name
if fn and fn.arguments:
t["arguments"] += fn.arguments
# Build final message
full_content = "".join(content_parts).strip() or None
# Convert tool calls to list format
tool_calls_list = None
if tool_calls_by_index:
tool_calls_list = []
for tc in tool_calls_by_index.values():
try:
# Parse accumulated arguments as JSON
parsed_args = json.loads(tc["arguments"])
except (json.JSONDecodeError, Exception):
parsed_args = tc["arguments"]
tool_calls_list.append(
{
"id": tc["id"],
"name": tc["name"],
"arguments": parsed_args,
}
)
finish_reason = "tool_calls"
if usage_stats is not None:
yield ("stats", usage_stats)
yield (
"message",
{
"content": full_content,
"tool_calls": tool_calls_list,
"finish_reason": finish_reason,
},
)
except Exception as e:
logger.warning("Azure OpenAI streaming returned an error: %s", str(e))
yield (
"message",
{
"content": None,
"tool_calls": None,
"finish_reason": "error",
},
)

View File

@ -0,0 +1 @@
"""GenAI provider plugins."""

View File

@ -0,0 +1,53 @@
"""Azure OpenAI Provider for Frigate AI.
Azure OpenAI exposes the same chat completions API as OpenAI once the
client is constructed, so this provider inherits all transport, streaming,
reasoning, and tool-calling logic from :class:`OpenAIClient` and only
overrides what is genuinely Azure-specific:
- Client construction: parses ``api-version`` out of the configured
``base_url`` query string and instantiates :class:`openai.AzureOpenAI`
with ``azure_endpoint`` instead of ``base_url``. Raises if the URL is
malformed; :class:`GenAIClientManager` catches the exception and
disables the provider.
- Context size: Azure does not expose a per-model ``max_model_len`` field
reliably, so we keep the historical 128K default rather than the
model-name heuristic used by OpenAI.
"""
import logging
from urllib.parse import parse_qs, urlparse
from openai import AzureOpenAI
from frigate.config import GenAIProviderEnum
from frigate.genai import register_genai_provider
from frigate.genai.plugins.openai import OpenAIClient
logger = logging.getLogger(__name__)
@register_genai_provider(GenAIProviderEnum.azure_openai)
class AzureOpenAIClient(OpenAIClient):
"""Generative AI client for Frigate using Azure OpenAI."""
def _init_provider(self) -> AzureOpenAI:
"""Initialize the AzureOpenAI client from the configured base_url."""
parsed_url = urlparse(self.genai_config.base_url or "")
query_params = parse_qs(parsed_url.query)
api_version = query_params.get("api-version", [None])[0]
if not api_version:
raise ValueError("Azure OpenAI base_url is missing api-version.")
azure_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}/"
return AzureOpenAI(
api_key=self.genai_config.api_key,
api_version=api_version,
azure_endpoint=azure_endpoint,
)
def get_context_size(self) -> int:
"""Azure does not reliably surface per-model context size; use 128K."""
return 128000

View File

@ -248,6 +248,13 @@ class GeminiClient(GenAIClient):
if tool_config:
config_params["tool_config"] = tool_config
# Ask thinking-capable models (Gemini 2.5+) to include their
# reasoning trace as separate `thought` parts so we can surface
# it on the reasoning channel. Older models ignore this field.
config_params["thinking_config"] = types.ThinkingConfig(
include_thoughts=True
)
# Merge runtime_options
if isinstance(self.genai_config.runtime_options, dict):
config_params.update(self.genai_config.runtime_options)
@ -262,19 +269,24 @@ class GeminiClient(GenAIClient):
if not response or not response.candidates:
return {
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
candidate = response.candidates[0]
content = None
reasoning_parts: list[str] = []
tool_calls = None
# Extract content and tool calls from response
# Extract content, reasoning, and tool calls from response
if candidate.content and candidate.content.parts:
for part in candidate.content.parts:
if part.text:
content = part.text.strip()
if getattr(part, "thought", False):
reasoning_parts.append(part.text)
else:
content = part.text.strip()
elif part.function_call:
# Handle function call
if tool_calls is None:
@ -297,6 +309,8 @@ class GeminiClient(GenAIClient):
}
)
reasoning = "".join(reasoning_parts).strip() or None
# Determine finish reason
finish_reason = "error"
if hasattr(candidate, "finish_reason") and candidate.finish_reason:
@ -322,6 +336,7 @@ class GeminiClient(GenAIClient):
return {
"content": content,
"reasoning": reasoning,
"tool_calls": tool_calls,
"finish_reason": finish_reason,
}
@ -330,6 +345,7 @@ class GeminiClient(GenAIClient):
logger.warning("Gemini API error during chat_with_tools: %s", str(e))
return {
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
@ -339,6 +355,7 @@ class GeminiClient(GenAIClient):
)
return {
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
@ -477,12 +494,19 @@ class GeminiClient(GenAIClient):
if tool_config:
config_params["tool_config"] = tool_config
# Ask thinking-capable models to include their reasoning trace
# as separate `thought` parts (Gemini 2.5+; ignored elsewhere).
config_params["thinking_config"] = types.ThinkingConfig(
include_thoughts=True
)
# Merge runtime_options
if isinstance(self.genai_config.runtime_options, dict):
config_params.update(self.genai_config.runtime_options)
# Use streaming API
content_parts: list[str] = []
reasoning_parts: list[str] = []
tool_calls_by_index: dict[int, dict[str, Any]] = {}
finish_reason = "stop"
usage_stats: Optional[dict[str, Any]] = None
@ -519,12 +543,16 @@ class GeminiClient(GenAIClient):
]:
finish_reason = "error"
# Extract content and tool calls from chunk
# Extract content, reasoning, and tool calls from chunk
if candidate.content and candidate.content.parts:
for part in candidate.content.parts:
if part.text:
content_parts.append(part.text)
yield ("content_delta", part.text)
if getattr(part, "thought", False):
reasoning_parts.append(part.text)
yield ("reasoning_delta", part.text)
else:
content_parts.append(part.text)
yield ("content_delta", part.text)
elif part.function_call:
# Handle function call
try:
@ -565,6 +593,7 @@ class GeminiClient(GenAIClient):
# Build final message
full_content = "".join(content_parts).strip() or None
full_reasoning = "".join(reasoning_parts).strip() or None
# Convert tool calls to list format
tool_calls_list = None
@ -593,6 +622,7 @@ class GeminiClient(GenAIClient):
"message",
{
"content": full_content,
"reasoning": full_reasoning,
"tool_calls": tool_calls_list,
"finish_reason": finish_reason,
},
@ -604,6 +634,7 @@ class GeminiClient(GenAIClient):
"message",
{
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
},
@ -616,6 +647,7 @@ class GeminiClient(GenAIClient):
"message",
{
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
},

View File

@ -4,7 +4,7 @@ import base64
import io
import json
import logging
from typing import Any, AsyncGenerator, Optional
from typing import Any, AsyncGenerator, Optional, cast
import httpx
import numpy as np
@ -75,6 +75,29 @@ def _parse_launch_arg(args: list[str], flag: str) -> str | None:
return args[idx + 1]
def _fetch_llama_props(base_url: str, model: str) -> dict[str, Any]:
"""Fetch /props from a llama.cpp server, with llama-swap fallback.
Raises the underlying RequestException if both endpoints fail; callers
decide how to surface the failure.
"""
try:
response = requests.get(
f"{base_url}/props",
params={"model": model},
timeout=10,
)
response.raise_for_status()
return cast(dict[str, Any], response.json())
except Exception:
response = requests.get(
f"{base_url}/upstream/{model}/props",
timeout=10,
)
response.raise_for_status()
return cast(dict[str, Any], response.json())
def _to_jpeg(img_bytes: bytes) -> bytes | None:
"""Convert image bytes to JPEG. llama.cpp/STB does not support WebP."""
try:
@ -239,21 +262,7 @@ class LlamaCppClient(GenAIClient):
info["supports_tools"] = True
try:
try:
response = requests.get(
f"{base_url}/props",
params={"model": configured_model},
timeout=10,
)
response.raise_for_status()
props = response.json()
except Exception:
response = requests.get(
f"{base_url}/upstream/{configured_model}/props",
timeout=10,
)
response.raise_for_status()
props = response.json()
props = _fetch_llama_props(base_url, configured_model)
if info["context_size"] is None:
default_settings = props.get("default_generation_settings", {})
@ -518,19 +527,28 @@ class LlamaCppClient(GenAIClient):
k: v for k, v in self.provider_options.items() if k != "context_size"
}
payload.update(provider_opts)
payload.update(self.genai_config.runtime_options)
return payload
def _message_from_choice(self, choice: dict[str, Any]) -> dict[str, Any]:
"""Parse OpenAI-style choice into {content, tool_calls, finish_reason}."""
"""Parse OpenAI-style choice into {content, reasoning, tool_calls, finish_reason}.
llama.cpp's `--reasoning-format` puts the trace in
`message.reasoning_content` (preferred) or `message.thinking`; both
keys are accepted so different builds work without configuration.
"""
message = choice.get("message", {})
content = message.get("content")
content = content.strip() if content else None
reasoning = message.get("reasoning_content") or message.get("thinking")
reasoning = reasoning.strip() if reasoning else None
tool_calls = parse_tool_calls_from_message(message)
finish_reason = choice.get("finish_reason") or (
"tool_calls" if tool_calls else "stop" if content else "error"
)
return {
"content": content,
"reasoning": reasoning,
"tool_calls": tool_calls,
"finish_reason": finish_reason,
}
@ -559,6 +577,31 @@ class LlamaCppClient(GenAIClient):
)
return result if result else None
def _refresh_media_marker(self) -> bool:
"""Re-fetch /props and update the cached media marker if it changed.
The server randomizes the marker per startup (unless LLAMA_MEDIA_MARKER
is set), so a stale marker indicates a restart. Returns True iff the
marker was updated to a new value used to gate a one-shot retry of
a failed embeddings request.
"""
if self.provider is None:
return False
try:
props = _fetch_llama_props(self.provider, self.genai_config.model)
except Exception as e:
logger.warning("Failed to refresh llama.cpp media marker: %s", e)
return False
marker = props.get("media_marker")
if not isinstance(marker, str) or not marker or marker == self._media_marker:
return False
logger.info("llama.cpp media marker changed (server restart); refreshed")
self._media_marker = marker
return True
def embed(
self,
texts: list[str] | None = None,
@ -583,30 +626,46 @@ class LlamaCppClient(GenAIClient):
EMBEDDING_DIM = 768
content = []
for text in texts:
content.append({"prompt_string": text})
encoded_images: list[str] = []
for img in images:
# llama.cpp uses STB which does not support WebP; convert to JPEG
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 the server's media marker placeholder.
# The marker is randomized per server startup (read from /props).
content.append(
{
"prompt_string": f"{self._media_marker}\n",
"multimodal_data": [encoded], # type: ignore[dict-item]
}
encoded_images.append(base64.b64encode(to_encode).decode("utf-8"))
def build_content() -> list[dict[str, Any]]:
# prompt_string must contain the server's media marker placeholder
# for each image. The marker is randomized per server startup.
content: list[dict[str, Any]] = []
for text in texts:
content.append({"prompt_string": text})
for encoded in encoded_images:
content.append(
{
"prompt_string": f"{self._media_marker}\n",
"multimodal_data": [encoded],
}
)
return content
def post_embeddings() -> requests.Response:
return requests.post(
f"{self.provider}/embeddings",
json={"model": self.genai_config.model, "content": build_content()},
timeout=self.timeout,
)
try:
response = requests.post(
f"{self.provider}/embeddings",
json={"model": self.genai_config.model, "content": content},
timeout=self.timeout,
)
response.raise_for_status()
try:
response = post_embeddings()
response.raise_for_status()
except requests.exceptions.RequestException:
# The server may have restarted with a new media marker.
# Refresh from /props; only retry if the marker actually changed.
if not encoded_images or not self._refresh_media_marker():
raise
response = post_embeddings()
response.raise_for_status()
result = response.json()
items = result.get("data", result) if isinstance(result, dict) else result
@ -752,6 +811,7 @@ class LlamaCppClient(GenAIClient):
try:
payload = self._build_payload(messages, tools, tool_choice, stream=True)
content_parts: list[str] = []
reasoning_parts: list[str] = []
tool_calls_by_index: dict[int, dict[str, Any]] = {}
finish_reason = "stop"
@ -781,6 +841,15 @@ class LlamaCppClient(GenAIClient):
delta = choices[0].get("delta", {})
if choices[0].get("finish_reason"):
finish_reason = choices[0]["finish_reason"]
# llama.cpp emits separated thinking under
# reasoning_content (preferred) or thinking before any
# content tokens arrive
reasoning_delta = delta.get("reasoning_content") or delta.get(
"thinking"
)
if reasoning_delta:
reasoning_parts.append(reasoning_delta)
yield ("reasoning_delta", reasoning_delta)
if delta.get("content"):
content_parts.append(delta["content"])
yield ("content_delta", delta["content"])
@ -806,6 +875,7 @@ class LlamaCppClient(GenAIClient):
)
full_content = "".join(content_parts).strip() or None
full_reasoning = "".join(reasoning_parts).strip() or None
tool_calls_list = self._streamed_tool_calls_to_list(tool_calls_by_index)
if tool_calls_list:
finish_reason = "tool_calls"
@ -813,6 +883,7 @@ class LlamaCppClient(GenAIClient):
"message",
{
"content": full_content,
"reasoning": full_reasoning,
"tool_calls": tool_calls_list,
"finish_reason": finish_reason,
},

View File

@ -309,6 +309,7 @@ class OllamaClient(GenAIClient):
"model": self.genai_config.model,
"messages": request_messages,
**self.provider_options,
**self.genai_config.runtime_options,
}
if stream:
request_params["stream"] = True
@ -336,6 +337,9 @@ class OllamaClient(GenAIClient):
response.get("done"),
)
content = message.get("content", "").strip() if message.get("content") else None
reasoning = (
message.get("thinking", "").strip() if message.get("thinking") else None
)
tool_calls = parse_tool_calls_from_message(message)
finish_reason = "error"
if response.get("done"):
@ -348,6 +352,7 @@ class OllamaClient(GenAIClient):
finish_reason = "stop"
return {
"content": content,
"reasoning": reasoning,
"tool_calls": tool_calls,
"finish_reason": finish_reason,
}
@ -431,6 +436,9 @@ class OllamaClient(GenAIClient):
)
response = await async_client.chat(**request_params)
result = self._message_from_response(response)
reasoning = result.get("reasoning")
if reasoning:
yield ("reasoning_delta", reasoning)
content = result.get("content")
if content:
yield ("content_delta", content)
@ -449,6 +457,7 @@ class OllamaClient(GenAIClient):
headers=self._auth_headers(),
)
content_parts: list[str] = []
reasoning_parts: list[str] = []
final_message: dict[str, Any] | None = None
final_chunk: Any = None
stream = await async_client.chat(**request_params)
@ -456,6 +465,10 @@ class OllamaClient(GenAIClient):
if not chunk or "message" not in chunk:
continue
msg = chunk.get("message", {})
reasoning_delta = msg.get("thinking") or ""
if reasoning_delta:
reasoning_parts.append(reasoning_delta)
yield ("reasoning_delta", reasoning_delta)
delta = msg.get("content") or ""
if delta:
content_parts.append(delta)
@ -463,8 +476,10 @@ class OllamaClient(GenAIClient):
if chunk.get("done"):
final_chunk = chunk
full_content = "".join(content_parts).strip() or None
full_reasoning = "".join(reasoning_parts).strip() or None
final_message = {
"content": full_content,
"reasoning": full_reasoning,
"tool_calls": None,
"finish_reason": "stop",
}
@ -481,6 +496,7 @@ class OllamaClient(GenAIClient):
"message",
{
"content": "".join(content_parts).strip() or None,
"reasoning": "".join(reasoning_parts).strip() or None,
"tool_calls": None,
"finish_reason": "stop",
},

View File

@ -38,7 +38,11 @@ class OpenAIClient(GenAIClient):
context_size: Optional[int] = None
def _init_provider(self) -> OpenAI:
"""Initialize the client."""
"""Initialize the client.
Subclasses (e.g. Azure) should raise on configuration errors; the
manager catches construction failures and disables the provider.
"""
# Extract context_size from provider_options as it's not a valid OpenAI client parameter
# It will be used in get_context_size() instead
provider_opts = {
@ -203,6 +207,7 @@ class OpenAIClient(GenAIClient):
"model": self.genai_config.model,
"messages": messages,
"timeout": self.timeout,
**self.genai_config.runtime_options,
}
if tools:
@ -219,7 +224,7 @@ class OpenAIClient(GenAIClient):
}
request_params.update(provider_opts)
result = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
result = self.provider.chat.completions.create(**request_params)
if (
result is None
@ -235,6 +240,10 @@ class OpenAIClient(GenAIClient):
choice = result.choices[0]
message = choice.message
content = message.content.strip() if message.content else None
raw_reasoning = getattr(message, "reasoning_content", None) or getattr(
message, "reasoning", None
)
reasoning = raw_reasoning.strip() if raw_reasoning else None
tool_calls = None
if message.tool_calls:
@ -269,6 +278,7 @@ class OpenAIClient(GenAIClient):
return {
"content": content,
"reasoning": reasoning,
"tool_calls": tool_calls,
"finish_reason": finish_reason,
}
@ -277,6 +287,7 @@ class OpenAIClient(GenAIClient):
logger.warning("OpenAI request timed out: %s", str(e))
return {
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
@ -284,6 +295,7 @@ class OpenAIClient(GenAIClient):
logger.warning("OpenAI returned an error: %s", str(e))
return {
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
@ -315,6 +327,7 @@ class OpenAIClient(GenAIClient):
"timeout": self.timeout,
"stream": True,
"stream_options": {"include_usage": True},
**self.genai_config.runtime_options,
}
if tools:
@ -333,11 +346,12 @@ class OpenAIClient(GenAIClient):
# Use streaming API
content_parts: list[str] = []
reasoning_parts: list[str] = []
tool_calls_by_index: dict[int, dict[str, Any]] = {}
finish_reason = "stop"
usage_stats: Optional[dict[str, Any]] = None
stream = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
stream = self.provider.chat.completions.create(**request_params)
for chunk in stream:
chunk_usage = getattr(chunk, "usage", None)
@ -354,6 +368,15 @@ class OpenAIClient(GenAIClient):
if choice.finish_reason:
finish_reason = choice.finish_reason
# Extract reasoning deltas (reasoning_content or reasoning,
# depending on the server)
reasoning_delta = getattr(delta, "reasoning_content", None) or getattr(
delta, "reasoning", None
)
if reasoning_delta:
reasoning_parts.append(reasoning_delta)
yield ("reasoning_delta", reasoning_delta)
# Extract content deltas
if delta.content:
content_parts.append(delta.content)
@ -382,6 +405,7 @@ class OpenAIClient(GenAIClient):
# Build final message
full_content = "".join(content_parts).strip() or None
full_reasoning = "".join(reasoning_parts).strip() or None
# Convert tool calls to list format
tool_calls_list = None
@ -410,6 +434,7 @@ class OpenAIClient(GenAIClient):
"message",
{
"content": full_content,
"reasoning": full_reasoning,
"tool_calls": tool_calls_list,
"finish_reason": finish_reason,
},
@ -421,6 +446,7 @@ class OpenAIClient(GenAIClient):
"message",
{
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
},
@ -431,6 +457,7 @@ class OpenAIClient(GenAIClient):
"message",
{
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
},

739
frigate/genai/prompts.py Normal file
View File

@ -0,0 +1,739 @@
"""Prompt and response-format builders for GenAI features.
Centralizes the per-feature prompt framing and structured-output schema
shaping so provider clients in :mod:`frigate.genai.plugins` only handle
transport.
"""
import datetime
from typing import Any, Dict, List, Optional
from playhouse.shortcuts import model_to_dict
from frigate.config import CameraConfig, FrigateConfig
from frigate.config.classification import ObjectClassificationType
from frigate.config.ui import UnitSystemEnum
from frigate.data_processing.post.types import ReviewMetadata
from frigate.models import Event
def build_review_description_prompt(
review_data: dict[str, Any],
thumbnails: list[bytes],
concerns: list[str],
preferred_language: str | None,
activity_context_prompt: str,
) -> str:
"""Build the prompt for review activity description generation."""
def get_concern_prompt() -> str:
if concerns:
concern_list = "\n - ".join(concerns)
return (
"\n- `other_concerns` (list of strings): Include a list of any of "
"the following concerns that are occurring:\n"
f" - {concern_list}"
)
else:
return ""
def get_language_prompt() -> str:
if preferred_language:
return f"Provide your answer in {preferred_language}"
else:
return ""
def get_objects_list() -> str:
if review_data["unified_objects"]:
return "\n- " + "\n- ".join(review_data["unified_objects"])
else:
return "\n- (No objects detected)"
return f"""
Your task is to analyze a sequence of images taken in chronological order from a security camera.
## Normal Activity Patterns for This Property
{activity_context_prompt}
## Task Instructions
Describe the scene based on observable actions and movements, evaluate the activity against the Activity Indicators above, and assign a potential_threat_level (0, 1, or 2) by applying the threat level indicators consistently.
## Analysis Guidelines
When forming your description:
- **CRITICAL: Only describe objects explicitly listed in "Objects in Scene" below.** Do not infer or mention additional people, vehicles, or objects not present in this list, even if visual patterns suggest them. If only a car is listed, do not describe a person interacting with it unless "person" is also in the objects list.
- **Only describe actions actually visible in the frames.** Do not assume or infer actions that you don't observe happening. If someone walks toward furniture but you never see them sit, do not say they sat. Stick to what you can see across the sequence.
- Describe what you observe: actions, movements, interactions with objects and the environment. Include any observable environmental changes (e.g., lighting changes triggered by activity).
- Note visible details such as clothing, items being carried or placed, tools or equipment present, and how they interact with the property or objects.
- Consider the full sequence chronologically: what happens from start to finish, how duration and actions relate to the location and objects involved.
- **Use the actual timestamp provided in "Activity started at"** below for time of day contextdo not infer time from image brightness or darkness. Unusual hours (late night/early morning) should increase suspicion when the observable behavior itself appears questionable. However, recognize that some legitimate activities can occur at any hour.
- **Consider duration as a primary factor**: Apply the duration thresholds defined in the activity patterns above. Brief sequences during normal hours with apparent purpose typically indicate normal activity unless explicit suspicious actions are visible.
- **Weigh all evidence holistically**: Match the activity against the normal and suspicious patterns defined above, then evaluate based on the complete context (zone, objects, time, actions, duration). Apply the threat level indicators consistently. Use your judgment for edge cases.
## Response Field Guidelines
Respond with a JSON object matching the provided schema. Field-specific guidance:
- `observations`: Include the very start of the activity for example, a vehicle entering the frame or pulling into the driveway even if it lasts only a few frames and the rest of the clip is dominated by a longer activity. Include each arrival, departure, object handled, and notable change in position or state. Each item is a single concrete fact written as a complete sentence.
- `scene`: Describe how the sequence begins, then the progression of events all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. For named subjects (those with a `` separator in "Objects in Scene"), always use their name do not replace them with generic terms. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign.
- `title`: Name the primary activity across the observations, together with the location. An activity is what is being done with objects, tools, or surfaces; locomotion through the scene qualifies as the activity only when no other interaction is observed. For named subjects, always use their name. For unnamed objects, refer to them naturally with articles.
- `shortSummary`: Briefly summarize the primary activity across the observations.
- `potential_threat_level`: Must be consistent with your scene description and the activity patterns above.
{get_concern_prompt()}
## Sequence Details
- Camera: {review_data["camera"]}
- Total frames: {len(thumbnails)} (Frame 1 = earliest, Frame {len(thumbnails)} = latest)
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
- Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}
## Objects in Scene
Each line represents a detection state, not necessarily unique individuals. The `` symbol separates a recognized subject's name from their object type — use only the name (before the `←`) in your response, not the type after it. The same subject may appear across multiple lines if detected multiple times.
**Note: Unidentified objects (without names) are NOT indicators of suspicious activitythey simply mean the system hasn't identified that object.**
{get_objects_list()}
{get_language_prompt()}
"""
def build_review_description_response_format(concerns: list[str]) -> dict[str, Any]:
"""Build the structured-output JSON schema for review descriptions.
Strips the `time` field (populated server-side) and drops
`other_concerns` when no concerns are configured.
"""
schema = ReviewMetadata.model_json_schema()
schema.get("properties", {}).pop("time", None)
if "time" in schema.get("required", []):
schema["required"].remove("time")
if not concerns:
schema.get("properties", {}).pop("other_concerns", None)
if "other_concerns" in schema.get("required", []):
schema["required"].remove("other_concerns")
return {
"type": "json_schema",
"json_schema": {
"name": "review_metadata",
"strict": True,
"schema": schema,
},
}
def build_review_summary_prompt(
start_ts: float,
end_ts: float,
events: list[dict[str, Any]],
preferred_language: str | None,
) -> str:
"""Build the prompt for a multi-event review summary."""
time_range = (
f"{datetime.datetime.fromtimestamp(start_ts).strftime('%B %d, %Y at %I:%M %p')}"
f" to "
f"{datetime.datetime.fromtimestamp(end_ts).strftime('%B %d, %Y at %I:%M %p')}"
)
prompt = f"""
You are a security officer writing a concise security report.
Time range: {time_range}
Input format: Each event is a JSON object with:
- "title", "scene", "confidence", "potential_threat_level" (0-2), "other_concerns", "camera", "time", "start_time", "end_time"
- "context": array of related events from other cameras that occurred during overlapping time periods
**Note: Use the "scene" field for event descriptions in the report. Ignore any "shortSummary" field if present.**
Report Structure - Use this EXACT format:
# Security Summary - {time_range}
## Overview
[Write 1-2 sentences summarizing the overall activity pattern during this period.]
---
## Timeline
[Group events by time periods (e.g., "Morning (6:00 AM - 12:00 PM)", "Afternoon (12:00 PM - 5:00 PM)", "Evening (5:00 PM - 9:00 PM)", "Night (9:00 PM - 6:00 AM)"). Use appropriate time blocks based on when events occurred.]
### [Time Block Name]
**HH:MM AM/PM** | [Camera Name] | [Threat Level Indicator]
- [Event title]: [Clear description incorporating contextual information from the "context" array]
- Context: [If context array has items, mention them here, e.g., "Delivery truck present on Front Driveway Cam (HH:MM AM/PM)"]
- Assessment: [Brief assessment incorporating context - if context explains the event, note it here]
[Repeat for each event in chronological order within the time block]
---
## Summary
[One sentence summarizing the period. If all events are normal/explained: "Routine activity observed." If review needed: "Some activity requires review but no security concerns." If security concerns: "Security concerns requiring immediate attention."]
Guidelines:
- List ALL events in chronological order, grouped by time blocks
- Threat level indicators: Normal, Needs review, 🔴 Security concern
- Integrate contextual information naturally - use the "context" array to enrich each event's description
- If context explains the event (e.g., delivery truck explains person at door), describe it accordingly (e.g., "delivery person" not "unidentified person")
- Be concise but informative - focus on what happened and what it means
- If contextual information makes an event clearly normal, reflect that in your assessment
- Only create time blocks that have events - don't create empty sections
"""
prompt += "\n\nEvents:\n"
for event in events:
prompt += f"\n{event}\n"
if preferred_language:
prompt += f"\nProvide your answer in {preferred_language}"
return prompt
def build_object_description_prompt(
camera_config: CameraConfig,
event: Event,
) -> str:
"""Build the prompt for a per-object description.
Pulls the per-label override from `objects.genai.object_prompts`, falling
back to the camera default, and interpolates event fields.
Raises:
KeyError: if the user-defined prompt template references an unknown
event field.
"""
template = camera_config.objects.genai.object_prompts.get(
str(event.label),
camera_config.objects.genai.prompt,
)
return template.format(**model_to_dict(event))
def get_attribute_classifications(config: FrigateConfig) -> List[Dict[str, Any]]:
"""Return enabled custom classification models of `attribute` type.
Each entry: {"name": <model name>, "objects": [<object label>, ...]}.
These models attach attribute metadata to events on the listed object
types, which can later be filtered via the search_objects `attribute`
field.
"""
result: List[Dict[str, Any]] = []
for model_key, model_config in config.classification.custom.items():
if not model_config.enabled or model_config.object_config is None:
continue
if (
model_config.object_config.classification_type
!= ObjectClassificationType.attribute
):
continue
result.append(
{
"name": model_config.name or model_key,
"objects": list(model_config.object_config.objects or []),
}
)
return result
def get_tool_definitions(
semantic_search_enabled: bool = False,
attribute_classifications: Optional[List[Dict[str, Any]]] = None,
) -> List[Dict[str, Any]]:
"""
Get OpenAI-compatible tool definitions for Frigate.
Returns a list of tool definitions that can be used with OpenAI-compatible
function calling APIs. When semantic search is enabled, the search_objects
tool exposes an additional `semantic_query` parameter for descriptive
queries (e.g. "person riding a lawn mower") and find_similar_objects is
included. When attribute classification models are configured, an
`attribute` parameter is exposed for filtering by their labels.
"""
search_objects_properties: Dict[str, Any] = {
"camera": {
"type": "string",
"description": "Camera name to filter by (optional).",
},
"label": {
"type": "string",
"description": (
"Generic object class to filter by — one of the tracked detector "
"labels such as 'person', 'package', 'car', 'dog', 'bird'. Use "
"this for broad queries like 'show me all cars today'. Combine "
"with semantic_query when the user also describes appearance or "
"behavior (e.g. label='person', semantic_query='riding a lawn "
"mower')."
),
},
"sub_label": {
"type": "string",
"description": (
"Filter by a DISCRETE NAMED entity recognized in the detection. "
"Use this for: a known person's name ('John'), a delivery "
"company ('Amazon', 'UPS'), a recognized animal species or "
"breed ('blue jay', 'cardinal', 'golden retriever'), or a "
"license plate string. When filtering by a specific name, set "
"only sub_label and leave label unset. Do NOT use sub_label "
"for descriptions of appearance, clothing, or actions — those "
"belong in semantic_query."
),
},
"after": {
"type": "string",
"description": "Start time in ISO 8601 format (e.g., '2024-01-01T00:00:00Z').",
},
"before": {
"type": "string",
"description": "End time in ISO 8601 format (e.g., '2024-01-01T23:59:59Z').",
},
"zones": {
"type": "array",
"items": {"type": "string"},
"description": "List of zone names to filter by.",
},
"limit": {
"type": "integer",
"description": "Maximum number of objects to return (default: 25).",
"default": 25,
},
}
if attribute_classifications:
model_outline = "; ".join(
f"{m['name']} (applies to {', '.join(m['objects']) or 'any object'})"
for m in attribute_classifications
)
search_objects_properties["attribute"] = {
"type": "string",
"description": (
"Filter by a classification attribute label produced by a "
"configured attribute classification model. Use this INSTEAD "
"of semantic_query when the user's request matches one of "
"these classifications. Configured models: "
f"{model_outline}. "
"Set the value to the attribute label that matches the user's "
"phrasing (case-sensitive)."
),
}
if semantic_search_enabled:
search_objects_properties["semantic_query"] = {
"type": "string",
"description": (
"Optional natural-language description of a PHYSICAL "
"CHARACTERISTIC, APPEARANCE, or ACTIVITY the user mentioned, "
"used to semantically narrow results. Only set this when the "
"user describes something beyond what label and sub_label can "
"express on their own.\n"
"USE for descriptive phrases like: 'riding a lawn mower', "
"'wearing a red jacket', 'carrying a package', 'walking a "
"dog', 'on a bicycle', 'holding an umbrella'.\n"
"DO NOT USE for:\n"
"- specific named people, pets, or delivery companies → use sub_label\n"
"- animal species or breed names like 'blue jay', 'cardinal', "
"'golden retriever' → use sub_label\n"
"- license plate strings → use sub_label\n"
"- generic object queries like 'all cars today' or 'every "
"person' → use label alone with no semantic_query\n"
"When set, combine with label/time/camera/zone filters as "
"usual (e.g. label='person', semantic_query='riding a lawn "
"mower', after='2024-05-01T00:00:00Z')."
),
}
search_objects_description = (
"Search the historical record of detected objects in Frigate. "
"Use this ONLY for questions about the PAST — e.g. 'did anyone come by today?', "
"'when was the last car?', 'show me detections from yesterday'. "
"Do NOT use this for monitoring or alerting requests about future events — "
"use start_camera_watch instead for those. "
"An 'object' in Frigate represents a tracked detection (e.g., a person, package, car).\n\n"
"Choose filters based on what the user is asking for:\n"
"- Generic class query ('show me all cars today'): set `label` only.\n"
"- Specific NAMED entity (known person, delivery company, animal "
"species/breed like 'blue jay' or 'golden retriever', license "
"plate): set `sub_label` only and leave `label` unset.\n"
)
if semantic_search_enabled:
search_objects_description += (
"- Physical CHARACTERISTIC, APPEARANCE, or ACTIVITY that is not a "
"discrete name ('person riding a lawn mower', 'someone in a red "
"jacket', 'person carrying a package'): set `semantic_query` with "
"the descriptive phrase, optionally alongside `label` for the "
"object class. Do NOT put descriptive phrases in sub_label."
)
return [
{
"type": "function",
"function": {
"name": "search_objects",
"description": search_objects_description,
"parameters": {
"type": "object",
"properties": search_objects_properties,
},
"required": [],
},
},
{
"type": "function",
"function": {
"name": "find_similar_objects",
"description": (
"Find tracked objects that are visually and semantically similar "
"to a specific past event. Use this when the user references a "
"particular object they have seen and wants to find other "
"sightings of the same or similar one ('that green car', 'the "
"person in the red jacket', 'the package that was delivered'). "
"Prefer this over search_objects whenever the user's intent is "
"'find more like this specific one.' Use search_objects first "
"only if you need to locate the anchor event. Requires semantic "
"search to be enabled."
),
"parameters": {
"type": "object",
"properties": {
"event_id": {
"type": "string",
"description": "The id of the anchor event to find similar objects to.",
},
"after": {
"type": "string",
"description": "Start time in ISO 8601 format (e.g., '2024-01-01T00:00:00Z').",
},
"before": {
"type": "string",
"description": "End time in ISO 8601 format (e.g., '2024-01-01T23:59:59Z').",
},
"cameras": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of cameras to restrict to. Defaults to all.",
},
"labels": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of labels to restrict to. Defaults to the anchor event's label.",
},
"sub_labels": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of sub_labels (names) to restrict to.",
},
"zones": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of zones. An event matches if any of its zones overlap.",
},
"similarity_mode": {
"type": "string",
"enum": ["visual", "semantic", "fused"],
"description": "Which similarity signal(s) to use. 'fused' (default) combines visual and semantic.",
"default": "fused",
},
"min_score": {
"type": "number",
"description": "Drop matches with a similarity score below this threshold (0.0-1.0).",
},
"limit": {
"type": "integer",
"description": "Maximum number of matches to return (default: 10).",
"default": 10,
},
},
"required": ["event_id"],
},
},
},
{
"type": "function",
"function": {
"name": "set_camera_state",
"description": (
"Change a camera's feature state (e.g., turn detection on/off, enable/disable recordings). "
"Use camera='*' to apply to all cameras at once. "
"Only call this tool when the user explicitly asks to change a camera setting. "
"Requires admin privileges."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": "Camera name to target, or '*' to target all cameras.",
},
"feature": {
"type": "string",
"enum": [
"detect",
"record",
"snapshots",
"audio",
"motion",
"enabled",
"birdseye",
"birdseye_mode",
"improve_contrast",
"ptz_autotracker",
"motion_contour_area",
"motion_threshold",
"notifications",
"audio_transcription",
"review_alerts",
"review_detections",
"object_descriptions",
"review_descriptions",
"profile",
],
"description": (
"The feature to change. Most features accept ON or OFF. "
"birdseye_mode accepts CONTINUOUS, MOTION, or OBJECTS. "
"motion_contour_area and motion_threshold accept a number. "
"profile accepts a profile name or 'none' to deactivate (requires camera='*')."
),
},
"value": {
"type": "string",
"description": "The value to set. ON or OFF for toggles, a number for thresholds, a profile name or 'none' for profile.",
},
},
"required": ["camera", "feature", "value"],
},
},
},
{
"type": "function",
"function": {
"name": "get_live_context",
"description": (
"Get the current live image and detection information for a camera: objects being tracked, "
"zones, timestamps. Use this to understand what is visible in the live view. "
"Call this when answering questions about what is happening right now on a specific camera."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": "Camera name to get live context for.",
},
},
"required": ["camera"],
},
},
},
{
"type": "function",
"function": {
"name": "start_camera_watch",
"description": (
"Start a continuous VLM watch job that monitors a camera and sends a notification "
"when a specified condition is met. Use this when the user wants to be alerted about "
"a future event, e.g. 'tell me when guests arrive' or 'notify me when the package is picked up'. "
"Only one watch job can run at a time. Returns a job ID."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": "Camera ID to monitor.",
},
"condition": {
"type": "string",
"description": (
"Natural-language description of the condition to watch for, "
"e.g. 'a person arrives at the front door'."
),
},
"max_duration_minutes": {
"type": "integer",
"description": "Maximum time to watch before giving up (minutes, default 60).",
"default": 60,
},
"labels": {
"type": "array",
"items": {"type": "string"},
"description": "Object labels that should trigger a VLM check (e.g. ['person', 'car']). If omitted, any detection on the camera triggers a check.",
},
"zones": {
"type": "array",
"items": {"type": "string"},
"description": "Zone names to filter by. If specified, only detections in these zones trigger a VLM check.",
},
},
"required": ["camera", "condition"],
},
},
},
{
"type": "function",
"function": {
"name": "stop_camera_watch",
"description": (
"Cancel the currently running VLM watch job. Use this when the user wants to "
"stop a previously started watch, e.g. 'stop watching the front door'."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_profile_status",
"description": (
"Get the current profile status including the active profile and "
"timestamps of when each profile was last activated. Use this to "
"determine time periods for recap requests — e.g. when the user asks "
"'what happened while I was away?', call this first to find the relevant "
"time window based on profile activation history."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_recap",
"description": (
"Get a recap of all activity (alerts and detections) for a given time period. "
"Use this after calling get_profile_status to retrieve what happened during "
"a specific window — e.g. 'what happened while I was away?'. Returns a "
"chronological list of activity with camera, objects, zones, and GenAI-generated "
"descriptions when available. Summarize the results for the user."
),
"parameters": {
"type": "object",
"properties": {
"after": {
"type": "string",
"description": "Start of the time period in ISO 8601 format (e.g. '2025-03-15T08:00:00').",
},
"before": {
"type": "string",
"description": "End of the time period in ISO 8601 format (e.g. '2025-03-15T17:00:00').",
},
"cameras": {
"type": "string",
"description": "Comma-separated camera IDs to include, or 'all' for all cameras. Default is 'all'.",
},
"severity": {
"type": "string",
"enum": ["alert", "detection"],
"description": "Filter by severity level. Omit to include both alerts and detections.",
},
},
"required": ["after", "before"],
},
},
},
]
def build_chat_system_prompt(
config: FrigateConfig,
allowed_cameras: List[str],
semantic_search_enabled: bool,
attribute_classifications: List[Dict[str, Any]],
) -> str:
"""Build the system prompt for the chat completion endpoint.
Composes the static framing with conditional sections describing the
available cameras, speed units, semantic-search routing guidance, and
configured attribute classifications.
"""
current_datetime = datetime.datetime.now()
current_date_str = current_datetime.strftime("%Y-%m-%d")
current_time_str = current_datetime.strftime("%I:%M:%S %p")
cameras_info: List[str] = []
has_speed_zone = False
for camera_id in allowed_cameras:
if camera_id not in config.cameras:
continue
camera_config = config.cameras[camera_id]
friendly_name = (
camera_config.friendly_name
if camera_config.friendly_name
else camera_id.replace("_", " ").title()
)
zone_names = list(camera_config.zones.keys())
if not has_speed_zone:
has_speed_zone = any(
zone.distances for zone in camera_config.zones.values()
)
if zone_names:
cameras_info.append(
f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_names)})"
)
else:
cameras_info.append(f" - {friendly_name} (ID: {camera_id})")
cameras_section = ""
if cameras_info:
cameras_section = (
"\n\nAvailable cameras:\n"
+ "\n".join(cameras_info)
+ "\n\nWhen users refer to cameras by their friendly name (e.g., 'Back Deck Camera'), use the corresponding camera ID (e.g., 'back_deck_cam') in tool calls."
)
speed_units_section = ""
if has_speed_zone:
speed_unit = (
"mph" if config.ui.unit_system == UnitSystemEnum.imperial else "km/h"
)
speed_units_section = f"\n\nReport object speeds to the user in {speed_unit}."
semantic_search_section = ""
if semantic_search_enabled:
semantic_search_section = (
"\n\nWhen routing a search_objects call, pick filters by the shape of the user's request:\n"
"- Generic class ('show me all cars today'): set `label` only.\n"
"- Specific named entity — a known person ('John'), delivery company ('Amazon'), animal species/breed ('blue jay', 'cardinal', 'golden retriever'), or license plate: set `sub_label` only and leave `label` unset.\n"
"- Physical characteristic, appearance, or activity that is NOT a discrete name ('find me people riding a lawn mower', 'someone in a red jacket', 'a person carrying a package'): set `semantic_query` with the descriptive phrase, optionally combined with `label` for the object class. Never put descriptive phrases in `sub_label`."
)
attribute_classification_section = ""
if attribute_classifications:
model_lines = "\n".join(
f"- {m['name']}: applies to {', '.join(m['objects']) or 'any object'}"
for m in attribute_classifications
)
attribute_classification_section = (
"\n\nAttribute classification models are configured for the following object types:\n"
f"{model_lines}\n"
"When the user's request matches one of these classifications, set the search_objects `attribute` field to the matching label rather than using `semantic_query`. Reserve `semantic_query` for descriptive phrases that fall outside the configured attribute labels."
)
return f"""You are a helpful assistant for Frigate, a security camera NVR system. You help users answer questions about their cameras, detected objects, and events.
Current server local date and time: {current_date_str} at {current_time_str}
Do not start your response with phrases like "I will check...", "Let me see...", or "Let me look...". Answer directly.
Always present times to the user in the server's local timezone. When tool results include start_time_local and end_time_local, use those exact strings when listing or describing detection times—do not convert or invent timestamps. Do not use UTC or ISO format with Z for the user-facing answer unless the tool result only provides Unix timestamps without local time fields.
When users ask about "today", "yesterday", "this week", etc., use the current date above as reference.
When searching for objects or events, use ISO 8601 format for dates (e.g., {current_date_str}T00:00:00Z for the start of today).
Always be accurate with time calculations based on the current date provided.
When a user refers to a specific object they have seen or describe with identifying details ("that green car", "the person in the red jacket", "a package left today"), prefer the find_similar_objects tool over search_objects. Use search_objects first only to locate the anchor event, then pass its id to find_similar_objects. For generic queries like "show me all cars today", keep using search_objects. If a user message begins with [attached_event:<id>], treat that event id as the anchor for any similarity or "tell me more" request in the same message and call find_similar_objects with that id.{semantic_search_section}{attribute_classification_section}{cameras_section}{speed_units_section}"""

View File

@ -12,6 +12,7 @@ import os
import subprocess as sp
import threading
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Optional, cast
@ -23,7 +24,7 @@ from frigate.const import REPLAY_CAMERA_PREFIX, REPLAY_DIR
from frigate.jobs.export import JobStatePublisher
from frigate.jobs.job import Job
from frigate.jobs.manager import job_is_running, set_current_job
from frigate.models import Recordings
from frigate.models import Export, Recordings
from frigate.types import JobStatusTypesEnum
from frigate.util.ffmpeg import run_ffmpeg_with_progress
@ -114,6 +115,125 @@ def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> Mode
return cast(ModelSelect, query)
class DebugReplaySource(ABC):
"""Abstract source for a debug replay session.
Provides the camera identity and time range the replay represents,
validates that usable content exists, and supplies the ffmpeg input
args used to build the replay clip.
"""
@property
@abstractmethod
def source_camera(self) -> str:
"""Camera name the replay is derived from."""
@property
@abstractmethod
def start_ts(self) -> float:
"""Unix timestamp marking the start of the replay range."""
@property
@abstractmethod
def end_ts(self) -> float:
"""Unix timestamp marking the end of the replay range."""
@abstractmethod
def validate(self) -> None:
"""Raise ValueError if the source has no usable content."""
@abstractmethod
def ffmpeg_input_args(self, working_dir: str) -> list[str]:
"""Return ffmpeg input args (including -i). May write temp files in working_dir."""
def cleanup(self, working_dir: str) -> None:
"""Remove any temp files the source created in working_dir. Default no-op."""
class RecordingDebugReplaySource(DebugReplaySource):
"""Replay source backed by the Recordings table.
Builds a concat playlist of recording files covering the time range
and feeds it to ffmpeg's concat demuxer.
"""
def __init__(self, source_camera: str, start_ts: float, end_ts: float) -> None:
self._camera = source_camera
self._start_ts = start_ts
self._end_ts = end_ts
self._concat_file: Optional[str] = None
@property
def source_camera(self) -> str:
return self._camera
@property
def start_ts(self) -> float:
return self._start_ts
@property
def end_ts(self) -> float:
return self._end_ts
def validate(self) -> None:
if self._end_ts <= self._start_ts:
raise ValueError("End time must be after start time")
if not query_recordings(self._camera, self._start_ts, self._end_ts).count():
raise ValueError(
f"No recordings found for camera '{self._camera}' in the specified time range"
)
def ffmpeg_input_args(self, working_dir: str) -> list[str]:
replay_name = f"{REPLAY_CAMERA_PREFIX}{self._camera}"
concat_file = os.path.join(working_dir, f"{replay_name}_concat.txt")
recordings = query_recordings(self._camera, self._start_ts, self._end_ts)
with open(concat_file, "w") as f:
for recording in recordings:
f.write(f"file '{recording.path}'\n")
self._concat_file = concat_file
return ["-f", "concat", "-safe", "0", "-i", concat_file]
def cleanup(self, working_dir: str) -> None:
if self._concat_file:
_remove_silent(self._concat_file)
class ExportDebugReplaySource(DebugReplaySource):
"""Replay source backed by an existing Export.
Uses the export's video file directly as the ffmpeg input — does not
require recordings to still exist for the time range.
"""
def __init__(self, export: Export, duration: float) -> None:
self._camera = cast(str, export.camera)
# Export.date is declared DateTimeField but Frigate writes raw unix
# timestamps to the column.
self._start_ts = float(cast(Any, export.date))
self._video_path = cast(str, export.video_path)
self._duration = duration
@property
def source_camera(self) -> str:
return self._camera
@property
def start_ts(self) -> float:
return self._start_ts
@property
def end_ts(self) -> float:
return self._start_ts + self._duration
def validate(self) -> None:
if not os.path.exists(self._video_path):
raise ValueError(f"Export video file not found: {self._video_path}")
def ffmpeg_input_args(self, working_dir: str) -> list[str]:
return ["-i", self._video_path]
class DebugReplayJobRunner(threading.Thread):
"""Worker thread that drives the startup job to completion.
@ -126,6 +246,7 @@ class DebugReplayJobRunner(threading.Thread):
def __init__(
self,
job: DebugReplayJob,
source: DebugReplaySource,
frigate_config: FrigateConfig,
config_publisher: CameraConfigUpdatePublisher,
replay_manager: "DebugReplayManager",
@ -133,6 +254,7 @@ class DebugReplayJobRunner(threading.Thread):
) -> None:
super().__init__(daemon=True, name=f"debug_replay_{job.id}")
self.job = job
self.source = source
self.frigate_config = frigate_config
self.config_publisher = config_publisher
self.replay_manager = replay_manager
@ -183,7 +305,6 @@ class DebugReplayJobRunner(threading.Thread):
def run(self) -> None:
replay_name = self.job.replay_camera_name
os.makedirs(REPLAY_DIR, exist_ok=True)
concat_file = os.path.join(REPLAY_DIR, f"{replay_name}_concat.txt")
clip_path = os.path.join(REPLAY_DIR, f"{replay_name}.mp4")
self.job.status = JobStatusTypesEnum.running
@ -192,23 +313,13 @@ class DebugReplayJobRunner(threading.Thread):
self._broadcast(force=True)
try:
recordings = query_recordings(
self.job.source_camera, self.job.start_ts, self.job.end_ts
)
with open(concat_file, "w") as f:
for recording in recordings:
f.write(f"file '{recording.path}'\n")
input_args = self.source.ffmpeg_input_args(REPLAY_DIR)
ffmpeg_cmd = [
self.frigate_config.ffmpeg.ffmpeg_path,
"-hide_banner",
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
concat_file,
*input_args,
"-c",
"copy",
"-movflags",
@ -285,7 +396,7 @@ class DebugReplayJobRunner(threading.Thread):
self.replay_manager.clear_session()
_remove_silent(clip_path)
finally:
_remove_silent(concat_file)
self.source.cleanup(REPLAY_DIR)
_set_active_runner(None)
def _finalize_cancelled(self, clip_path: str) -> None:
@ -309,52 +420,43 @@ def _remove_silent(path: str) -> None:
def start_debug_replay_job(
*,
source_camera: str,
start_ts: float,
end_ts: float,
source: DebugReplaySource,
frigate_config: FrigateConfig,
config_publisher: CameraConfigUpdatePublisher,
replay_manager: "DebugReplayManager",
) -> str:
"""Validate, create job, start runner. Returns the job id.
Raises ValueError for bad params (camera missing, time range
invalid, no recordings) and RuntimeError if a session is already
active.
Raises ValueError for an invalid source (camera missing, source has
no usable content) and RuntimeError if a session is already active.
"""
if job_is_running(JOB_TYPE) or replay_manager.active:
raise RuntimeError("A replay session is already active")
if source_camera not in frigate_config.cameras:
raise ValueError(f"Camera '{source_camera}' not found")
if source.source_camera not in frigate_config.cameras:
raise ValueError(f"Camera '{source.source_camera}' not found")
if end_ts <= start_ts:
raise ValueError("End time must be after start time")
source.validate()
recordings = query_recordings(source_camera, start_ts, end_ts)
if not recordings.count():
raise ValueError(
f"No recordings found for camera '{source_camera}' in the specified time range"
)
replay_name = f"{REPLAY_CAMERA_PREFIX}{source_camera}"
replay_name = f"{REPLAY_CAMERA_PREFIX}{source.source_camera}"
replay_manager.mark_starting(
source_camera=source_camera,
source_camera=source.source_camera,
replay_camera_name=replay_name,
start_ts=start_ts,
end_ts=end_ts,
start_ts=source.start_ts,
end_ts=source.end_ts,
)
job = DebugReplayJob(
source_camera=source_camera,
source_camera=source.source_camera,
replay_camera_name=replay_name,
start_ts=start_ts,
end_ts=end_ts,
start_ts=source.start_ts,
end_ts=source.end_ts,
)
set_current_job(job)
runner = DebugReplayJobRunner(
job=job,
source=source,
frigate_config=frigate_config,
config_publisher=config_publisher,
replay_manager=replay_manager,

View File

@ -15,11 +15,12 @@ class TestDebugReplayAPI(BaseTestHttp):
# Stub the factory to skip validation/threading and just record the
# name on the manager the way the real factory's mark_starting would.
def fake_start(**kwargs):
source = kwargs["source"]
kwargs["replay_manager"].mark_starting(
source_camera=kwargs["source_camera"],
source_camera=source.source_camera,
replay_camera_name="_replay_front",
start_ts=kwargs["start_ts"],
end_ts=kwargs["end_ts"],
start_ts=source.start_ts,
end_ts=source.end_ts,
)
return "job-1234"

View File

@ -1673,5 +1673,60 @@ class TestConfig(unittest.TestCase):
self.assertRaises(ValueError, lambda: FrigateConfig(**config))
class TestAttributeFilterDefaults(unittest.TestCase):
"""Verify attribute filter min_score handling at config load."""
def setUp(self):
self.minimal = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
def _build_config(self, object_filters: dict | None = None) -> FrigateConfig:
config = deep_merge({}, self.minimal)
if object_filters is not None:
config.setdefault("objects", {})["filters"] = object_filters
return FrigateConfig(**config)
def test_attribute_with_no_filter_gets_default_min_score(self):
"""Attribute with no user-provided filter gets created with min_score=0.7."""
config = self._build_config()
face_filter = config.objects.filters.get("face")
self.assertIsNotNone(face_filter)
self.assertEqual(face_filter.min_score, 0.7)
def test_attribute_filter_without_min_score_gets_bumped(self):
"""If user sets some FilterConfig field but not min_score, min_score is bumped to 0.7."""
config = self._build_config({"face": {"min_area": 500}})
face_filter = config.objects.filters["face"]
self.assertEqual(face_filter.min_area, 500)
self.assertEqual(face_filter.min_score, 0.7)
def test_attribute_filter_explicit_min_score_half_is_preserved(self):
"""User-provided min_score=0.5 must NOT be silently rewritten to 0.7."""
config = self._build_config({"face": {"min_score": 0.5}})
face_filter = config.objects.filters["face"]
self.assertEqual(face_filter.min_score, 0.5)
def test_attribute_filter_explicit_min_score_other_value_is_preserved(self):
"""Sanity: explicit non-0.5 values pass through unchanged."""
config = self._build_config({"face": {"min_score": 0.3}})
face_filter = config.objects.filters["face"]
self.assertEqual(face_filter.min_score, 0.3)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -71,6 +71,14 @@ class TestDebugReplayManagerSession(unittest.TestCase):
class TestDebugReplayManagerStop(unittest.TestCase):
def setUp(self) -> None:
# stop() publishes a terminal job_state via a real JobStatePublisher,
# which opens a ZMQ REQ socket and blocks on REP. No dispatcher runs
# in unit tests, so substitute a no-op publisher.
patcher = patch("frigate.debug_replay.JobStatePublisher")
patcher.start()
self.addCleanup(patcher.stop)
def test_stop_when_inactive_is_a_noop(self) -> None:
from frigate.debug_replay import DebugReplayManager

View File

@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch
from frigate.debug_replay import DebugReplayManager
from frigate.jobs.debug_replay import (
DebugReplayJob,
RecordingDebugReplaySource,
cancel_debug_replay_job,
get_active_runner,
start_debug_replay_job,
@ -99,9 +100,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
def test_rejects_unknown_camera(self) -> None:
with self.assertRaises(ValueError):
start_debug_replay_job(
source_camera="missing",
start_ts=100.0,
end_ts=200.0,
source=RecordingDebugReplaySource(
source_camera="missing", start_ts=100.0, end_ts=200.0
),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@ -110,9 +111,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
def test_rejects_invalid_time_range(self) -> None:
with self.assertRaises(ValueError):
start_debug_replay_job(
source_camera="front",
start_ts=200.0,
end_ts=100.0,
source=RecordingDebugReplaySource(
source_camera="front", start_ts=200.0, end_ts=100.0
),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@ -124,9 +125,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
with patch("frigate.jobs.debug_replay.query_recordings", return_value=empty_qs):
with self.assertRaises(ValueError):
start_debug_replay_job(
source_camera="front",
start_ts=100.0,
end_ts=200.0,
source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0
),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@ -154,9 +155,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
patch("builtins.open", unittest.mock.mock_open()),
):
job_id = start_debug_replay_job(
source_camera="front",
start_ts=100.0,
end_ts=200.0,
source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0
),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@ -191,9 +192,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
patch("builtins.open", unittest.mock.mock_open()),
):
start_debug_replay_job(
source_camera="front",
start_ts=100.0,
end_ts=200.0,
source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0
),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@ -201,9 +202,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
with self.assertRaises(RuntimeError):
start_debug_replay_job(
source_camera="front",
start_ts=100.0,
end_ts=200.0,
source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0
),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@ -269,9 +270,9 @@ class TestRunnerHappyPath(unittest.TestCase):
patch("builtins.open", unittest.mock.mock_open()),
):
start_debug_replay_job(
source_camera="front",
start_ts=100.0,
end_ts=200.0,
source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0
),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@ -340,9 +341,9 @@ class TestRunnerFailurePath(unittest.TestCase):
patch("builtins.open", unittest.mock.mock_open()),
):
start_debug_replay_job(
source_camera="front",
start_ts=100.0,
end_ts=200.0,
source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0
),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@ -418,9 +419,9 @@ class TestRunnerCancellation(unittest.TestCase):
patch("builtins.open", unittest.mock.mock_open()),
):
start_debug_replay_job(
source_camera="front",
start_ts=100.0,
end_ts=200.0,
source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0
),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,

View File

@ -230,7 +230,7 @@ class TestExportResolution(unittest.TestCase):
id=export_id,
camera=camera,
name=f"export-{export_id}",
date=datetime.datetime.now(),
date=int(datetime.datetime.now().timestamp()),
video_path=f"/media/frigate/exports/{filename}",
thumb_path=f"/media/frigate/exports/{filename}.jpg",
in_progress=False,

View File

@ -357,6 +357,9 @@ class TrackedObjectProcessor(threading.Thread):
def get_current_frame_time(self, camera: str) -> float:
"""Returns the latest frame time for a given camera."""
if camera not in self.camera_states:
return 0.0
return self.camera_states[camera].current_frame_time
def set_sub_label(

View File

@ -364,6 +364,64 @@ def main():
continue
section_data.pop(key, None)
if field_name == "objects":
# Produce a parallel `filters_attribute` block alongside `filters`,
# with object-wording rewritten for attribute filters (face,
# license_plate, courier logos). The frontend's
# buildTranslationPath routes `filters.<attr>.<field>` lookups to
# `filters_attribute.<field>` when `<attr>` is in
# `model.all_attributes`. Keep this rewrite list explicit rather
# than running a blanket s/object/attribute/ so unrelated
# descriptions (e.g. "JSON object") never accidentally flip.
filters_block = section_data.get("filters")
if isinstance(filters_block, dict):
attribute_rewrites = [
("Object filters", "Attribute filters"),
("detected objects", "detected attributes"),
("object area", "attribute area"),
("object type", "attribute"),
("the object", "the attribute"),
]
# Per-field overrides for cases where the generic rewrite
# doesn't capture the attribute-specific semantics. Keys
# match the FilterConfig field name; values are partial
# overrides applied AFTER the generic rewrites.
attribute_field_overrides: Dict[str, Dict[str, str]] = {
"min_score": {
"description": (
"Minimum single-frame detection confidence required "
"to associate this attribute with its parent object."
),
},
}
def rewrite(text: str) -> str:
for source, replacement in attribute_rewrites:
text = text.replace(source, replacement)
return text
attribute_variant: Dict[str, Any] = {}
for key, value in filters_block.items():
if key in ("label", "description"):
if isinstance(value, str):
attribute_variant[key] = rewrite(value)
continue
if not isinstance(value, dict):
continue
field_trans: Dict[str, str] = {}
if isinstance(value.get("label"), str):
field_trans["label"] = rewrite(value["label"])
if isinstance(value.get("description"), str):
field_trans["description"] = rewrite(value["description"])
overrides = attribute_field_overrides.get(key)
if overrides:
field_trans.update(overrides)
if field_trans:
attribute_variant[key] = field_trans
if attribute_variant:
section_data["filters_attribute"] = attribute_variant
if not section_data:
logger.warning(f"No translations found for section: {field_name}")
continue

View File

@ -0,0 +1,235 @@
/**
* go2rtc streams settings page tests -- MEDIUM tier.
*
* Regression coverage for the compat-mode (ffmpeg:) URL editor: unknown
* fragments like #timeout=10 must remain visible and editable when the
* stream is using compatibility mode.
*/
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
const STREAM_NAME = "dome_sub";
const FFMPEG_URL_WITH_TIMEOUT =
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#timeout=10";
async function installRawPathsRoute(page: Page, streamUrl: string) {
let lastSavedConfig: unknown = null;
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({
json: {
cameras: {},
go2rtc: { streams: { [STREAM_NAME]: [streamUrl] } },
},
}),
);
await page.route("**/api/config/set", async (route) => {
lastSavedConfig = route.request().postDataJSON();
await route.fulfill({ json: { success: true, require_restart: false } });
});
return {
capturedConfig: () => lastSavedConfig,
};
}
async function expandStream(page: Page, streamName: string) {
// Each StreamCard renders the stream name as an h4 next to a rename
// button, with the chevron toggle as the last button in the header row.
// Scope to the header row (h4's grandparent) and click that last button.
const headerRow = page
.locator(`h4:text-is("${streamName}")`)
.locator("xpath=../..");
await headerRow.getByRole("button").last().click();
}
test.describe("go2rtc streams settings — ffmpeg compat mode @medium", () => {
test("preserves unknown fragments like #timeout= in the URL input", async ({
frigateApp,
}) => {
await installRawPathsRoute(frigateApp.page, FFMPEG_URL_WITH_TIMEOUT);
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
await expect(
frigateApp.page.getByRole("heading", { name: STREAM_NAME }),
).toBeVisible();
await expandStream(frigateApp.page, STREAM_NAME);
const urlInput = frigateApp.page.getByPlaceholder(
"e.g., rtsp://user:pass@192.168.1.100/stream",
);
await expect(urlInput).toBeVisible();
// Focus the input so credential masking is bypassed and the raw value
// is rendered — this matches how a user would inspect the URL before
// editing it.
await urlInput.focus();
await expect(urlInput).toHaveValue(
"rtsp://user:pass@192.168.0.20:554/Stream1#timeout=10",
);
});
test("lets the user add an extra fragment in compat mode", async ({
frigateApp,
}) => {
const capture = await installRawPathsRoute(
frigateApp.page,
FFMPEG_URL_WITH_TIMEOUT,
);
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
await expandStream(frigateApp.page, STREAM_NAME);
const urlInput = frigateApp.page.getByPlaceholder(
"e.g., rtsp://user:pass@192.168.1.100/stream",
);
await urlInput.focus();
await urlInput.fill(
"rtsp://user:pass@192.168.0.20:554/Stream1#timeout=10#backchannel=0",
);
await urlInput.blur();
// Reopen and re-focus to assert the new value round-tripped through
// parseFfmpegBaseAndExtras + buildFfmpegUrl back into the displayed text.
await urlInput.focus();
await expect(urlInput).toHaveValue(
"rtsp://user:pass@192.168.0.20:554/Stream1#timeout=10#backchannel=0",
);
// Save and verify the persisted URL includes both extras after the
// recognized video/audio directives.
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
.toMatchObject({
config_data: {
go2rtc: {
streams: {
[STREAM_NAME]: [
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#timeout=10#backchannel=0",
],
},
},
},
});
});
test("preserves repeatable #audio= fallback chain and lets the user add another codec", async ({
frigateApp,
}) => {
const capture = await installRawPathsRoute(
frigateApp.page,
// Idiomatic go2rtc fallback: copy if source has the codec, else transcode
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#audio=opus",
);
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
await expandStream(frigateApp.page, STREAM_NAME);
// Two pre-populated audio rows — one per #audio= fragment.
const audioLabel = frigateApp.page.locator(`label:text-is("Audio")`);
const audioRowsContainer = audioLabel.locator("xpath=../..");
await expect(audioRowsContainer.getByRole("combobox")).toHaveCount(2);
await expect(audioRowsContainer.getByRole("combobox").first()).toHaveText(
"Copy",
);
await expect(audioRowsContainer.getByRole("combobox").nth(1)).toHaveText(
"Transcode to Opus",
);
// Add a third audio codec via the LuPlus next to the "Audio" label.
await audioRowsContainer
.getByRole("button", { name: "Add audio codec" })
.click();
await expect(audioRowsContainer.getByRole("combobox")).toHaveCount(3);
// Change the newly-added entry to AAC.
await audioRowsContainer.getByRole("combobox").nth(2).click();
await frigateApp.page
.getByRole("option", { name: "Transcode to AAC" })
.click();
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
.toMatchObject({
config_data: {
go2rtc: {
streams: {
[STREAM_NAME]: [
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#audio=opus#audio=aac",
],
},
},
},
});
});
test("LuX is only shown on fallback rows and removes only that codec", async ({
frigateApp,
}) => {
const capture = await installRawPathsRoute(
frigateApp.page,
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#audio=opus",
);
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
await expandStream(frigateApp.page, STREAM_NAME);
const audioLabel = frigateApp.page.locator(`label:text-is("Audio")`);
const audioRowsContainer = audioLabel.locator("xpath=../..");
const removeButtons = audioRowsContainer.getByRole("button", {
name: "Remove codec",
});
// Primary (audio=copy) row is permanent and has no X; only the audio=opus
// fallback exposes a remove button.
await expect(removeButtons).toHaveCount(1);
await removeButtons.first().click();
await expect(audioRowsContainer.getByRole("combobox")).toHaveCount(1);
await expect(audioRowsContainer.getByRole("combobox")).toHaveText("Copy");
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
.toMatchObject({
config_data: {
go2rtc: {
streams: {
[STREAM_NAME]: [
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy",
],
},
},
},
});
});
test("picking Exclude on the primary row drops the #video= fragment entirely", async ({
frigateApp,
}) => {
const capture = await installRawPathsRoute(
frigateApp.page,
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy",
);
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
await expandStream(frigateApp.page, STREAM_NAME);
const videoLabel = frigateApp.page.locator(`label:text-is("Video")`);
const videoRowsContainer = videoLabel.locator("xpath=../..");
await videoRowsContainer.getByRole("combobox").first().click();
await frigateApp.page.getByRole("option", { name: "Exclude" }).click();
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
.toMatchObject({
config_data: {
go2rtc: {
streams: {
[STREAM_NAME]: [
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#audio=copy",
],
},
},
},
});
});
});

View File

@ -950,4 +950,4 @@
"label": "Original camera state",
"description": "Keep track of original state of camera."
}
}
}

View File

@ -921,6 +921,41 @@
"label": "Original GenAI state",
"description": "Indicates whether GenAI was enabled in the original static config."
}
},
"filters_attribute": {
"label": "Attribute filters",
"description": "Filters applied to detected attributes to reduce false positives (area, ratio, confidence).",
"min_area": {
"label": "Minimum attribute area",
"description": "Minimum bounding box area (pixels or percentage) required for this attribute. Can be pixels (int) or percentage (float between 0.000001 and 0.99)."
},
"max_area": {
"label": "Maximum attribute area",
"description": "Maximum bounding box area (pixels or percentage) allowed for this attribute. Can be pixels (int) or percentage (float between 0.000001 and 0.99)."
},
"min_ratio": {
"label": "Minimum aspect ratio",
"description": "Minimum width/height ratio required for the bounding box to qualify."
},
"max_ratio": {
"label": "Maximum aspect ratio",
"description": "Maximum width/height ratio allowed for the bounding box to qualify."
},
"threshold": {
"label": "Confidence threshold",
"description": "Average detection confidence threshold required for the attribute to be considered a true positive."
},
"min_score": {
"label": "Minimum confidence",
"description": "Minimum single-frame detection confidence required to associate this attribute with its parent object."
},
"mask": {
"label": "Filter mask",
"description": "Polygon coordinates defining where this filter applies within the frame."
},
"raw_mask": {
"label": "Raw Mask"
}
}
},
"record": {
@ -1597,4 +1632,4 @@
"description": "Ignore time synchronization differences between camera and Frigate server for ONVIF communication."
}
}
}
}

View File

@ -60,5 +60,10 @@
"stats": {
"context": "{{tokens}} tokens",
"tokens_per_second": "{{rate}} t/s"
},
"reasoning": {
"active": "Reasoning…",
"show": "Show reasoning",
"hide": "Hide reasoning"
}
}

View File

@ -222,7 +222,7 @@
"label": "Hide object path"
},
"debugReplay": {
"label": "Debug replay",
"label": "Debug Replay",
"aria": "View this tracked object in the debug replay view"
},
"more": {

View File

@ -40,6 +40,11 @@
"profilePrefix": "{{profile}} profile: {{fields}}"
}
},
"menuDot": {
"overrideGlobal": "This section overrides the global configuration",
"overrideProfile": "This section is overridden by the {{profile}} profile",
"unsaved": "This section has unsaved changes"
},
"menu": {
"general": "General",
"globalConfig": "Global configuration",
@ -472,10 +477,13 @@
"streams": {
"title": "Enable / Disable Cameras",
"enableLabel": "Enabled cameras",
"enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.<br /> <em>Note: This does not disable go2rtc restreams.</em>",
"enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.<br /> <em>Note: This does not disable go2rtc restreams.</em><br /><br />Drag the handle to reorder the cameras as they appear in the UI. The order of enabled cameras will be reflected throughout the UI including the Live dashboard and camera selection dropdowns.",
"disableLabel": "Disabled cameras",
"disableDesc": "Enable a camera that is currently not visible in the UI and disabled in the configuration. A restart of Frigate is required after enabling.",
"enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes.",
"reorderHandle": "Drag to reorder",
"saving": "Saving…",
"saved": "Saved",
"friendlyName": {
"edit": "Edit camera display name",
"title": "Edit Display Name",
@ -1583,6 +1591,8 @@
"resetError": "Failed to reset settings",
"saveAllSuccess_one": "Saved {{count}} section successfully.",
"saveAllSuccess_other": "All {{count}} sections saved successfully.",
"saveAllSuccessRestartRequired_one": "Saved {{count}} section successfully. Restart Frigate to apply your changes.",
"saveAllSuccessRestartRequired_other": "All {{count}} sections saved successfully. Restart Frigate to apply your changes.",
"saveAllPartial_one": "{{successCount}} of {{totalCount}} section saved. {{failCount}} failed.",
"saveAllPartial_other": "{{successCount}} of {{totalCount}} sections saved. {{failCount}} failed.",
"saveAllFailure": "Failed to save all sections."
@ -1639,6 +1649,7 @@
"addStream": "Add stream",
"addStreamDesc": "Enter a name for the new stream. This name will be used to reference the stream in your camera configuration.",
"addUrl": "Add URL",
"streamNumber": "Stream {{index}}",
"streamName": "Stream name",
"streamNamePlaceholder": "e.g., front_door",
"streamUrlPlaceholder": "e.g., rtsp://user:pass@192.168.1.100/stream",
@ -1672,7 +1683,15 @@
"audioMp3": "Transcode to MP3",
"audioExclude": "Exclude",
"hardwareNone": "No hardware acceleration",
"hardwareAuto": "Automatic hardware acceleration"
"hardwareAuto": "Automatic (recommended)",
"hardwareVaapi": "VAAPI",
"hardwareCuda": "CUDA",
"hardwareV4l2m2m": "V4L2 M2M",
"hardwareDxva2": "DXVA2",
"hardwareVideotoolbox": "VideoToolbox",
"addVideoCodec": "Add video codec",
"addAudioCodec": "Add audio codec",
"removeCodec": "Remove codec"
}
},
"birdseye": {
@ -1680,6 +1699,13 @@
"objects": "Objects",
"motion": "Motion",
"continuous": "Continuous"
},
"cameraOrder": {
"label": "Camera order",
"description": "Drag cameras to set their order in the Birdseye layout.",
"reorderHandle": "Drag to reorder",
"saving": "Saving…",
"saved": "Saved"
}
},
"retainMode": {

View File

@ -10,15 +10,21 @@ export function WsProvider({ children }: { children: ReactNode }) {
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectAttempt = useRef(0);
const unmounted = useRef(false);
const pendingSends = useRef<Map<string, unknown>>(new Map());
const sendJsonMessage = useCallback((msg: unknown) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(msg));
} else if (msg && typeof msg === "object" && "topic" in msg) {
// Sends issued before the socket reaches OPEN (or during a reconnect
// window) are buffered here and flushed in onopen
pendingSends.current.set(String((msg as { topic: unknown }).topic), msg);
}
}, []);
useEffect(() => {
unmounted.current = false;
const queue = pendingSends.current;
function connect() {
if (unmounted.current) return;
@ -31,6 +37,10 @@ export function WsProvider({ children }: { children: ReactNode }) {
ws.send(
JSON.stringify({ topic: "onConnect", message: "", retain: false }),
);
for (const queued of queue.values()) {
ws.send(JSON.stringify(queued));
}
queue.clear();
};
ws.onmessage = (event: MessageEvent) => {
@ -64,6 +74,7 @@ export function WsProvider({ children }: { children: ReactNode }) {
ws.onerror = null;
ws.close();
}
queue.clear();
resetWsStore();
};
}, [wsUrl]);

View File

@ -32,6 +32,9 @@ import { FaFolder, FaVideo } from "react-icons/fa";
import { HiSquare2Stack } from "react-icons/hi2";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import useContextMenu from "@/hooks/use-contextmenu";
import axios from "axios";
import { toast } from "sonner";
import { useNavigate } from "react-router-dom";
type CaseCardProps = {
className: string;
@ -123,11 +126,63 @@ export function ExportCard({
onAssignToCase,
onRemoveFromCase,
}: ExportCardProps) {
const { t } = useTranslation(["views/exports"]);
const { t } = useTranslation(["views/exports", "views/replay"]);
const navigate = useNavigate();
const isAdmin = useIsAdmin();
const [loading, setLoading] = useState(
exportedRecording.thumb_path.length > 0,
);
const [isStartingReplay, setIsStartingReplay] = useState(false);
const handleDebugReplay = useCallback(() => {
setIsStartingReplay(true);
axios
.post("debug_replay/start_from_export", {
export_id: exportedRecording.id,
})
.then((response) => {
if (response.status === 202 || response.status === 200) {
navigate("/replay");
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
if (error.response?.status === 409) {
toast.error(t("dialog.toast.alreadyActive", { ns: "views/replay" }), {
position: "top-center",
closeButton: true,
dismissible: false,
action: (
<a
href={`${baseUrl}replay`}
target="_blank"
rel="noopener noreferrer"
>
<Button>
{t("dialog.toast.goToReplay", { ns: "views/replay" })}
</Button>
</a>
),
});
} else {
toast.error(
t("dialog.toast.error", {
ns: "views/replay",
error: errorMessage,
}),
{ position: "top-center" },
);
}
})
.finally(() => {
setIsStartingReplay(false);
});
}, [exportedRecording.id, navigate, t]);
// Resync the skeleton state whenever the backing export changes. The
// list keys by id now, so in practice the component remounts instead
@ -301,6 +356,21 @@ export function ExportCard({
{t("tooltip.downloadVideo")}
</a>
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("title", { ns: "views/replay" })}
disabled={isStartingReplay}
onClick={(e) => {
e.stopPropagation();
handleDebugReplay();
}}
>
{isStartingReplay
? t("dialog.starting", { ns: "views/replay" })
: t("title", { ns: "views/replay" })}
</DropdownMenuItem>
)}
{isAdmin && onAssignToCase && (
<DropdownMenuItem
className="cursor-pointer"

View File

@ -0,0 +1,87 @@
import { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { LuBrain, LuChevronDown, LuChevronRight } from "react-icons/lu";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type ReasoningBubbleProps = {
/** The accumulated reasoning text from the model. */
reasoning: string;
/**
* Whether the assistant has begun producing the user-facing answer.
* While false the reasoning is still streaming and we keep the panel
* open with a "Thinking…" label. Once true, the panel auto-collapses
* so the answer is the primary focus, but stays expandable.
*/
answerStarted: boolean;
};
export function ReasoningBubble({
reasoning,
answerStarted,
}: ReasoningBubbleProps) {
const { t } = useTranslation(["views/chat"]);
// Open while the model is still mid-thought (no answer tokens yet);
// once the answer begins, collapse on its own but let the user reopen.
const [open, setOpen] = useState(true);
const userInteractedRef = useRef(false);
const lastAutoState = useRef(true);
useEffect(() => {
if (userInteractedRef.current) return;
const desired = !answerStarted;
if (desired !== lastAutoState.current) {
lastAutoState.current = desired;
setOpen(desired);
}
}, [answerStarted]);
const handleOpenChange = (next: boolean) => {
userInteractedRef.current = true;
setOpen(next);
};
const label = !answerStarted
? t("reasoning.active")
: open
? t("reasoning.hide")
: t("reasoning.show");
return (
<div className="self-start rounded-2xl bg-muted/60 px-3 py-2 text-muted-foreground">
<Collapsible open={open} onOpenChange={handleOpenChange}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-auto w-full min-w-0 justify-start gap-2 whitespace-normal p-0 text-left text-xs hover:bg-transparent"
>
<LuBrain
className={cn(
"size-3 shrink-0",
!answerStarted && "animate-pulse",
)}
/>
<span className="break-words font-medium">{label}</span>
{answerStarted &&
(open ? (
<LuChevronDown className="ml-auto size-3 shrink-0" />
) : (
<LuChevronRight className="ml-auto size-3 shrink-0" />
))}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="scrollbar-container mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-sans text-xs leading-relaxed">
{reasoning}
</pre>
</CollapsibleContent>
</Collapsible>
</div>
);
}

View File

@ -19,7 +19,7 @@ const birdseye: SectionConfigOverrides = {
],
restartRequired: [],
fieldOrder: ["enabled", "mode", "order"],
hiddenFields: [],
hiddenFields: ["order"],
advancedFields: [],
overrideFields: ["enabled", "mode"],
uiSchema: {
@ -56,6 +56,7 @@ const birdseye: SectionConfigOverrides = {
uiSchema: {
mode: {
"ui:size": "xs",
"ui:after": { render: "BirdseyeCameraReorder" },
},
},
},

View File

@ -1,12 +1,60 @@
import type { FrigateConfig } from "@/types/frigateConfig";
import type { HiddenFieldContext } from "@/types/configForm";
import { getEffectiveAttributeLabels } from "@/utils/configUtil";
import type { SectionConfigOverrides } from "./types";
// Attribute labels (face, license_plate, Frigate+ couriers like DHL/Amazon,
// etc.) are populated into objects.filters by the backend even when the
// model can't actually detect them. They aren't user-settable, so hide any
// `filters.<attr>` patterns from forms and override comparisons.
const hideAttributeFilters = (config: FrigateConfig): string[] =>
(config.model?.all_attributes ?? []).map((attr) => `filters.${attr}`);
// etc.) are populated into objects.filters by the backend for every
// attribute the model knows about.
//
// - Untracked attributes: hide the whole `filters.<attr>` collapsible.
// - Tracked attributes: strip the FilterConfig fields we don't expose
// (`threshold`, `min_ratio`, `max_ratio`) from the form data so RJSF
// doesn't surface them as ad-hoc additionalProperties entries under the
// restricted AttributeFilter schema (see modifySchemaForSection objects
// branch). The data is sanitized out symmetrically from the baseline
// too, so power-user YAML values for those fields are preserved on save
// (buildOverrides only emits diffs of fields the form has seen).
const ATTRIBUTE_FILTER_HIDDEN_SUBFIELDS = [
"threshold",
"min_ratio",
"max_ratio",
];
const hideAttributeFilters = ({
fullConfig,
fullCameraConfig,
level,
formData,
}: HiddenFieldContext): string[] => {
const trackFromForm = Array.isArray(
(formData as { track?: unknown } | undefined)?.track,
)
? (formData as { track: string[] }).track
: undefined;
const track =
trackFromForm ??
(level !== "global" ? fullCameraConfig?.objects?.track : undefined) ??
fullConfig.objects?.track ??
[];
const attrs = getEffectiveAttributeLabels(
fullConfig,
fullCameraConfig,
level,
);
const hidden: string[] = [];
for (const attr of attrs) {
if (!track.includes(attr)) {
hidden.push(`filters.${attr}`);
} else {
for (const field of ATTRIBUTE_FILTER_HIDDEN_SUBFIELDS) {
hidden.push(`filters.${attr}.${field}`);
}
}
}
return hidden;
};
const objects: SectionConfigOverrides = {
base: {

View File

@ -46,7 +46,11 @@ const record: SectionConfigOverrides = {
uiSchema: {
export: {
hwaccel_args: {
"ui:options": { suppressMultiSchema: true, size: "lg" },
"ui:widget": "FfmpegArgsWidget",
"ui:options": {
suppressMultiSchema: true,
ffmpegPresetField: "hwaccel_args",
},
},
},
"alerts.retain.mode": {

View File

@ -5,7 +5,7 @@ const ui: SectionConfigOverrides = {
sectionDocs: "/configuration/reference",
restartRequired: [],
fieldOrder: ["dashboard", "order"],
hiddenFields: [],
hiddenFields: ["order"],
advancedFields: [],
overrideFields: [],
},

View File

@ -0,0 +1,213 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import axios from "axios";
import { toast } from "sonner";
import useSWR from "swr";
import { Reorder, useDragControls } from "framer-motion";
import { LuCheck, LuGripVertical } from "react-icons/lu";
import { SplitCardRow } from "@/components/card/SettingsGroupCard";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { FrigateConfig } from "@/types/frigateConfig";
import { cn } from "@/lib/utils";
import type { SectionRendererProps } from "./registry";
const SAVED_INDICATOR_MS = 1500;
type SaveStatus = "idle" | "saving" | "saved";
export default function BirdseyeCameraReorder({
formContext,
}: SectionRendererProps) {
const { t } = useTranslation(["views/settings", "common"]);
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const birdseyeCameras = useMemo(() => {
if (!config) return [];
return Object.keys(config.cameras)
.filter(
(name) =>
config.cameras[name].enabled_in_config &&
config.cameras[name].birdseye?.enabled !== false,
)
.sort((a, b) => {
const orderA = config.cameras[a].birdseye?.order ?? 0;
const orderB = config.cameras[b].birdseye?.order ?? 0;
if (orderA !== orderB) return orderA - orderB;
return a.localeCompare(b);
});
}, [config]);
const [orderedCameras, setOrderedCameras] =
useState<string[]>(birdseyeCameras);
const orderedCamerasRef = useRef(orderedCameras);
useEffect(() => {
orderedCamerasRef.current = orderedCameras;
}, [orderedCameras]);
useEffect(() => {
setOrderedCameras((prev) => {
if (
prev.length === birdseyeCameras.length &&
prev.every((cam, i) => cam === birdseyeCameras[i])
) {
return prev;
}
return birdseyeCameras;
});
}, [birdseyeCameras]);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
const savedResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (savedResetTimerRef.current) {
clearTimeout(savedResetTimerRef.current);
}
};
}, []);
const handleDragEnd = useCallback(async () => {
const current = orderedCamerasRef.current;
if (
current.length === birdseyeCameras.length &&
current.every((cam, i) => cam === birdseyeCameras[i])
) {
return;
}
const cameraUpdates: Record<string, { birdseye: { order: number } }> = {};
current.forEach((cam, i) => {
cameraUpdates[cam] = { birdseye: { order: i * 10 } };
});
if (savedResetTimerRef.current) {
clearTimeout(savedResetTimerRef.current);
savedResetTimerRef.current = null;
}
setSaveStatus("saving");
try {
await axios.put("config/set", {
requires_restart: 0,
config_data: { cameras: cameraUpdates },
});
await updateConfig();
setSaveStatus("saved");
savedResetTimerRef.current = setTimeout(() => {
setSaveStatus("idle");
savedResetTimerRef.current = null;
}, SAVED_INDICATOR_MS);
} catch (error) {
setOrderedCameras(birdseyeCameras);
setSaveStatus("idle");
const errorMessage =
axios.isAxiosError(error) &&
(error.response?.data?.message || error.response?.data?.detail)
? error.response?.data?.message || error.response?.data?.detail
: t("toast.save.error.noMessage", { ns: "common" });
toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), {
position: "top-center",
});
}
}, [birdseyeCameras, updateConfig, t]);
if (formContext?.level && formContext.level !== "global") {
return null;
}
if (!config || birdseyeCameras.length < 2) {
return null;
}
return (
<SplitCardRow
label={t("birdseye.cameraOrder.label", { ns: "views/settings" })}
description={t("birdseye.cameraOrder.description", {
ns: "views/settings",
})}
content={
<div className="max-w-md space-y-1.5">
<Reorder.Group
as="div"
axis="y"
values={orderedCameras}
onReorder={setOrderedCameras}
className="space-y-2 rounded-lg bg-secondary p-4"
>
{orderedCameras.map((camera) => (
<BirdseyeCameraRow
key={camera}
camera={camera}
onDragEnd={handleDragEnd}
/>
))}
</Reorder.Group>
<SaveStatusIndicator status={saveStatus} />
</div>
}
/>
);
}
type SaveStatusIndicatorProps = {
status: SaveStatus;
};
function SaveStatusIndicator({ status }: SaveStatusIndicatorProps) {
const { t } = useTranslation(["views/settings"]);
return (
<div
aria-live="polite"
className={cn(
"flex h-4 items-center justify-start gap-1 text-xs transition-opacity duration-200",
status === "idle" ? "opacity-0" : "opacity-100",
)}
>
{status === "saving" && (
<span className="text-muted-foreground">
{t("birdseye.cameraOrder.saving")}
</span>
)}
{status === "saved" && (
<span className="flex items-center gap-1 text-success">
<LuCheck className="size-3.5" />
{t("birdseye.cameraOrder.saved")}
</span>
)}
</div>
);
}
type BirdseyeCameraRowProps = {
camera: string;
onDragEnd: () => void;
};
function BirdseyeCameraRow({ camera, onDragEnd }: BirdseyeCameraRowProps) {
const { t } = useTranslation(["views/settings"]);
const controls = useDragControls();
return (
<Reorder.Item
as="div"
value={camera}
dragListener={false}
dragControls={controls}
onDragEnd={onDragEnd}
className="flex flex-row items-center gap-1"
>
<button
type="button"
onPointerDown={(e) => controls.start(e)}
className="-ml-1 cursor-grab touch-none rounded p-1 text-muted-foreground hover:text-primary active:cursor-grabbing"
aria-label={t("birdseye.cameraOrder.reorderHandle")}
>
<LuGripVertical className="size-4" />
</button>
<CameraNameLabel camera={camera} />
</Reorder.Item>
);
}

View File

@ -3,6 +3,7 @@ import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
import CameraReviewStatusToggles from "./CameraReviewStatusToggles";
import ProxyRoleMap from "./ProxyRoleMap";
import NotificationsSettingsExtras from "./NotificationsSettingsExtras";
import BirdseyeCameraReorder from "./BirdseyeCameraReorder";
import type { ConfigFormContext } from "@/types/configForm";
// Props that will be injected into all section renderers
@ -52,6 +53,9 @@ export const sectionRenderers: SectionRenderers = {
notifications: {
NotificationsSettingsExtras,
},
birdseye: {
BirdseyeCameraReorder,
},
};
export default sectionRenderers;

View File

@ -308,11 +308,30 @@ export function ConfigSection({
// Get section schema using cached hook
const sectionSchema = useSectionSchema(sectionPath, effectiveLevel);
// Apply special case handling for sections with problematic schema defaults
// Apply special case handling for sections with problematic schema defaults.
// The HiddenFieldContext is built from `config` (saved state) only — not the
// in-flight raw section value — because the schema is computed before
// rawFormData is derived. The objects-branch fallback in
// modifySchemaForSection reads `track` from fullCameraConfig / fullConfig.
const modifiedSchema = useMemo(
() =>
modifySchemaForSection(sectionPath, level, sectionSchema ?? undefined),
[sectionPath, level, sectionSchema],
modifySchemaForSection(
sectionPath,
level,
sectionSchema ?? undefined,
config
? {
fullConfig: config,
fullCameraConfig:
effectiveLevel === "camera" && cameraName
? config.cameras?.[cameraName]
: undefined,
level,
cameraName,
}
: undefined,
),
[sectionPath, level, sectionSchema, config, effectiveLevel, cameraName],
);
// Get override status (camera vs global)
@ -384,7 +403,19 @@ export function ConfigSection({
// When editing a profile, hide fields that require a restart since they
// cannot take effect via profile switching alone.
const effectiveHiddenFields = useMemo(() => {
const base = resolveHiddenFieldEntries(sectionConfig.hiddenFields, config);
const ctx = config
? {
fullConfig: config,
fullCameraConfig:
effectiveLevel === "camera" && cameraName
? config.cameras?.[cameraName]
: undefined,
level,
cameraName,
formData: rawFormData,
}
: undefined;
const base = resolveHiddenFieldEntries(sectionConfig.hiddenFields, ctx);
if (!profileName || !sectionConfig.restartRequired?.length) {
return base;
}
@ -394,6 +425,10 @@ export function ConfigSection({
sectionConfig.hiddenFields,
sectionConfig.restartRequired,
config,
effectiveLevel,
cameraName,
level,
rawFormData,
]);
const sanitizeSectionData = useCallback(
@ -743,6 +778,7 @@ export function ConfigSection({
"Settings saved successfully. Restart Frigate to apply your changes.",
}),
{
duration: 10000,
action: (
<a onClick={() => setRestartDialogOpen(true)}>
<Button>

View File

@ -20,6 +20,7 @@ import type { ProfilesApiResponse } from "@/types/profile";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { formatList } from "@/utils/stringUtil";
import {
buildHiddenFieldContext,
getEffectiveHiddenFields,
pathMatchesHiddenPattern,
} from "@/utils/configUtil";
@ -187,7 +188,7 @@ export function CameraOverridesBadge({ sectionPath, className }: Props) {
const hiddenFields = getEffectiveHiddenFields(
sectionPath,
"global",
config,
buildHiddenFieldContext(config, "global"),
);
if (hiddenFields.length === 0) return rawEntries;
return rawEntries

View File

@ -9,7 +9,8 @@
import { RJSFSchema } from "@rjsf/utils";
import { applySchemaDefaults } from "@/lib/config-schema";
import { isJsonObject } from "@/lib/utils";
import { JsonObject, JsonValue } from "@/types/configForm";
import { HiddenFieldContext, JsonObject, JsonValue } from "@/types/configForm";
import { getEffectiveAttributeLabels } from "@/utils/configUtil";
/**
* Sections that require special handling at the global level.
@ -37,13 +38,28 @@ export function isSpecialCaseSection(
*
* - detectors: Strip the "default" field to prevent RJSF from merging the
* default {"cpu": {"type": "cpu"}} with stored detector keys.
* - genai: Inject a default provider value on the additionalProperties shape.
* - objects: Promote tracked attribute labels (face, license_plate, courier
* logos) from `filters.additionalProperties` to explicit
* `filters.properties.<attr>` entries with a restricted FilterConfig
* shape, so RJSF renders just that one field for
* attribute filters. Non-attribute tracked labels (person, car, )
* keep flowing through the unmodified `additionalProperties` and render
* the full FilterConfig form.
*/
export function modifySchemaForSection(
sectionPath: string,
level: string,
schema: RJSFSchema | undefined,
ctx?: HiddenFieldContext,
): RJSFSchema | undefined {
if (!schema || !isSpecialCaseSection(sectionPath, level)) {
if (!schema) return schema;
if (sectionPath === "objects") {
return modifyObjectsSchema(schema, ctx);
}
if (!isSpecialCaseSection(sectionPath, level)) {
return schema;
}
@ -79,6 +95,151 @@ export function modifySchemaForSection(
return schema;
}
/**
* Build a stripped FilterConfig schema for tracked attribute filters
* (face, license_plate, etc.). Keeps only the fields meaningful for
* attribute detections `min_score`, `min_area`, `max_area`. `threshold`
* and the ratio fields aren't exposed: attributes don't flow through
* `_is_false_positive` (no median-of-history check), and aspect-ratio
* filtering isn't a typical attribute-tuning knob.
*
* `min_area` and `max_area` are `Union[int, float]` in Pydantic which
* emits as `anyOf` in JSON schema; we flatten to a plain `number` so RJSF
* doesn't render the int/float type-selector dropdown for each attribute
* filter. The backend still accepts either int (pixels) or float
* (percentage) since the underlying FilterConfig union is unchanged.
*/
function buildAttributeFilterSchema(
filterConfigSchema: RJSFSchema,
attributeLabel: string,
): RJSFSchema {
const props = isJsonObject(
(filterConfigSchema as { properties?: unknown }).properties,
)
? (filterConfigSchema as { properties: Record<string, RJSFSchema> })
.properties
: undefined;
const minScoreSchema =
props && props.min_score ? props.min_score : { type: "number" };
const flattenToNumber = (src: RJSFSchema | undefined): RJSFSchema => {
if (!src) return { type: "number" };
const { anyOf: _anyOf, ...rest } = src as {
anyOf?: unknown;
[k: string]: unknown;
};
return { ...rest, type: "number" } as RJSFSchema;
};
return {
type: "object",
title: attributeLabel,
properties: {
min_score: minScoreSchema,
min_area: flattenToNumber(props && props.min_area),
max_area: flattenToNumber(props && props.max_area),
},
additionalProperties: false,
} as RJSFSchema;
}
function modifyObjectsSchema(
schema: RJSFSchema,
ctx: HiddenFieldContext | undefined,
): RJSFSchema {
if (!ctx) return schema;
const allAttributes = getEffectiveAttributeLabels(
ctx.fullConfig,
ctx.fullCameraConfig,
ctx.level,
);
// Resolve effective track at this scope, falling back through camera
// config then global config (matches hideAttributeFilters in objects.ts).
const trackFromForm = Array.isArray(
(ctx.formData as { track?: unknown } | undefined)?.track,
)
? (ctx.formData as { track: string[] }).track
: undefined;
const track =
trackFromForm ??
(ctx.level !== "global"
? ctx.fullCameraConfig?.objects?.track
: undefined) ??
ctx.fullConfig.objects?.track ??
[];
if (track.length === 0) return schema;
const schemaProperties = isJsonObject(
(schema as { properties?: unknown }).properties,
)
? (schema as { properties: Record<string, RJSFSchema> }).properties
: undefined;
const filtersSchema =
schemaProperties && schemaProperties.filters
? schemaProperties.filters
: undefined;
if (!filtersSchema) return schema;
const filterEntrySchema = isJsonObject(
(filtersSchema as { additionalProperties?: unknown }).additionalProperties,
)
? (filtersSchema as { additionalProperties: RJSFSchema })
.additionalProperties
: undefined;
if (!filterEntrySchema) return schema;
const attributeSet = new Set(allAttributes);
const existingProperties = isJsonObject(
(filtersSchema as { properties?: unknown }).properties,
)
? (filtersSchema as { properties: Record<string, RJSFSchema> }).properties
: {};
// Promote every tracked label to an explicit property entry so RJSF
// renders it as a normal collapsible (no additionalProperties key/value
// editor UI). Attribute labels get a restricted shape with only
// `min_score`; non-attribute labels get the full FilterConfig. Sorted
// alphabetically so the filter collapsibles match the order of the
// sibling `track` switches.
const sortedTrackedLabels = track
.filter((label): label is string => typeof label === "string")
.slice()
.sort((a, b) => a.localeCompare(b));
const updatedFilterProperties: Record<string, RJSFSchema> = {
...existingProperties,
};
for (const label of sortedTrackedLabels) {
if (attributeSet.has(label)) {
updatedFilterProperties[label] = buildAttributeFilterSchema(
filterEntrySchema,
label,
);
} else {
updatedFilterProperties[label] = {
...filterEntrySchema,
title: label,
} as RJSFSchema;
}
}
const updatedFiltersSchema: RJSFSchema = {
...filtersSchema,
properties: updatedFilterProperties,
};
return {
...schema,
properties: {
...schemaProperties,
filters: updatedFiltersSchema,
},
};
}
/**
* Get effective defaults for sections with special schema patterns.
*

View File

@ -6,6 +6,7 @@
*/
import type { ConfigFormContext } from "@/types/configForm";
import { getEffectiveAttributeLabels } from "@/utils/configUtil";
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
@ -70,12 +71,27 @@ export function buildTranslationPath(
(segment): segment is string => typeof segment === "string",
);
// Handle filters section - skip the dynamic filter object name
// Example: filters.person.threshold -> filters.threshold
// Handle filters section - skip the dynamic filter object name. Route
// to `filters_attribute.<field>` when the dynamic key is an attribute
// label (face, license_plate, courier logos) so attribute filter fields
// pick up the attribute-worded translations emitted by
// generate_config_translations.py.
// Example: filters.person.threshold -> filters.threshold
// Example: filters.face.min_area -> filters_attribute.min_area
const filtersIndex = stringSegments.indexOf("filters");
if (filtersIndex !== -1 && stringSegments.length > filtersIndex + 2) {
const filterKey = stringSegments[filtersIndex + 1];
const allAttributes = getEffectiveAttributeLabels(
formContext?.fullConfig,
formContext?.fullCameraConfig,
formContext?.level,
);
const sectionWord = allAttributes.includes(filterKey)
? "filters_attribute"
: "filters";
const normalized = [
...stringSegments.slice(0, filtersIndex + 1),
...stringSegments.slice(0, filtersIndex),
sectionWord,
...stringSegments.slice(filtersIndex + 2),
];
return normalized.join(".");

View File

@ -1,4 +1,6 @@
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import axios from "axios";
import { toast } from "sonner";
import { Event } from "@/types/event";
import { baseUrl } from "@/api/baseUrl";
import { ReviewSegment, REVIEW_PADDING } from "@/types/review";
@ -12,6 +14,7 @@ import {
DropdownMenuTrigger,
DropdownMenuPortal,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { HiDotsHorizontal } from "react-icons/hi";
import { SearchResult } from "@/types/search";
import { FrigateConfig } from "@/types/frigateConfig";
@ -33,9 +36,14 @@ export default function DetailActionsMenu({
setSearch,
setSimilarity,
}: Props) {
const { t } = useTranslation(["views/explore", "views/faceLibrary"]);
const { t } = useTranslation([
"views/explore",
"views/faceLibrary",
"views/replay",
]);
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const isAdmin = useIsAdmin();
const clipTimeRange = useMemo(() => {
@ -49,6 +57,54 @@ export default function DetailActionsMenu({
search.data?.type === "audio" ? null : [`review/event/${search.id}`],
);
const handleDebugReplay = useCallback(() => {
setIsStarting(true);
axios
.post("debug_replay/start", {
camera: search.camera,
start_time: search.start_time,
end_time: search.end_time,
})
.then((response) => {
if (response.status === 202 || response.status === 200) {
navigate("/replay");
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
if (error.response?.status === 409) {
toast.error(t("dialog.toast.alreadyActive", { ns: "views/replay" }), {
position: "top-center",
closeButton: true,
dismissible: false,
action: (
<a
href={`${baseUrl}replay`}
target="_blank"
rel="noopener noreferrer"
>
<Button>
{t("dialog.toast.goToReplay", { ns: "views/replay" })}
</Button>
</a>
),
});
} else {
toast.error(t("dialog.toast.error", { error: errorMessage }), {
position: "top-center",
});
}
})
.finally(() => {
setIsStarting(false);
});
}, [navigate, search.camera, search.start_time, search.end_time, t]);
// don't render menu at all if no options are available
const hasSemanticSearchOption =
config?.semantic_search.enabled &&
@ -172,6 +228,24 @@ export default function DetailActionsMenu({
</div>
</DropdownMenuItem>
)}
{search.has_clip && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("itemMenu.debugReplay.aria")}
disabled={isStarting}
onSelect={() => {
setIsOpen(false);
handleDebugReplay();
}}
>
<span>
{isStarting
? t("dialog.starting", { ns: "views/replay" })
: t("itemMenu.debugReplay.label")}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>

View File

@ -10,6 +10,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { JsonObject, JsonValue } from "@/types/configForm";
import { isJsonObject } from "@/lib/utils";
import {
buildHiddenFieldContext,
getBaseCameraSectionValue,
getEffectiveHiddenFields,
pathMatchesHiddenPattern,
@ -59,6 +60,64 @@ function stripHiddenPaths(value: JsonValue, hiddenFields: string[]): JsonValue {
return cloned;
}
/**
* Field paths that the backend resolves per-camera at runtime (from `fps`,
* stream introspection, or other camera-local state) but defaults to `None`
* in the global Pydantic model. Because the `/config` endpoint serializes
* with `exclude_none=True`, these paths are absent from the global section
* yet always populated on cameras, which would otherwise make every camera
* appear to override fields the user never set globally.
*/
const AUTO_DERIVED_FIELDS: Record<string, readonly string[]> = {
detect: [
"width",
"height",
"min_initialized",
"max_disappeared",
"stationary.interval",
"stationary.threshold",
],
};
/**
* Drop auto-derived field paths from the camera value when the global value
* has no explicit setting for that path. If the user later sets one of these
* fields globally, the path will be present in `globalValue` and normal
* comparison resumes.
*/
function stripAutoDerivedMissingFromGlobal(
sectionPath: string,
globalValue: JsonValue,
cameraValue: JsonValue,
): JsonValue {
const fields = AUTO_DERIVED_FIELDS[sectionPath];
if (!fields || !isJsonObject(cameraValue)) return cameraValue;
const cloned = cloneDeep(cameraValue) as JsonObject;
for (const path of fields) {
if (get(globalValue, path) === undefined) {
unsetWithWildcard(cloned as Record<string, unknown>, path);
}
}
return cloned;
}
/**
* Whether the given field is auto-derived for `sectionPath` and the global
* value at that path is missing in which case a per-camera value should
* not be treated as an override.
*/
function isAutoDerivedMissingFromGlobal(
sectionPath: string,
fieldPath: string,
globalValue: unknown,
): boolean {
const fields = AUTO_DERIVED_FIELDS[sectionPath];
if (!fields) return false;
if (!fields.includes(fieldPath)) return false;
const value = get(globalValue as JsonObject, fieldPath);
return value === undefined || value === null;
}
/**
* Collapse null and empty-object values for override comparisons so
* semantically equivalent shapes match. The schema may default `mask: None`
@ -228,16 +287,21 @@ export function useConfigOverride({
const hiddenFields = getEffectiveHiddenFields(
sectionPath,
"camera",
config,
buildHiddenFieldContext(config, "camera", cameraName),
);
const collapsedGlobal = stripHiddenPaths(
collapseEmpty(normalizedGlobalValue),
hiddenFields,
);
const collapsedCamera = stripHiddenPaths(
const collapsedCameraRaw = stripHiddenPaths(
collapseEmpty(normalizedCameraValue),
hiddenFields,
);
const collapsedCamera = stripAutoDerivedMissingFromGlobal(
sectionPath,
collapsedGlobal,
collapsedCameraRaw,
);
const comparisonGlobal = compareFields
? pickFields(collapsedGlobal, compareFields)
@ -258,6 +322,20 @@ export function useConfigOverride({
const globalFieldValue = get(normalizedGlobalValue, fieldPath);
const cameraFieldValue = get(normalizedCameraValue, fieldPath);
if (
isAutoDerivedMissingFromGlobal(
sectionPath,
fieldPath,
normalizedGlobalValue,
)
) {
return {
isOverridden: false,
globalValue: globalFieldValue,
cameraValue: cameraFieldValue,
};
}
return {
isOverridden: !isEqual(
collapseEmpty(globalFieldValue as JsonValue),
@ -362,15 +440,24 @@ export function useAllCameraOverrides(
getBaseCameraSectionValue(config, cameraName, key),
);
const hiddenFields = getEffectiveHiddenFields(key, "camera", config);
const hiddenFields = getEffectiveHiddenFields(
key,
"camera",
buildHiddenFieldContext(config, "camera", cameraName),
);
const collapsedGlobal = stripHiddenPaths(
collapseEmpty(globalValue),
hiddenFields,
);
const collapsedCamera = stripHiddenPaths(
const collapsedCameraRaw = stripHiddenPaths(
collapseEmpty(cameraValue),
hiddenFields,
);
const collapsedCamera = stripAutoDerivedMissingFromGlobal(
key,
collapsedGlobal,
collapsedCameraRaw,
);
const comparisonGlobal = compareFields
? pickFields(collapsedGlobal, compareFields)
: collapsedGlobal;
@ -615,7 +702,11 @@ export function useCamerasOverridingSection(
const deltasByPath = new Map<string, FieldDelta>();
// 1. Camera-level overrides (uses base_config when a profile is active)
const cameraValue = collapseEmpty(cameraSectionValues[idx]);
const cameraValue = stripAutoDerivedMissingFromGlobal(
sectionPath,
globalValue,
collapseEmpty(cameraSectionValues[idx]),
);
for (const delta of collectFieldDeltas(
globalValue,
cameraValue,
@ -696,16 +787,20 @@ export function useCameraSectionDeltas(
const globalValue = collapseEmpty(
getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema),
);
const cameraValue = collapseEmpty(
normalizeConfigValue(
getBaseCameraSectionValue(config, cameraName, sectionPath),
const cameraValue = stripAutoDerivedMissingFromGlobal(
sectionPath,
globalValue,
collapseEmpty(
normalizeConfigValue(
getBaseCameraSectionValue(config, cameraName, sectionPath),
),
),
);
const hiddenFields = getEffectiveHiddenFields(
sectionPath,
"camera",
config,
buildHiddenFieldContext(config, "camera", cameraName),
);
const deltas: FieldDelta[] = [];
@ -774,7 +869,7 @@ export function useProfileSectionDeltas(
const hiddenFields = getEffectiveHiddenFields(
sectionPath,
"camera",
config,
buildHiddenFieldContext(config, "camera", cameraName),
);
const deltas: FieldDelta[] = [];

View File

@ -10,7 +10,7 @@ import useSWR from "swr";
import useDeepMemo from "./use-deep-memo";
import { capitalizeAll, capitalizeFirstLetter } from "@/utils/stringUtil";
import { isReplayCamera } from "@/utils/cameraUtil";
import { useFrigateStats } from "@/api/ws";
import { useFrigateStats, useJobStatus } from "@/api/ws";
import { useIsAdmin } from "./use-is-admin";
import { useTranslation } from "react-i18next";
@ -19,11 +19,15 @@ export default function useStats(stats: FrigateStats | undefined) {
const { t } = useTranslation(["views/system"]);
const { data: config } = useSWR<FrigateConfig>("config");
const isAdmin = useIsAdmin();
const { data: debugReplayStatus } = useSWR(
isAdmin ? "debug_replay/status" : null,
{
revalidateOnFocus: false,
},
// Pass isAdmin as revalidateOnFocus so non-admins never send the jobState snapshot pull
const { payload: replayJob } = useJobStatus("debug_replay", isAdmin);
const replayActive = Boolean(
isAdmin &&
replayJob &&
(replayJob.status === "queued" ||
replayJob.status === "running" ||
replayJob.status === "success"),
);
const memoizedStats = useDeepMemo(stats);
@ -102,6 +106,11 @@ export default function useStats(stats: FrigateStats | undefined) {
// check camera cpu usages
Object.entries(memoizedStats["cameras"]).forEach(([name, cam]) => {
// Skip replay cameras
if (isReplayCamera(name)) {
return;
}
const ffmpegAvg = parseFloat(
memoizedStats["cpu_usages"][cam["ffmpeg_pid"]]?.cpu_average,
);
@ -111,12 +120,7 @@ export default function useStats(stats: FrigateStats | undefined) {
const cameraName = config?.cameras?.[name]?.friendly_name ?? name;
// Skip ffmpeg warnings for replay cameras
if (
!isNaN(ffmpegAvg) &&
ffmpegAvg >= CameraFfmpegThreshold.error &&
!isReplayCamera(name)
) {
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
problems.push({
text: t("stats.ffmpegHighCpuUsage", {
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
@ -140,7 +144,7 @@ export default function useStats(stats: FrigateStats | undefined) {
});
// Add message if debug replay is active
if (debugReplayStatus?.active) {
if (replayActive) {
problems.push({
text: t("stats.debugReplayActive", {
defaultValue: "Debug replay session is active",
@ -151,7 +155,7 @@ export default function useStats(stats: FrigateStats | undefined) {
}
return problems;
}, [config, memoizedStats, t, debugReplayStatus]);
}, [config, memoizedStats, t, replayActive]);
return { potentialProblems };
}

View File

@ -7,6 +7,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import axios from "axios";
import { ChatEventThumbnailsRow } from "@/components/chat/ChatEventThumbnailsRow";
import { MessageBubble } from "@/components/chat/ChatMessage";
import { ReasoningBubble } from "@/components/chat/ReasoningBubble";
import { ToolCallsGroup } from "@/components/chat/ToolCallsGroup";
import { ChatStartingState } from "@/components/chat/ChatStartingState";
import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip";
@ -200,15 +201,21 @@ export default function ChatPage() {
const hasToolCalls =
msg.toolCalls && msg.toolCalls.length > 0;
const hasContent = !!msg.content?.trim();
const hasReasoning = !!msg.reasoning?.trim();
const showProcessing =
isLastAssistant && isLoading && !hasContent;
isLastAssistant &&
isLoading &&
!hasContent &&
!hasReasoning;
// Hide empty placeholder only when there are no tool calls yet
// Hide empty placeholder only when there are no tool calls
// and no reasoning streaming yet
if (
isLastAssistant &&
isLoading &&
!hasContent &&
!hasToolCalls
!hasToolCalls &&
!hasReasoning
)
return (
<div
@ -226,13 +233,22 @@ export default function ChatPage() {
{msg.role === "assistant" && hasToolCalls && (
<ToolCallsGroup toolCalls={msg.toolCalls!} />
)}
{msg.role === "assistant" && hasReasoning && (
<ReasoningBubble
reasoning={msg.reasoning!}
answerStarted={hasContent}
/>
)}
{showProcessing ? (
<div className="flex items-center gap-2 self-start rounded-2xl bg-muted px-5 py-4">
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.3s]" />
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.15s]" />
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60" />
</div>
) : (
) : msg.role === "assistant" &&
!hasContent &&
hasReasoning &&
!isComplete ? null : (
<MessageBubble
role={msg.role}
content={msg.content}

View File

@ -89,10 +89,14 @@ import { mutate } from "swr";
import { RJSFSchema } from "@rjsf/utils";
import {
buildConfigDataForPath,
buildHiddenFieldContext,
flattenOverrides,
getSectionConfig,
parseProfileFromSectionPath,
prepareSectionSavePayload,
PROFILE_ELIGIBLE_SECTIONS,
resolveHiddenFieldEntries,
sanitizeSectionData,
} from "@/utils/configUtil";
import type { ProfileState, ProfilesApiResponse } from "@/types/profile";
import { getProfileColor } from "@/utils/profileColors";
@ -103,6 +107,12 @@ import SaveAllPreviewPopover, {
type SaveAllPreviewItem,
} from "@/components/overlay/detail/SaveAllPreviewPopover";
import { useRestart } from "@/api/ws";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { TooltipPortal } from "@radix-ui/react-tooltip";
const allSettingsViews = [
"uiSettings",
@ -786,24 +796,22 @@ export default function Settings() {
[],
);
// Show save/undo all buttons only when changes span multiple sections
// or the single changed section is not the one currently being viewed
// Show save/undo all buttons only when at least one pending change lives
// outside the currently visible page. Map each pending key to its menu key
// (e.g. both `detectors` and `model` collapse to `systemDetectorsAndModel`)
// so a composite page with two pending config-sections still counts as one.
const showSaveAllButtons = useMemo(() => {
const pendingKeys = Object.keys(pendingDataBySection);
if (pendingKeys.length === 0) return false;
if (pendingKeys.length >= 2) return true;
// Exactly one pending section — check if it matches the current view
const key = pendingKeys[0];
const menuKey = pendingKeyToMenuKey(key);
if (menuKey !== pageToggle) return true;
// For camera-scoped keys, also check if the camera matches
if (key.includes("::")) {
const cameraName = key.slice(0, key.indexOf("::"));
return cameraName !== selectedCamera;
for (const key of pendingKeys) {
const menuKey = pendingKeyToMenuKey(key);
if (menuKey !== pageToggle) return true;
if (key.includes("::")) {
const cameraName = key.slice(0, key.indexOf("::"));
if (cameraName !== selectedCamera) return true;
}
}
return false;
}, [pendingDataBySection, pendingKeyToMenuKey, pageToggle, selectedCamera]);
@ -821,8 +829,119 @@ export default function Settings() {
let failCount = 0;
let anyNeedsRestart = false;
const savedKeys: string[] = [];
// Pending entries that have been successfully PUT — cleared in one batch
// after `mutate("config")` resolves
const keysToClear: string[] = [];
const pendingKeys = Object.keys(pendingDataBySection);
// `detectors` and `model` are owned by DetectorsAndModelSettingsView,
// which saves them atomically (single combined PUT with a pre-clear when
// detector keys change or the Plus/Custom tab flips). Doing the same here
// keeps Save All consistent with the page's own Save button
const hasPendingDetectors = "detectors" in pendingDataBySection;
const hasPendingModel = "model" in pendingDataBySection;
if (hasPendingDetectors || hasPendingModel) {
try {
const pendingDetectors = hasPendingDetectors
? pendingDataBySection.detectors
: undefined;
const pendingModel = hasPendingModel
? pendingDataBySection.model
: undefined;
// Hidden-field lists come from the section configs themselves so
// they stay in sync with what the embedded forms strip on render
const detectorHiddenFields = resolveHiddenFieldEntries(
getSectionConfig("detectors", "global").hiddenFields,
buildHiddenFieldContext(config, "global"),
);
const modelHiddenFields = resolveHiddenFieldEntries(
getSectionConfig("model", "global").hiddenFields,
buildHiddenFieldContext(config, "global"),
);
const sanitizedDetectors =
pendingDetectors !== undefined
? sanitizeSectionData(pendingDetectors, detectorHiddenFields)
: undefined;
const sanitizedModel =
pendingModel !== undefined
? sanitizeSectionData(pendingModel, modelHiddenFields)
: undefined;
// Pre-clear conditions: detector keys differ from saved config (rename
// or add/remove), OR the model save flips between Plus and Custom modes
let detectorKeysChanged = false;
if (sanitizedDetectors && typeof sanitizedDetectors === "object") {
const pendingKeySet = Object.keys(
sanitizedDetectors as JsonObject,
).sort();
const savedKeySet = Object.keys(config.detectors ?? {}).sort();
detectorKeysChanged =
JSON.stringify(pendingKeySet) !== JSON.stringify(savedKeySet);
}
let modelTabChanged = false;
if (sanitizedModel && typeof sanitizedModel === "object") {
const newPath = (sanitizedModel as { path?: string }).path;
const oldPath = config.model?.path;
const newIsPlus =
typeof newPath === "string" && newPath.startsWith("plus://");
const oldIsPlus =
typeof oldPath === "string" && oldPath.startsWith("plus://");
modelTabChanged = newIsPlus !== oldIsPlus;
}
if (detectorKeysChanged || modelTabChanged) {
try {
await axios.put("config/set", {
requires_restart: 0,
config_data: { detectors: null, model: null },
});
} catch {
// best-effort cleanup; the merge-write below will surface any
// real error.
}
}
const combinedConfigData: Record<string, unknown> = {};
if (sanitizedDetectors !== undefined) {
combinedConfigData.detectors = sanitizedDetectors;
}
if (sanitizedModel !== undefined) {
combinedConfigData.model = sanitizedModel;
}
await axios.put("config/set", {
requires_restart: 0,
config_data: combinedConfigData,
});
if (hasPendingDetectors) {
keysToClear.push("detectors");
savedKeys.push("detectors");
}
if (hasPendingModel) {
keysToClear.push("model");
savedKeys.push("model");
}
if (hasPendingDetectors || hasPendingModel) {
successCount++;
anyNeedsRestart = true;
}
} catch (error) {
// eslint-disable-next-line no-console
console.error(
"Save All error saving detectors/model atomically",
error,
);
if (hasPendingDetectors || hasPendingModel) {
failCount++;
}
}
}
const pendingKeys = Object.keys(pendingDataBySection).filter(
(key) => key !== "detectors" && key !== "model",
);
for (const key of pendingKeys) {
const pendingData = pendingDataBySection[key];
@ -836,11 +955,8 @@ export default function Settings() {
});
if (!payload) {
// No actual overrides — clear the pending entry
setPendingDataBySection((prev) => {
const { [key]: _, ...rest } = prev;
return rest;
});
// No actual overrides — schedule the pending entry for clearing
keysToClear.push(key);
successCount++;
continue;
}
@ -859,11 +975,8 @@ export default function Settings() {
anyNeedsRestart = true;
}
// Clear pending entry on success
setPendingDataBySection((prev) => {
const { [key]: _, ...rest } = prev;
return rest;
});
// Defer clearing the pending entry until after mutate("config") resolves
keysToClear.push(key);
savedKeys.push(key);
successCount++;
} catch (error) {
@ -873,10 +986,22 @@ export default function Settings() {
}
}
// Refresh config from server once
// Refresh config from server once — must complete before clearing the
// pending entries so consumers don't observe a moment where pending is
// empty AND config is still stale
await mutate("config");
mutate("config/raw_paths");
if (keysToClear.length > 0) {
setPendingDataBySection((prev) => {
const next = { ...prev };
for (const key of keysToClear) {
delete next[key];
}
return next;
});
}
// Clear hasChanges in sidebar for all successfully saved sections
if (savedKeys.length > 0) {
setSectionStatusByKey((prev) => {
@ -900,11 +1025,12 @@ export default function Settings() {
if (failCount === 0) {
if (anyNeedsRestart) {
toast.success(
t("toast.saveAllSuccess", {
t("toast.saveAllSuccessRestartRequired", {
ns: "views/settings",
count: successCount,
}),
{
duration: 10000,
action: (
<a onClick={() => setRestartDialogOpen(true)}>
<Button>
@ -1386,10 +1512,20 @@ export default function Settings() {
CAMERA_SECTION_KEYS.has(key) && status?.isOverridden;
const showUnsavedDot = status?.hasChanges;
const dotColor =
status?.overrideSource === "profile" && activeProfileColor
? activeProfileColor.dot
: "bg-selected";
const isProfileOverride =
status?.overrideSource === "profile" && activeProfileColor;
const dotColor = isProfileOverride
? activeProfileColor.dot
: "bg-selected";
const overrideTooltip = isProfileOverride
? t("menuDot.overrideProfile", {
profile: activeEditingProfile
? (profileFriendlyNames.get(activeEditingProfile) ??
activeEditingProfile)
: "",
})
: t("menuDot.overrideGlobal");
return (
<div className="flex w-full min-w-0 items-center justify-between pr-4 md:pr-0">
@ -1399,19 +1535,46 @@ export default function Settings() {
{(showOverrideDot || showUnsavedDot) && (
<div className="ml-2 flex shrink-0 items-center gap-2">
{showOverrideDot && (
<span
className={cn("inline-block size-2 rounded-full", dotColor)}
/>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"inline-block size-2 rounded-full",
dotColor,
)}
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="right">
{overrideTooltip}
</TooltipContent>
</TooltipPortal>
</Tooltip>
)}
{showUnsavedDot && (
<span className="inline-block size-2 rounded-full bg-unsaved" />
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block size-2 rounded-full bg-unsaved" />
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="right">
{t("menuDot.unsaved")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
)}
</div>
)}
</div>
);
},
[sectionStatusByKey, t, activeProfileColor],
[
sectionStatusByKey,
t,
activeProfileColor,
activeEditingProfile,
profileFriendlyNames,
],
);
if (isMobile) {

View File

@ -7,6 +7,7 @@ export type ToolCall = {
export type ChatMessage = {
role: "user" | "assistant";
content: string;
reasoning?: string;
toolCalls?: ToolCall[];
stats?: ChatStats;
};

View File

@ -13,7 +13,19 @@ export type JsonArray = JsonValue[];
export type ConfigSectionData = JsonObject;
export type HiddenFieldEntry = string | ((config: FrigateConfig) => string[]);
export type HiddenFieldContext = {
fullConfig: FrigateConfig;
fullCameraConfig?: CameraConfig;
level: "global" | "camera" | "replay";
cameraName?: string;
// Saved form data for the current section/scope (i.e. rawFormData in
// BaseSection.tsx). Not the user's in-flight RJSF edits. Optional because
// most hidden-field callsites compute patterns without a specific section
// value on hand; resolvers fall back to fullCameraConfig / fullConfig.
formData?: ConfigSectionData;
};
export type HiddenFieldEntry = string | ((ctx: HiddenFieldContext) => string[]);
export type ConfigFormContext = {
level?: "global" | "camera";

View File

@ -522,8 +522,8 @@ export interface FrigateConfig {
path: string | null;
width: number;
colormap: { [key: string]: [number, number, number] };
attributes_map: { [key: string]: [string] };
all_attributes: [string];
attributes_map: { [key: string]: string[] };
all_attributes: string[];
plus?: {
name: string;
id: string;

View File

@ -27,6 +27,7 @@ type StreamChunk =
| { type: "error"; error: string }
| { type: "tool_calls"; tool_calls: ToolCall[] }
| { type: "content"; delta: string }
| { type: "reasoning"; delta: string }
| StatsChunk;
/**
@ -109,6 +110,19 @@ export async function streamChatCompletion(
});
return "continue";
}
if (data.type === "reasoning" && data.delta !== undefined) {
updateMessages((prev) => {
const next = [...prev];
const lastMsg = next[next.length - 1];
if (lastMsg?.role === "assistant")
next[next.length - 1] = {
...lastMsg,
reasoning: (lastMsg.reasoning ?? "") + data.delta,
};
return next;
});
return "continue";
}
if (data.type === "stats") {
const stats: ChatStats = {
promptTokens: data.prompt_tokens,

View File

@ -19,9 +19,10 @@ import {
sanitizeOverridesForSection,
} from "@/components/config-form/sections/section-special-cases";
import type { RJSFSchema } from "@rjsf/utils";
import type { FrigateConfig } from "@/types/frigateConfig";
import type { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import type {
ConfigSectionData,
HiddenFieldContext,
JsonObject,
JsonValue,
} from "@/types/configForm";
@ -568,6 +569,17 @@ export function prepareSectionSavePayload(opts: {
schemaSection,
level,
sectionSchema,
config
? {
fullConfig: config,
fullCameraConfig:
level === "camera" && cameraName
? config.cameras?.[cameraName]
: undefined,
level,
cameraName,
}
: undefined,
);
// Compute rawFormData (the current stored value for this section)
@ -615,10 +627,16 @@ export function prepareSectionSavePayload(opts: {
// For profile sections, also hide restart-required fields to match
// effectiveHiddenFields in BaseSection (prevents spurious deletion markers
// for fields that are hidden from the form during profile editing).
const resolvedHidden = resolveHiddenFieldEntries(
sectionConfig.hiddenFields,
config,
);
const resolvedHidden = resolveHiddenFieldEntries(sectionConfig.hiddenFields, {
fullConfig: config,
fullCameraConfig:
level === "camera" && cameraName
? config.cameras?.[cameraName]
: undefined,
level,
cameraName,
formData: rawFormData as ConfigSectionData,
});
const hiddenFieldsForSanitize =
profileInfo.isProfile && sectionConfig.restartRequired?.length
? [...new Set([...resolvedHidden, ...sectionConfig.restartRequired])]
@ -731,32 +749,77 @@ export function getSectionConfig(
return mergeSectionConfig(entry.base, overrides);
}
/**
* Resolve the effective attribute label set at a given scope. At camera
* (and replay) scope on a dedicated LPR camera (`camera.type === "lpr"`),
* `license_plate` is treated as a regular tracked object not an
* attribute to match the backend's per-camera carve-out in
* `frigate/video/detect.py`. Returns the full attribute list at global
* scope and for non-LPR cameras.
*/
export function getEffectiveAttributeLabels(
fullConfig: FrigateConfig | undefined,
fullCameraConfig: CameraConfig | undefined,
level: "global" | "camera" | "replay" | undefined,
): string[] {
const all = fullConfig?.model?.all_attributes ?? [];
if (level !== "global" && fullCameraConfig?.type === "lpr") {
return all.filter((attr) => attr !== "license_plate");
}
return all;
}
/**
* Build a `HiddenFieldContext` for the common case where a callsite has
* `config`, an optional `cameraName`, and a level, but no per-section
* saved form data to thread through. Resolvers that don't read `formData`
* (which is most of them) just fall through to `fullCameraConfig` /
* `fullConfig`.
*/
export function buildHiddenFieldContext(
config: FrigateConfig | undefined,
level: "global" | "camera" | "replay",
cameraName?: string,
): HiddenFieldContext | undefined {
if (!config) return undefined;
return {
fullConfig: config,
fullCameraConfig:
level !== "global" && cameraName
? config.cameras?.[cameraName]
: undefined,
level,
cameraName,
};
}
/**
* Resolve the effective hidden-field patterns for a section. Each entry in
* `hiddenFields` is either a literal pattern or a function that produces
* patterns from the loaded config (e.g. `filters.<attr>` for each
* `model.all_attributes` entry on the objects section).
* patterns from the loaded config and scope (e.g. `filters.<attr>` for each
* `model.all_attributes` entry on the objects section, gated by the
* effective `objects.track` list at the current scope).
*/
export function getEffectiveHiddenFields(
sectionKey: string,
level: "global" | "camera" | "replay",
config: FrigateConfig | undefined,
ctx: HiddenFieldContext | undefined,
): string[] {
return resolveHiddenFieldEntries(
getSectionConfig(sectionKey, level).hiddenFields,
config,
ctx,
);
}
export function resolveHiddenFieldEntries(
entries: SectionConfig["hiddenFields"] | undefined,
config: FrigateConfig | undefined,
ctx: HiddenFieldContext | undefined,
): string[] {
if (!entries || entries.length === 0) return [];
const result: string[] = [];
for (const entry of entries) {
if (typeof entry === "function") {
if (config) result.push(...entry(config));
if (ctx) result.push(...entry(ctx));
} else {
result.push(entry);
}

View File

@ -8,13 +8,23 @@ export type FfmpegAudioOption =
| "pcm"
| "mp3"
| "exclude";
export type FfmpegHardwareOption = "none" | "auto";
export type FfmpegHardwareOption =
| "none"
| "auto"
| "vaapi"
| "cuda"
| "v4l2m2m"
| "dxva2"
| "videotoolbox";
export type ParsedFfmpegUrl = {
isFfmpeg: boolean;
baseUrl: string;
video: FfmpegVideoOption;
audio: FfmpegAudioOption;
// go2rtc accepts repeatable #video=/#audio= fragments to express a fallback
// chain (copy if source codec matches, otherwise transcode). An empty array
// means no fragment is emitted for that track — equivalent to "exclude".
videos: FfmpegVideoOption[];
audios: FfmpegAudioOption[];
hardware: FfmpegHardwareOption;
extraFragments: string[];
};
@ -37,13 +47,21 @@ const HARDWARE_SPECIFIC = new Set([
"videotoolbox",
]);
function isRecognizedFragment(frag: string): boolean {
if (frag === "hardware") return true;
if (frag.startsWith("video=")) return VIDEO_VALUES.has(frag.slice(6));
if (frag.startsWith("audio=")) return AUDIO_VALUES.has(frag.slice(6));
if (frag.startsWith("hardware=")) return HARDWARE_SPECIFIC.has(frag.slice(9));
return false;
}
export function parseFfmpegUrl(url: string): ParsedFfmpegUrl {
if (!url.startsWith("ffmpeg:")) {
return {
isFfmpeg: false,
baseUrl: url,
video: "copy",
audio: "copy",
videos: [],
audios: [],
hardware: "none",
extraFragments: [],
};
@ -54,63 +72,76 @@ export function parseFfmpegUrl(url: string): ParsedFfmpegUrl {
const baseUrl = parts[0];
const fragments = parts.slice(1);
let video: FfmpegVideoOption | null = null;
let audio: FfmpegAudioOption | null = null;
const videos: FfmpegVideoOption[] = [];
const audios: FfmpegAudioOption[] = [];
let hardware: FfmpegHardwareOption = "none";
const extraFragments: string[] = [];
for (const frag of fragments) {
if (frag.startsWith("video=")) {
const val = frag.slice(6);
if (VIDEO_VALUES.has(val)) {
video = val as FfmpegVideoOption;
} else {
extraFragments.push(frag);
}
} else if (frag.startsWith("audio=")) {
const val = frag.slice(6);
if (AUDIO_VALUES.has(val)) {
audio = val as FfmpegAudioOption;
} else {
extraFragments.push(frag);
}
if (frag.startsWith("video=") && VIDEO_VALUES.has(frag.slice(6))) {
videos.push(frag.slice(6) as FfmpegVideoOption);
} else if (frag.startsWith("audio=") && AUDIO_VALUES.has(frag.slice(6))) {
audios.push(frag.slice(6) as FfmpegAudioOption);
} else if (frag === "hardware") {
hardware = "auto";
} else if (frag.startsWith("hardware=")) {
const val = frag.slice(9);
if (HARDWARE_SPECIFIC.has(val)) {
hardware = "auto";
} else {
extraFragments.push(frag);
}
} else if (
frag.startsWith("hardware=") &&
HARDWARE_SPECIFIC.has(frag.slice(9))
) {
hardware = frag.slice(9) as FfmpegHardwareOption;
} else {
extraFragments.push(frag);
}
}
const hasAnyKnownFragment = video !== null || audio !== null;
return {
isFfmpeg: true,
baseUrl,
video: video ?? (hasAnyKnownFragment ? "exclude" : "copy"),
audio: audio ?? (hasAnyKnownFragment ? "exclude" : "copy"),
// Guarantee at least one row per track so the UI always has a primary
// dropdown to render; "exclude" is the sentinel meaning "no fragment".
videos: videos.length > 0 ? videos : ["exclude"],
audios: audios.length > 0 ? audios : ["exclude"],
hardware,
extraFragments,
};
}
// Splits the editable "base URL + extra fragments" portion of a compat-mode
// URL into its parts. Recognized fragments (video=, audio=, hardware) are
// dropped — they are managed by the dedicated controls in the UI.
export function parseFfmpegBaseAndExtras(input: string): {
baseUrl: string;
extraFragments: string[];
} {
const cleaned = input.startsWith("ffmpeg:") ? input.slice(7) : input;
const parts = cleaned.split("#");
const baseUrl = parts[0];
const extraFragments = parts.slice(1).filter((f) => !isRecognizedFragment(f));
return { baseUrl, extraFragments };
}
export function buildFfmpegUrl(parsed: ParsedFfmpegUrl): string {
let url = `ffmpeg:${parsed.baseUrl}`;
if (parsed.video !== "exclude") {
url += `#video=${parsed.video}`;
// Exclude is a primary-row sentinel meaning "no fragment for this track" —
// it's mutually exclusive with fallbacks. If the primary is exclude, emit
// nothing for that track regardless of trailing entries.
if (parsed.videos[0] !== "exclude") {
for (const v of parsed.videos) {
if (v === "exclude") continue;
url += `#video=${v}`;
}
}
if (parsed.audio !== "exclude") {
url += `#audio=${parsed.audio}`;
if (parsed.audios[0] !== "exclude") {
for (const a of parsed.audios) {
if (a === "exclude") continue;
url += `#audio=${a}`;
}
}
if (parsed.hardware === "auto") {
url += "#hardware";
} else if (parsed.hardware !== "none") {
url += `#hardware=${parsed.hardware}`;
}
for (const frag of parsed.extraFragments) {
url += `#${frag}`;
@ -131,7 +162,9 @@ export function toggleFfmpegMode(url: string, enable: boolean): string {
return url;
}
const withoutPrefix = url.slice(7);
const baseUrl = withoutPrefix.split("#")[0];
return baseUrl;
// Preserve unknown fragments (e.g. #timeout=10) when leaving compat mode;
// only video/audio/hardware are go2rtc-ffmpeg directives that should be
// dropped along with the prefix.
const parsed = parseFfmpegUrl(url);
return [parsed.baseUrl, ...parsed.extraFragments].join("#");
}

View File

@ -1,5 +1,5 @@
import Heading from "@/components/ui/heading";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
CONTROL_COLUMN_CLASS_NAME,
SettingsGroupCard,
@ -14,7 +14,15 @@ import { useTranslation } from "react-i18next";
import CameraEditForm from "@/components/settings/CameraEditForm";
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog";
import { LuExternalLink, LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
import {
LuCheck,
LuExternalLink,
LuGripVertical,
LuPencil,
LuPlus,
LuTrash2,
} from "react-icons/lu";
import { Reorder, useDragControls } from "framer-motion";
import { IoMdArrowRoundBack } from "react-icons/io";
import { Link } from "react-router-dom";
import { useDocDomain } from "@/hooks/use-doc-domain";
@ -45,6 +53,10 @@ import {
SelectValue,
} from "@/components/ui/select";
const REORDER_SAVED_INDICATOR_MS = 1500;
type ReorderSaveStatus = "idle" | "saving" | "saved";
type CameraManagementViewProps = {
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
profileState?: ProfileState;
@ -54,7 +66,7 @@ export default function CameraManagementView({
setUnsavedChanges,
profileState,
}: CameraManagementViewProps) {
const { t } = useTranslation(["views/settings"]);
const { t } = useTranslation(["views/settings", "common"]);
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
@ -72,16 +84,99 @@ export default function CameraManagementView({
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const { send: sendRestart } = useRestart();
// List of cameras for dropdown
const enabledCameras = useMemo(() => {
if (config) {
return Object.keys(config.cameras)
.filter((camera) => config.cameras[camera].enabled_in_config)
.sort();
.sort((a, b) => {
const orderA = config.cameras[a].ui?.order ?? 0;
const orderB = config.cameras[b].ui?.order ?? 0;
if (orderA !== orderB) return orderA - orderB;
return a.localeCompare(b);
});
}
return [];
}, [config]);
// Diverges from config during a drag and while the save is in flight.
const [orderedCameras, setOrderedCameras] =
useState<string[]>(enabledCameras);
const orderedCamerasRef = useRef(orderedCameras);
useEffect(() => {
orderedCamerasRef.current = orderedCameras;
}, [orderedCameras]);
useEffect(() => {
setOrderedCameras((prev) => {
if (
prev.length === enabledCameras.length &&
prev.every((cam, i) => cam === enabledCameras[i])
) {
return prev;
}
return enabledCameras;
});
}, [enabledCameras]);
const [reorderSaveStatus, setReorderSaveStatus] =
useState<ReorderSaveStatus>("idle");
const reorderSavedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
useEffect(() => {
return () => {
if (reorderSavedTimerRef.current) {
clearTimeout(reorderSavedTimerRef.current);
}
};
}, []);
const handleReorderDragEnd = useCallback(async () => {
const current = orderedCamerasRef.current;
if (
current.length === enabledCameras.length &&
current.every((cam, i) => cam === enabledCameras[i])
) {
return;
}
const cameraUpdates: Record<string, { ui: { order: number } }> = {};
current.forEach((cam, i) => {
cameraUpdates[cam] = { ui: { order: i * 10 } };
});
if (reorderSavedTimerRef.current) {
clearTimeout(reorderSavedTimerRef.current);
reorderSavedTimerRef.current = null;
}
setReorderSaveStatus("saving");
try {
await axios.put("config/set", {
requires_restart: 0,
config_data: { cameras: cameraUpdates },
});
await updateConfig();
setReorderSaveStatus("saved");
reorderSavedTimerRef.current = setTimeout(() => {
setReorderSaveStatus("idle");
reorderSavedTimerRef.current = null;
}, REORDER_SAVED_INDICATOR_MS);
} catch (error) {
setOrderedCameras(enabledCameras);
setReorderSaveStatus("idle");
const errorMessage =
axios.isAxiosError(error) &&
(error.response?.data?.message || error.response?.data?.detail)
? error.response?.data?.message || error.response?.data?.detail
: t("toast.save.error.noMessage", { ns: "common" });
toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), {
position: "top-center",
});
}
}, [enabledCameras, updateConfig, t]);
const disabledCameras = useMemo(() => {
if (config) {
return Object.keys(config.cameras)
@ -173,22 +268,26 @@ export default function CameraManagementView({
</p>
</Label>
</div>
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
{enabledCameras.map((camera) => (
<div
key={camera}
className="flex flex-row items-center justify-between"
>
<div className="flex items-center gap-1">
<CameraNameLabel camera={camera} />
<CameraFriendlyNameEditor
cameraName={camera}
onConfigChanged={updateConfig}
/>
</div>
<CameraEnableSwitch cameraName={camera} />
</div>
))}
<div className="max-w-md space-y-1.5">
<Reorder.Group
as="div"
axis="y"
values={orderedCameras}
onReorder={setOrderedCameras}
className="space-y-2 rounded-lg bg-secondary p-4"
>
{orderedCameras.map((camera) => (
<EnabledCameraRow
key={camera}
camera={camera}
onConfigChanged={updateConfig}
onDragEnd={handleReorderDragEnd}
/>
))}
</Reorder.Group>
<ReorderSaveStatusIndicator
status={reorderSaveStatus}
/>
</div>
<p className="text-sm text-muted-foreground md:hidden">
<Trans ns="views/settings">
@ -309,6 +408,80 @@ export default function CameraManagementView({
);
}
type ReorderSaveStatusIndicatorProps = {
status: ReorderSaveStatus;
};
function ReorderSaveStatusIndicator({
status,
}: ReorderSaveStatusIndicatorProps) {
const { t } = useTranslation(["views/settings"]);
return (
<div
aria-live="polite"
className={cn(
"flex h-4 items-center justify-start gap-1 text-xs transition-opacity duration-200",
status === "idle" ? "opacity-0" : "opacity-100",
)}
>
{status === "saving" && (
<span className="text-muted-foreground">
{t("cameraManagement.streams.saving")}
</span>
)}
{status === "saved" && (
<span className="flex items-center gap-1 text-success">
<LuCheck className="size-3.5" />
{t("cameraManagement.streams.saved")}
</span>
)}
</div>
);
}
type EnabledCameraRowProps = {
camera: string;
onConfigChanged: () => Promise<unknown>;
onDragEnd: () => void;
};
function EnabledCameraRow({
camera,
onConfigChanged,
onDragEnd,
}: EnabledCameraRowProps) {
const { t } = useTranslation(["views/settings"]);
const controls = useDragControls();
return (
<Reorder.Item
as="div"
value={camera}
dragListener={false}
dragControls={controls}
onDragEnd={onDragEnd}
className="flex flex-row items-center justify-between"
>
<div className="flex items-center gap-1">
<button
type="button"
onPointerDown={(e) => controls.start(e)}
className="-ml-1 cursor-grab touch-none rounded p-1 text-muted-foreground hover:text-primary active:cursor-grabbing"
aria-label={t("cameraManagement.streams.reorderHandle")}
>
<LuGripVertical className="size-4" />
</button>
<CameraNameLabel camera={camera} />
<CameraFriendlyNameEditor
cameraName={camera}
onConfigChanged={onConfigChanged}
/>
</div>
<CameraEnableSwitch cameraName={camera} />
</Reorder.Item>
);
}
type CameraEnableSwitchProps = {
cameraName: string;
};

View File

@ -50,6 +50,12 @@ import {
import { ConfigSectionTemplate } from "@/components/config-form/sections";
import { ConfigMessageBanner } from "@/components/config-form/ConfigMessageBanner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
buildHiddenFieldContext,
getSectionConfig,
resolveHiddenFieldEntries,
sanitizeSectionData,
} from "@/utils/configUtil";
type ModelTab = "plus" | "custom";
@ -107,6 +113,8 @@ const TYPE_MODEL_DEFAULTS: Record<string, ConfigSectionData> = {
const STATUS_BAR_KEY = "detectors_and_model";
const EMPTY_PENDING: Record<string, ConfigSectionData> = {};
const deriveInitialState = (config: FrigateConfig): PageState => {
const plusModelId = config.model?.plus?.id;
const modelPath = config.model?.path;
@ -150,6 +158,11 @@ const deriveInitialState = (config: FrigateConfig): PageState => {
export default function DetectorsAndModelSettingsView({
setUnsavedChanges,
pendingDataBySection,
onPendingDataChange,
onSectionStatusChange,
isSavingAll,
onSectionSavingChange,
}: SettingsPageProps) {
const { t } = useTranslation(["views/settings", "common"]);
const { getLocaleDocUrl } = useDocDomain();
@ -157,15 +170,17 @@ export default function DetectorsAndModelSettingsView({
const { mutate: globalMutate } = useSWRConfig();
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
const [snapshot, setSnapshot] = useState<PageState | null>(null);
// track the saved config
const snapshot = useMemo<PageState | null>(
() => (config ? deriveInitialState(config) : null),
[config],
);
const [state, setState] = useState<PageState | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [resetKey, setResetKey] = useState(0);
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const { send: sendRestart } = useRestart();
const [childPending, setChildPending] = useState<
Record<string, ConfigSectionData>
>({});
const childPending = pendingDataBySection ?? EMPTY_PENDING;
const [detectorStatus, setDetectorStatus] = useState<SectionStatus>({
hasChanges: false,
isOverridden: false,
@ -208,13 +223,38 @@ export default function DetectorsAndModelSettingsView({
const isFilterActive = !showBaseModels || !showFineTunedModels;
const detectorHiddenFields = useMemo(
() =>
resolveHiddenFieldEntries(
getSectionConfig("detectors", "global").hiddenFields,
buildHiddenFieldContext(config, "global"),
),
[config],
);
const modelHiddenFields = useMemo(
() =>
resolveHiddenFieldEntries(
getSectionConfig("model", "global").hiddenFields,
buildHiddenFieldContext(config, "global"),
),
[config],
);
const liveDetectors = useMemo(
() => childPending["detectors"] ?? snapshot?.detectors,
[childPending, snapshot],
);
const liveCustomModel = useMemo(
() => childPending["model"] ?? snapshot?.customModel,
[childPending, snapshot],
);
const currentDetectorType = useMemo(() => {
if (!state) return undefined;
const values = Object.values(state.detectors ?? {});
const values = Object.values(liveDetectors ?? {});
if (values.length === 0) return undefined;
const first = values[0] as { type?: string } | undefined;
return first?.type;
}, [state]);
}, [liveDetectors]);
// fill in defaults when detector type changes
const prevDetectorTypeRef = useRef<string | undefined>(undefined);
@ -226,28 +266,25 @@ export default function DetectorsAndModelSettingsView({
if (!newType || !(newType in TYPE_MODEL_DEFAULTS)) return;
const defaults = TYPE_MODEL_DEFAULTS[newType];
setChildPending((prev) => {
const next: Record<string, ConfigSectionData> = {
...prev,
model: defaults,
onPendingDataChange?.("model", undefined, defaults);
if (newType === "openvino") {
const detectorsCurrent = (childPending.detectors ??
state?.detectors ??
{}) as {
[key: string]: { device?: string };
};
if (newType === "openvino") {
const detectorsCurrent = (prev.detectors ?? state?.detectors ?? {}) as {
[key: string]: { device?: string };
};
const entries = Object.entries(detectorsCurrent);
if (entries.length > 0) {
const [firstKey, firstValue] = entries[0];
if (!firstValue?.device) {
next.detectors = {
...detectorsCurrent,
[firstKey]: { ...firstValue, device: "CPU" },
} as ConfigSectionData;
}
const entries = Object.entries(detectorsCurrent);
if (entries.length > 0) {
const [firstKey, firstValue] = entries[0];
if (!firstValue?.device) {
onPendingDataChange?.("detectors", undefined, {
...detectorsCurrent,
[firstKey]: { ...firstValue, device: "CPU" },
} as ConfigSectionData);
}
}
return next;
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentDetectorType]);
@ -271,72 +308,121 @@ export default function DetectorsAndModelSettingsView({
const plusModelMissing = state?.modelTab === "plus" && !state?.plusModelId;
const handleChildPendingChange = useCallback(
(
sectionKey: string,
_cameraName: string | undefined,
data: ConfigSectionData | null,
) => {
setChildPending((prev) => {
if (data === null) {
if (!(sectionKey in prev)) return prev;
const { [sectionKey]: _drop, ...rest } = prev;
return rest;
}
return { ...prev, [sectionKey]: data };
});
},
[],
);
const handleDetectorStatusChange = useCallback(
(status: SectionStatus) => setDetectorStatus(status),
[],
(status: SectionStatus) => {
setDetectorStatus(status);
onSectionStatusChange?.("detectors", "global", status);
},
[onSectionStatusChange],
);
// BaseSection drives `modelStatus` only when the Custom tab is mounted
const handleModelStatusChange = useCallback(
(status: SectionStatus) => setModelStatus(status),
[],
);
// report the *combined* model-section status to the parent
useEffect(() => {
const detectorsPending = childPending["detectors"];
setState((prev) => {
if (!prev || !snapshot) return prev;
// When the embedded form un-modifies (data returns to baseline) it clears
// its entry from childPending — fall back to snapshot so state.detectors
// doesn't keep a stale value the user has visually reverted.
return {
...prev,
detectors: detectorsPending ?? snapshot.detectors,
};
if (!state || !snapshot) return;
const tabChanged = state.modelTab !== snapshot.modelTab;
const plusIdChanged =
state.modelTab === "plus" && state.plusModelId !== snapshot.plusModelId;
const pageLevelDirty = tabChanged || plusIdChanged;
onSectionStatusChange?.("model", "global", {
hasChanges: modelStatus.hasChanges || pageLevelDirty,
isOverridden: modelStatus.isOverridden,
overrideSource: modelStatus.overrideSource,
hasValidationErrors: modelStatus.hasValidationErrors,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [childPending["detectors"]]);
}, [state, snapshot, modelStatus, onSectionStatusChange]);
// Tab toggle and Plus-model selection are page-local UI, but Save All and the
// sidebar dot live on `pendingDataBySection["model"]` and section status from
// the parent. These handlers mirror Plus-tab changes into both so a Plus-only
// edit (no custom-form typing) is still dirty and survives navigation.
const handleModelTabChange = useCallback(
(newTab: ModelTab) => {
setState((prev) => (prev ? { ...prev, modelTab: newTab } : prev));
if (!snapshot) return;
if (newTab === "plus") {
if (state?.plusModelId) {
onPendingDataChange?.("model", undefined, {
path: `plus://${state.plusModelId}`,
} as ConfigSectionData);
} else {
// No Plus model selected — clear any stale pending so the save
// action is correctly disabled until the user picks one.
onPendingDataChange?.("model", undefined, null);
}
} else {
// Switching to Custom: if pending["model"] still holds a plus path
// from a previous Plus selection, swap it for the snapshot's custom
// model so Save All writes the correct payload. Don't overwrite
// genuine custom-form edits the user typed earlier.
const currentPath = (
pendingDataBySection?.["model"] as { path?: string } | undefined
)?.path;
if (
typeof currentPath === "string" &&
currentPath.startsWith("plus://")
) {
onPendingDataChange?.(
"model",
undefined,
snapshot.customModel as ConfigSectionData,
);
}
}
},
[state?.plusModelId, snapshot, pendingDataBySection, onPendingDataChange],
);
const handlePlusModelIdChange = useCallback(
(newId: string) => {
setState((prev) => (prev ? { ...prev, plusModelId: newId } : prev));
onPendingDataChange?.("model", undefined, {
path: `plus://${newId}`,
} as ConfigSectionData);
},
[onPendingDataChange],
);
useEffect(() => {
const modelPending = childPending["model"];
setState((prev) => {
if (!prev || !snapshot) return prev;
return {
...prev,
customModel: modelPending ?? snapshot.customModel,
};
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [childPending["model"]]);
useEffect(() => {
if (!config || snapshot !== null) return;
if (!config || state !== null) return;
const initial = deriveInitialState(config);
setSnapshot(initial);
setState(initial);
}, [config, snapshot]);
// Restore Plus-tab UI state from any prior pending edits the user made
// before navigating away. `pendingDataBySection["model"]` is the source of
// truth for Save All; infer modelTab/plusModelId from it so the UI lines up.
const pendingModel = pendingDataBySection?.["model"] as
| { path?: string }
| undefined;
const pendingPath = pendingModel?.path;
if (typeof pendingPath === "string" && pendingPath.startsWith("plus://")) {
setState({
...initial,
modelTab: "plus",
plusModelId: pendingPath.slice("plus://".length) || undefined,
});
} else if (pendingModel && initial.modelTab === "plus") {
// There's a pending custom-model edit while the saved tab was Plus —
// means the user already switched to Custom before navigating away.
setState({ ...initial, modelTab: "custom" });
} else {
setState(initial);
}
}, [config, state, pendingDataBySection]);
const isDirty = useMemo(() => {
if (!state || !snapshot) return false;
return JSON.stringify(state) !== JSON.stringify(snapshot);
}, [state, snapshot]);
if (state.modelTab !== snapshot.modelTab) return true;
if (state.plusModelId !== snapshot.plusModelId) return true;
if ("detectors" in childPending) return true;
if ("model" in childPending) return true;
return false;
}, [state, snapshot, childPending]);
useEffect(() => {
if (isDirty) {
@ -362,24 +448,38 @@ export default function DetectorsAndModelSettingsView({
const tabChanged = state.modelTab !== snapshot.modelTab;
// Strip computed/merged fields that the backend populates in /config
// responses but doesn't accept back on /config/set.
const sanitizedDetectors = sanitizeSectionData(
liveDetectors ?? {},
detectorHiddenFields,
);
const sanitizedCustomModel = sanitizeSectionData(
liveCustomModel ?? {},
modelHiddenFields,
);
const modelPayload =
state.modelTab === "plus"
? { path: `plus://${state.plusModelId}` }
: state.customModel;
: sanitizedCustomModel;
const detectorKeysChanged =
JSON.stringify(Object.keys(state.detectors).sort()) !==
JSON.stringify(Object.keys(liveDetectors ?? {}).sort()) !==
JSON.stringify(Object.keys(snapshot.detectors).sort());
setIsSaving(true);
onSectionSavingChange?.(true);
let preCleared = false;
try {
// Pre-clear both `detectors` and `model` together when a renaming
// Pre-clear both `detectors` and `model` together when renaming
if (tabChanged || detectorKeysChanged) {
try {
await axios.put("config/set", {
requires_restart: 0,
config_data: { detectors: null, model: null },
});
preCleared = true;
} catch {
// best-effort cleanup
}
@ -388,7 +488,7 @@ export default function DetectorsAndModelSettingsView({
await axios.put("config/set", {
requires_restart: 0,
config_data: {
detectors: state.detectors,
detectors: sanitizedDetectors,
model: modelPayload,
},
});
@ -396,9 +496,11 @@ export default function DetectorsAndModelSettingsView({
await globalMutate("config");
await globalMutate("config/raw_paths");
// Re-derive snapshot from the freshly saved state so isDirty resets.
setSnapshot({ ...state });
setChildPending({});
// `snapshot` is derived from `config` via useMemo, so the awaited mutate
// above has already refreshed it. Just clear the pending entries — that
// resets isDirty since state should now match snapshot.
onPendingDataChange?.("detectors", undefined, null);
onPendingDataChange?.("model", undefined, null);
setResetKey((k) => k + 1);
addMessage(
@ -410,6 +512,7 @@ export default function DetectorsAndModelSettingsView({
toast.success(t("detectorsAndModel.toast.saveSuccess"), {
position: "top-center",
duration: 10000,
action: (
<Button onClick={() => setRestartDialogOpen(true)}>
{t("restart.button", { ns: "components/dialog" })}
@ -425,24 +528,60 @@ export default function DetectorsAndModelSettingsView({
err.response?.data?.detail ||
t("detectorsAndModel.toast.saveError");
toast.error(message, { position: "top-center" });
// Re-sync the config cache in case the two-step PUT left the backend
// ahead of the frontend (e.g. step 1 cleared `model` but step 2 failed).
if (preCleared) {
const restoreModel =
snapshot.modelTab === "plus" && snapshot.plusModelId
? { path: `plus://${snapshot.plusModelId}` }
: sanitizeSectionData(snapshot.customModel, modelHiddenFields);
try {
await axios.put("config/set", {
requires_restart: 0,
config_data: {
detectors: sanitizeSectionData(
snapshot.detectors,
detectorHiddenFields,
),
model: restoreModel,
},
});
} catch {
// best-effort
}
}
// Re-sync the config cache to reflect whatever state the backend
// landed on after the failure (and any restore attempt).
await globalMutate("config");
} finally {
setIsSaving(false);
onSectionSavingChange?.(false);
}
}, [state, snapshot, globalMutate, addMessage, t]);
}, [
state,
snapshot,
liveDetectors,
liveCustomModel,
detectorHiddenFields,
modelHiddenFields,
globalMutate,
onSectionSavingChange,
addMessage,
onPendingDataChange,
t,
]);
const onUndo = useCallback(() => {
if (snapshot) {
setState(snapshot);
setChildPending({});
onPendingDataChange?.("detectors", undefined, null);
onPendingDataChange?.("model", undefined, null);
// Force the embedded forms to re-mount so their internal dirty/baseline
// state is rebuilt from the current config — clearing childPending alone
// state is rebuilt from the current config — clearing pending alone
// doesn't reset BaseSection's internal tracking.
setResetKey((k) => k + 1);
}
}, [snapshot]);
}, [snapshot, onPendingDataChange]);
if (!config || !state) {
return <ActivityIndicator />;
@ -451,6 +590,7 @@ export default function DetectorsAndModelSettingsView({
const saveDisabled =
!isDirty ||
isSaving ||
isSavingAll ||
detectorStatus.hasValidationErrors ||
(state.modelTab === "custom" && modelStatus.hasValidationErrors) ||
plusMismatch ||
@ -497,7 +637,7 @@ export default function DetectorsAndModelSettingsView({
showTitle={false}
embedded
pendingDataBySection={childPending}
onPendingDataChange={handleChildPendingChange}
onPendingDataChange={onPendingDataChange}
onStatusChange={handleDetectorStatusChange}
/>
</SettingsGroupCard>
@ -522,9 +662,7 @@ export default function DetectorsAndModelSettingsView({
<Tabs
value={state.modelTab}
onValueChange={(value) =>
setState((prev) =>
prev ? { ...prev, modelTab: value as ModelTab } : prev,
)
handleModelTabChange(value as ModelTab)
}
>
<TabsList className="mb-4">
@ -548,11 +686,7 @@ export default function DetectorsAndModelSettingsView({
<div className="flex w-full items-center gap-2">
<Select
value={state.plusModelId}
onValueChange={(value) =>
setState((prev) =>
prev ? { ...prev, plusModelId: value } : prev,
)
}
onValueChange={handlePlusModelIdChange}
>
<SelectTrigger className="w-full">
{state.plusModelId &&
@ -712,7 +846,7 @@ export default function DetectorsAndModelSettingsView({
showTitle={false}
embedded
pendingDataBySection={childPending}
onPendingDataChange={handleChildPendingChange}
onPendingDataChange={onPendingDataChange}
onStatusChange={handleModelStatusChange}
/>
</TabsContent>
@ -726,7 +860,7 @@ export default function DetectorsAndModelSettingsView({
showTitle={false}
embedded
pendingDataBySection={childPending}
onPendingDataChange={handleChildPendingChange}
onPendingDataChange={onPendingDataChange}
onStatusChange={handleModelStatusChange}
/>
)}

View File

@ -10,15 +10,21 @@ import {
LuEye,
LuEyeOff,
LuPencil,
LuPlus,
LuCirclePlus,
LuSlidersHorizontal,
LuTrash2,
LuX,
} from "react-icons/lu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Link } from "react-router-dom";
import Heading from "@/components/ui/heading";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent } from "@/components/ui/card";
import {
Collapsible,
@ -62,11 +68,13 @@ import {
} from "@/utils/credentialMask";
import {
parseFfmpegUrl,
parseFfmpegBaseAndExtras,
buildFfmpegUrl,
toggleFfmpegMode,
type FfmpegVideoOption,
type FfmpegAudioOption,
type FfmpegHardwareOption,
type ParsedFfmpegUrl,
} from "@/utils/go2rtcFfmpeg";
type RawPathsResponse = {
@ -365,7 +373,7 @@ export default function Go2RtcStreamsSettingsView({
variant="outline"
className="my-4"
>
<LuPlus className="mr-2 size-4" />
<LuCirclePlus className="mr-2 size-4" />
{t("go2rtcStreams.addStream")}
</Button>
</div>
@ -703,7 +711,7 @@ function StreamCard({
</div>
</div>
<CollapsibleContent>
<div className="space-y-3 px-4 pb-4">
<div className="space-y-2 px-4 pb-4">
{urls.map((url, urlIndex) => (
<StreamUrlEntry
key={urlIndex}
@ -728,7 +736,7 @@ function StreamCard({
onClick={onAddUrl}
className="w-fit"
>
<LuPlus className="mr-2 size-4" />
<LuCirclePlus className="mr-2 size-4" />
{t("go2rtcStreams.addUrl")}
</Button>
</div>
@ -764,7 +772,9 @@ function StreamUrlEntry({
const [isFocused, setIsFocused] = useState(false);
const parsed = useMemo(() => parseFfmpegUrl(url), [url]);
const rawBaseUrl = parsed.isFfmpeg ? parsed.baseUrl : url;
const rawBaseUrl = parsed.isFfmpeg
? [parsed.baseUrl, ...parsed.extraFragments].join("#")
: url;
const canToggleCredentials =
hasCredentials(rawBaseUrl) && !isMaskedPath(rawBaseUrl);
@ -778,15 +788,16 @@ function StreamUrlEntry({
}, [rawBaseUrl, showCredentials, isFocused]);
const isTranscodingVideo =
parsed.isFfmpeg && parsed.video !== "copy" && parsed.video !== "exclude";
parsed.isFfmpeg && parsed.videos.some((v) => v === "h264" || v === "h265");
const handleBaseUrlChange = useCallback(
(newBaseUrl: string) => {
(newInput: string) => {
if (parsed.isFfmpeg) {
const newUrl = buildFfmpegUrl({ ...parsed, baseUrl: newBaseUrl });
const { baseUrl, extraFragments } = parseFfmpegBaseAndExtras(newInput);
const newUrl = buildFfmpegUrl({ ...parsed, baseUrl, extraFragments });
onUpdateUrl(streamName, urlIndex, newUrl);
} else {
onUpdateUrl(streamName, urlIndex, newBaseUrl);
onUpdateUrl(streamName, urlIndex, newInput);
}
},
[parsed, streamName, urlIndex, onUpdateUrl],
@ -800,212 +811,328 @@ function StreamUrlEntry({
[url, streamName, urlIndex, onUpdateUrl],
);
const handleFfmpegOptionChange = useCallback(
(
field: "video" | "audio" | "hardware",
value: FfmpegVideoOption | FfmpegAudioOption | FfmpegHardwareOption,
) => {
const updated = { ...parsed, [field]: value };
// Clear hardware when switching away from transcoding video
if (field === "video" && (value === "copy" || value === "exclude")) {
updated.hardware = "none";
const persistFfmpeg = useCallback(
(next: Partial<ParsedFfmpegUrl>) => {
const merged = { ...parsed, ...next };
// Hardware acceleration is meaningless without a transcoding video codec
if (!merged.videos.some((v) => v === "h264" || v === "h265")) {
merged.hardware = "none";
}
const newUrl = buildFfmpegUrl(updated);
onUpdateUrl(streamName, urlIndex, newUrl);
onUpdateUrl(streamName, urlIndex, buildFfmpegUrl(merged));
},
[parsed, streamName, urlIndex, onUpdateUrl],
);
const audioDisplayLabel = useMemo(() => {
const labels: Record<string, string> = {
copy: t("go2rtcStreams.ffmpeg.audioCopy"),
aac: t("go2rtcStreams.ffmpeg.audioAac"),
opus: t("go2rtcStreams.ffmpeg.audioOpus"),
pcmu: t("go2rtcStreams.ffmpeg.audioPcmu"),
pcma: t("go2rtcStreams.ffmpeg.audioPcma"),
pcm: t("go2rtcStreams.ffmpeg.audioPcm"),
mp3: t("go2rtcStreams.ffmpeg.audioMp3"),
exclude: t("go2rtcStreams.ffmpeg.audioExclude"),
};
return labels[parsed.audio] || parsed.audio;
}, [parsed.audio, t]);
const updateVideoAt = useCallback(
(idx: number, value: FfmpegVideoOption) => {
// Picking exclude on the primary row drops any existing fallbacks —
// they have no meaning when the track is excluded entirely.
const videos =
idx === 0 && value === "exclude"
? ["exclude" as FfmpegVideoOption]
: parsed.videos.map((v, i) => (i === idx ? value : v));
persistFfmpeg({ videos });
},
[parsed.videos, persistFfmpeg],
);
const addVideo = useCallback(() => {
persistFfmpeg({ videos: [...parsed.videos, "copy"] });
}, [parsed.videos, persistFfmpeg]);
const removeVideoAt = useCallback(
(idx: number) => {
persistFfmpeg({ videos: parsed.videos.filter((_, i) => i !== idx) });
},
[parsed.videos, persistFfmpeg],
);
const updateAudioAt = useCallback(
(idx: number, value: FfmpegAudioOption) => {
// Picking exclude on the primary row drops any existing fallbacks —
// they have no meaning when the track is excluded entirely.
const audios =
idx === 0 && value === "exclude"
? ["exclude" as FfmpegAudioOption]
: parsed.audios.map((a, i) => (i === idx ? value : a));
persistFfmpeg({ audios });
},
[parsed.audios, persistFfmpeg],
);
const addAudio = useCallback(() => {
persistFfmpeg({ audios: [...parsed.audios, "copy"] });
}, [parsed.audios, persistFfmpeg]);
const removeAudioAt = useCallback(
(idx: number) => {
persistFfmpeg({ audios: parsed.audios.filter((_, i) => i !== idx) });
},
[parsed.audios, persistFfmpeg],
);
const updateHardware = useCallback(
(value: FfmpegHardwareOption) => {
persistFfmpeg({ hardware: value });
},
[persistFfmpeg],
);
const videoLabels: Record<FfmpegVideoOption, string> = {
copy: t("go2rtcStreams.ffmpeg.videoCopy"),
h264: t("go2rtcStreams.ffmpeg.videoH264"),
h265: t("go2rtcStreams.ffmpeg.videoH265"),
exclude: t("go2rtcStreams.ffmpeg.videoExclude"),
};
const audioLabels: Record<FfmpegAudioOption, string> = {
copy: t("go2rtcStreams.ffmpeg.audioCopy"),
aac: t("go2rtcStreams.ffmpeg.audioAac"),
opus: t("go2rtcStreams.ffmpeg.audioOpus"),
pcmu: t("go2rtcStreams.ffmpeg.audioPcmu"),
pcma: t("go2rtcStreams.ffmpeg.audioPcma"),
pcm: t("go2rtcStreams.ffmpeg.audioPcm"),
mp3: t("go2rtcStreams.ffmpeg.audioMp3"),
exclude: t("go2rtcStreams.ffmpeg.audioExclude"),
};
const hardwareLabels: Record<FfmpegHardwareOption, string> = {
none: t("go2rtcStreams.ffmpeg.hardwareNone"),
auto: t("go2rtcStreams.ffmpeg.hardwareAuto"),
vaapi: t("go2rtcStreams.ffmpeg.hardwareVaapi"),
cuda: t("go2rtcStreams.ffmpeg.hardwareCuda"),
v4l2m2m: t("go2rtcStreams.ffmpeg.hardwareV4l2m2m"),
dxva2: t("go2rtcStreams.ffmpeg.hardwareDxva2"),
videotoolbox: t("go2rtcStreams.ffmpeg.hardwareVideotoolbox"),
};
return (
<div className="space-y-2 rounded-lg bg-background p-3">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
className="text-md h-8 pr-10"
value={baseUrlForDisplay}
onChange={(e) => handleBaseUrlChange(e.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={t("go2rtcStreams.streamUrlPlaceholder")}
/>
{canToggleCredentials && (
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={onToggleCredentialVisibility}
>
{showCredentials ? (
<LuEyeOff className="size-4" />
) : (
<LuEye className="size-4" />
)}
</Button>
)}
</div>
<div className="pb-4">
<div className="flex h-7 flex-row items-center justify-start gap-2 text-sm text-primary-variant">
{t("go2rtcStreams.streamNumber", { index: urlIndex + 1 })}
{canRemove && (
<Button
variant="ghost"
size="sm"
onClick={onRemoveUrl}
className="text-secondary-foreground hover:text-secondary-foreground"
className="size-7 p-0 text-secondary-foreground hover:text-secondary-foreground"
aria-label={t("button.delete", { ns: "common" })}
>
<LuTrash2 className="size-4" />
</Button>
)}
</div>
{/* ffmpeg module toggle */}
<div className="flex items-center space-x-2">
<Switch
checked={parsed.isFfmpeg}
onCheckedChange={handleFfmpegToggle}
/>
<Label className="text-sm">
{t("go2rtcStreams.ffmpeg.useFfmpegModule")}
</Label>
</div>
{/* ffmpeg options */}
{parsed.isFfmpeg && (
<div
className={cn(
"grid grid-cols-1 gap-3 pl-4",
isTranscodingVideo ? "sm:grid-cols-3" : "sm:grid-cols-2",
)}
>
{/* Video */}
<div className="space-y-1">
<Label className="text-xs font-medium">
{t("go2rtcStreams.ffmpeg.video")}
</Label>
<Select
value={parsed.video}
onValueChange={(v) =>
handleFfmpegOptionChange("video", v as FfmpegVideoOption)
}
>
<SelectTrigger className="h-8">
{parsed.video === "copy"
? t("go2rtcStreams.ffmpeg.videoCopy")
: parsed.video === "h264"
? t("go2rtcStreams.ffmpeg.videoH264")
: parsed.video === "h265"
? t("go2rtcStreams.ffmpeg.videoH265")
: t("go2rtcStreams.ffmpeg.videoExclude")}
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="copy">
{t("go2rtcStreams.ffmpeg.videoCopy")}
</SelectItem>
<SelectItem value="h264">
{t("go2rtcStreams.ffmpeg.videoH264")}
</SelectItem>
<SelectItem value="h265">
{t("go2rtcStreams.ffmpeg.videoH265")}
</SelectItem>
<SelectItem value="exclude">
{t("go2rtcStreams.ffmpeg.videoExclude")}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
{/* Audio */}
<div className="space-y-1">
<Label className="text-xs font-medium">
{t("go2rtcStreams.ffmpeg.audio")}
</Label>
<Select
value={parsed.audio}
onValueChange={(v) =>
handleFfmpegOptionChange("audio", v as FfmpegAudioOption)
}
>
<SelectTrigger className="h-8">{audioDisplayLabel}</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="copy">
{t("go2rtcStreams.ffmpeg.audioCopy")}
</SelectItem>
<SelectItem value="aac">
{t("go2rtcStreams.ffmpeg.audioAac")}
</SelectItem>
<SelectItem value="opus">
{t("go2rtcStreams.ffmpeg.audioOpus")}
</SelectItem>
<SelectItem value="pcmu">
{t("go2rtcStreams.ffmpeg.audioPcmu")}
</SelectItem>
<SelectItem value="pcma">
{t("go2rtcStreams.ffmpeg.audioPcma")}
</SelectItem>
<SelectItem value="pcm">
{t("go2rtcStreams.ffmpeg.audioPcm")}
</SelectItem>
<SelectItem value="mp3">
{t("go2rtcStreams.ffmpeg.audioMp3")}
</SelectItem>
<SelectItem value="exclude">
{t("go2rtcStreams.ffmpeg.audioExclude")}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
{/* Hardware acceleration - only when transcoding video */}
{isTranscodingVideo && (
<div className="space-y-1">
<Label className="text-xs font-medium">
{t("go2rtcStreams.ffmpeg.hardware")}
</Label>
<Select
value={parsed.hardware}
onValueChange={(v) =>
handleFfmpegOptionChange(
"hardware",
v as FfmpegHardwareOption,
)
}
<div className="space-y-4 rounded-lg bg-background p-4">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
className="text-md h-8 pr-10"
value={baseUrlForDisplay}
onChange={(e) => handleBaseUrlChange(e.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={t("go2rtcStreams.streamUrlPlaceholder")}
/>
{canToggleCredentials && (
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={onToggleCredentialVisibility}
>
<SelectTrigger className="h-8">
{parsed.hardware === "auto"
? t("go2rtcStreams.ffmpeg.hardwareAuto")
: t("go2rtcStreams.ffmpeg.hardwareNone")}
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="none">
{t("go2rtcStreams.ffmpeg.hardwareNone")}
</SelectItem>
<SelectItem value="auto">
{t("go2rtcStreams.ffmpeg.hardwareAuto")}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
)}
{showCredentials || isFocused ? (
<LuEyeOff className="size-4" />
) : (
<LuEye className="size-4" />
)}
</Button>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant={parsed.isFfmpeg ? "select" : "ghost"}
size="sm"
aria-pressed={parsed.isFfmpeg}
aria-label={t("go2rtcStreams.ffmpeg.useFfmpegModule")}
onClick={() => handleFfmpegToggle(!parsed.isFfmpeg)}
className="size-8 p-0"
>
<LuSlidersHorizontal className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("go2rtcStreams.ffmpeg.useFfmpegModule")}
</TooltipContent>
</Tooltip>
</div>
)}
{/* ffmpeg options */}
{parsed.isFfmpeg && (
<div
className={cn(
"grid grid-cols-1 gap-3 pl-4",
isTranscodingVideo ? "sm:grid-cols-3" : "sm:grid-cols-2",
)}
>
{/* Video — one row per #video= fragment */}
<div className="space-y-2">
<div className="flex h-7 items-center justify-start gap-2">
<Label className="text-xs font-medium">
{t("go2rtcStreams.ffmpeg.video")}
</Label>
{parsed.videos[0] !== "exclude" && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={addVideo}
className="size-6 p-0 text-muted-foreground hover:text-primary"
aria-label={t("go2rtcStreams.ffmpeg.addVideoCodec")}
>
<LuCirclePlus className="size-4" />
</Button>
)}
</div>
{parsed.videos.map((v, idx) => (
<div key={idx} className="flex items-center gap-1">
<Select
value={v}
onValueChange={(next) =>
updateVideoAt(idx, next as FfmpegVideoOption)
}
>
<SelectTrigger className="h-8 flex-1">
{videoLabels[v] ?? v}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{(Object.keys(videoLabels) as FfmpegVideoOption[])
// Exclude is only meaningful on the primary row.
.filter((opt) => idx === 0 || opt !== "exclude")
.map((opt) => (
<SelectItem key={opt} value={opt}>
{videoLabels[opt]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{idx > 0 ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeVideoAt(idx)}
className="size-8 p-0 text-muted-foreground hover:text-primary"
aria-label={t("go2rtcStreams.ffmpeg.removeCodec")}
>
<LuX className="size-4" />
</Button>
) : (
// Reserve the same horizontal slot so the primary Select
// doesn't stretch wider than fallback rows.
<div className="size-8 shrink-0" aria-hidden="true" />
)}
</div>
))}
</div>
{/* Audio — one row per #audio= fragment */}
<div className="space-y-2">
<div className="flex h-7 items-center justify-start gap-2">
<Label className="text-xs font-medium">
{t("go2rtcStreams.ffmpeg.audio")}
</Label>
{parsed.audios[0] !== "exclude" && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={addAudio}
className="size-6 p-0 text-muted-foreground hover:text-primary"
aria-label={t("go2rtcStreams.ffmpeg.addAudioCodec")}
>
<LuCirclePlus className="size-4" />
</Button>
)}
</div>
{parsed.audios.map((a, idx) => (
<div key={idx} className="flex items-center gap-1">
<Select
value={a}
onValueChange={(next) =>
updateAudioAt(idx, next as FfmpegAudioOption)
}
>
<SelectTrigger className="h-8 flex-1">
{audioLabels[a] ?? a}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{(Object.keys(audioLabels) as FfmpegAudioOption[])
// Exclude is only meaningful on the primary row.
.filter((opt) => idx === 0 || opt !== "exclude")
.map((opt) => (
<SelectItem key={opt} value={opt}>
{audioLabels[opt]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{idx > 0 ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeAudioAt(idx)}
className="size-8 p-0 text-muted-foreground hover:text-primary"
aria-label={t("go2rtcStreams.ffmpeg.removeCodec")}
>
<LuX className="size-4" />
</Button>
) : (
<div className="size-8 shrink-0" aria-hidden="true" />
)}
</div>
))}
</div>
{/* Hardware acceleration — only when transcoding video */}
{isTranscodingVideo && (
<div className="space-y-2">
<div className="flex h-7 items-center">
<Label className="text-xs font-medium">
{t("go2rtcStreams.ffmpeg.hardware")}
</Label>
</div>
<Select
value={parsed.hardware}
onValueChange={(v) =>
updateHardware(v as FfmpegHardwareOption)
}
>
<SelectTrigger className="h-8">
{hardwareLabels[parsed.hardware] ?? parsed.hardware}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{(
Object.keys(hardwareLabels) as FfmpegHardwareOption[]
).map((opt) => (
<SelectItem key={opt} value={opt}>
{hardwareLabels[opt]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
)}
</div>
)}
</div>
</div>
);
}