mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 03:41:55 +03:00
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
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>
This commit is contained in:
parent
d968f00500
commit
43d97acd21
@ -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 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
|
### When to use
|
||||||
|
|
||||||
- Reproducing a detection or tracking issue from a specific time range
|
- Reproducing a detection or tracking issue from a specific time range
|
||||||
|
|||||||
@ -6,11 +6,18 @@ from datetime import datetime
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from peewee import DoesNotExist
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from frigate.api.auth import require_role
|
from frigate.api.auth import require_role
|
||||||
from frigate.api.defs.tags import Tags
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -25,6 +32,12 @@ class DebugReplayStartBody(BaseModel):
|
|||||||
end_time: float = Field(title="End timestamp")
|
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):
|
class DebugReplayStartResponse(BaseModel):
|
||||||
"""Response for starting a debug replay session."""
|
"""Response for starting a debug replay session."""
|
||||||
|
|
||||||
@ -73,13 +86,95 @@ class DebugReplayStopResponse(BaseModel):
|
|||||||
async def start_debug_replay(request: Request, body: DebugReplayStartBody):
|
async def start_debug_replay(request: Request, body: DebugReplayStartBody):
|
||||||
"""Start a debug replay session asynchronously."""
|
"""Start a debug replay session asynchronously."""
|
||||||
replay_manager = request.app.replay_manager
|
replay_manager = request.app.replay_manager
|
||||||
|
source = RecordingDebugReplaySource(
|
||||||
|
source_camera=body.camera,
|
||||||
|
start_ts=body.start_time,
|
||||||
|
end_ts=body.end_time,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
job_id = await asyncio.to_thread(
|
job_id = await asyncio.to_thread(
|
||||||
start_debug_replay_job,
|
start_debug_replay_job,
|
||||||
source_camera=body.camera,
|
source=source,
|
||||||
start_ts=body.start_time,
|
frigate_config=request.app.frigate_config,
|
||||||
end_ts=body.end_time,
|
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,
|
frigate_config=request.app.frigate_config,
|
||||||
config_publisher=request.app.config_publisher,
|
config_publisher=request.app.config_publisher,
|
||||||
replay_manager=replay_manager,
|
replay_manager=replay_manager,
|
||||||
|
|||||||
@ -398,7 +398,7 @@ class _StreamingZipBuffer:
|
|||||||
def _unique_archive_name(export: Export, used: set[str]) -> str:
|
def _unique_archive_name(export: Export, used: set[str]) -> str:
|
||||||
base = sanitize_filename(export.name) if export.name else None
|
base = sanitize_filename(export.name) if export.name else None
|
||||||
if not base:
|
if not base:
|
||||||
base = f"{export.camera}_{int(datetime.datetime.timestamp(export.date))}"
|
base = f"{export.camera}_{int(export.date)}"
|
||||||
|
|
||||||
candidate = f"{base}.mp4"
|
candidate = f"{base}.mp4"
|
||||||
counter = 1
|
counter = 1
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
@ -25,7 +26,15 @@ from frigate.const import (
|
|||||||
REPLAY_DIR,
|
REPLAY_DIR,
|
||||||
THUMB_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.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||||
from frigate.util.config import find_config_file
|
from frigate.util.config import find_config_file
|
||||||
|
|
||||||
@ -49,6 +58,7 @@ class DebugReplayManager:
|
|||||||
self.clip_path: str | None = None
|
self.clip_path: str | None = None
|
||||||
self.start_ts: float | None = None
|
self.start_ts: float | None = None
|
||||||
self.end_ts: float | None = None
|
self.end_ts: float | None = None
|
||||||
|
self._job_state_publisher = JobStatePublisher()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def active(self) -> bool:
|
def active(self) -> bool:
|
||||||
@ -150,6 +160,7 @@ class DebugReplayManager:
|
|||||||
return
|
return
|
||||||
|
|
||||||
replay_name = self.replay_camera_name
|
replay_name = self.replay_camera_name
|
||||||
|
source_camera = self.source_camera
|
||||||
|
|
||||||
# Only publish remove if the camera was actually added to the live
|
# Only publish remove if the camera was actually added to the live
|
||||||
# config (i.e. the runner reached the starting_camera phase).
|
# config (i.e. the runner reached the starting_camera phase).
|
||||||
@ -163,6 +174,21 @@ class DebugReplayManager:
|
|||||||
self._cleanup_db(replay_name)
|
self._cleanup_db(replay_name)
|
||||||
self._cleanup_files(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()
|
self._clear_locked()
|
||||||
|
|
||||||
logger.info("Debug replay stopped and cleaned up: %s", replay_name)
|
logger.info("Debug replay stopped and cleaned up: %s", replay_name)
|
||||||
|
|||||||
@ -282,6 +282,13 @@ class OpenVINOModelRunner(BaseModelRunner):
|
|||||||
EnrichmentModelTypeEnum.arcface.value,
|
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):
|
def __init__(self, model_path: str, device: str, model_type: str, **kwargs):
|
||||||
self.model_path = model_path
|
self.model_path = model_path
|
||||||
self.device = device
|
self.device = device
|
||||||
@ -310,9 +317,15 @@ class OpenVINOModelRunner(BaseModelRunner):
|
|||||||
# Apply performance optimization
|
# Apply performance optimization
|
||||||
self.ov_core.set_property(device, {"PERF_COUNT": "NO"})
|
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"})
|
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
|
# Compile model
|
||||||
self.compiled_model = self.ov_core.compile_model(
|
self.compiled_model = self.ov_core.compile_model(
|
||||||
model=model_path, device_name=device
|
model=model_path, device_name=device
|
||||||
|
|||||||
@ -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
|
c.enabled_in_config and c.audio_transcription.enabled
|
||||||
for c in self.config.cameras.values()
|
for c in self.config.cameras.values()
|
||||||
):
|
):
|
||||||
|
|||||||
@ -100,7 +100,10 @@ class AudioProcessor(FrigateProcess):
|
|||||||
|
|
||||||
threading.current_thread().name = "process:audio_manager"
|
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 = (
|
self.transcription_model_runner: AudioTranscriptionModelRunner | None = (
|
||||||
AudioTranscriptionModelRunner(
|
AudioTranscriptionModelRunner(
|
||||||
self.config.audio_transcription.device or "AUTO",
|
self.config.audio_transcription.device or "AUTO",
|
||||||
@ -206,7 +209,7 @@ class AudioEventMaintainer(threading.Thread):
|
|||||||
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio.value)
|
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio.value)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
self.config.audio_transcription.enabled
|
self.camera_config.audio_transcription.enabled
|
||||||
and self.audio_transcription_model_runner is not None
|
and self.audio_transcription_model_runner is not None
|
||||||
):
|
):
|
||||||
# init the transcription processor for this camera
|
# init the transcription processor for this camera
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import base64
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, AsyncGenerator, Optional
|
from typing import Any, AsyncGenerator, Optional, cast
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@ -75,6 +75,29 @@ def _parse_launch_arg(args: list[str], flag: str) -> str | None:
|
|||||||
return args[idx + 1]
|
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:
|
def _to_jpeg(img_bytes: bytes) -> bytes | None:
|
||||||
"""Convert image bytes to JPEG. llama.cpp/STB does not support WebP."""
|
"""Convert image bytes to JPEG. llama.cpp/STB does not support WebP."""
|
||||||
try:
|
try:
|
||||||
@ -239,21 +262,7 @@ class LlamaCppClient(GenAIClient):
|
|||||||
info["supports_tools"] = True
|
info["supports_tools"] = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
props = _fetch_llama_props(base_url, configured_model)
|
||||||
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()
|
|
||||||
|
|
||||||
if info["context_size"] is None:
|
if info["context_size"] is None:
|
||||||
default_settings = props.get("default_generation_settings", {})
|
default_settings = props.get("default_generation_settings", {})
|
||||||
@ -559,6 +568,31 @@ class LlamaCppClient(GenAIClient):
|
|||||||
)
|
)
|
||||||
return result if result else None
|
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(
|
def embed(
|
||||||
self,
|
self,
|
||||||
texts: list[str] | None = None,
|
texts: list[str] | None = None,
|
||||||
@ -583,30 +617,46 @@ class LlamaCppClient(GenAIClient):
|
|||||||
|
|
||||||
EMBEDDING_DIM = 768
|
EMBEDDING_DIM = 768
|
||||||
|
|
||||||
content = []
|
encoded_images: list[str] = []
|
||||||
for text in texts:
|
|
||||||
content.append({"prompt_string": text})
|
|
||||||
for img in images:
|
for img in images:
|
||||||
# llama.cpp uses STB which does not support WebP; convert to JPEG
|
# llama.cpp uses STB which does not support WebP; convert to JPEG
|
||||||
jpeg_bytes = _to_jpeg(img)
|
jpeg_bytes = _to_jpeg(img)
|
||||||
to_encode = jpeg_bytes if jpeg_bytes is not None else img
|
to_encode = jpeg_bytes if jpeg_bytes is not None else img
|
||||||
encoded = base64.b64encode(to_encode).decode("utf-8")
|
encoded_images.append(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).
|
def build_content() -> list[dict[str, Any]]:
|
||||||
content.append(
|
# prompt_string must contain the server's media marker placeholder
|
||||||
{
|
# for each image. The marker is randomized per server startup.
|
||||||
"prompt_string": f"{self._media_marker}\n",
|
content: list[dict[str, Any]] = []
|
||||||
"multimodal_data": [encoded], # type: ignore[dict-item]
|
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:
|
try:
|
||||||
response = requests.post(
|
try:
|
||||||
f"{self.provider}/embeddings",
|
response = post_embeddings()
|
||||||
json={"model": self.genai_config.model, "content": content},
|
response.raise_for_status()
|
||||||
timeout=self.timeout,
|
except requests.exceptions.RequestException:
|
||||||
)
|
# The server may have restarted with a new media marker.
|
||||||
response.raise_for_status()
|
# 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()
|
result = response.json()
|
||||||
|
|
||||||
items = result.get("data", result) if isinstance(result, dict) else result
|
items = result.get("data", result) if isinstance(result, dict) else result
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import os
|
|||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Any, Optional, cast
|
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.export import JobStatePublisher
|
||||||
from frigate.jobs.job import Job
|
from frigate.jobs.job import Job
|
||||||
from frigate.jobs.manager import job_is_running, set_current_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.types import JobStatusTypesEnum
|
||||||
from frigate.util.ffmpeg import run_ffmpeg_with_progress
|
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)
|
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):
|
class DebugReplayJobRunner(threading.Thread):
|
||||||
"""Worker thread that drives the startup job to completion.
|
"""Worker thread that drives the startup job to completion.
|
||||||
|
|
||||||
@ -126,6 +246,7 @@ class DebugReplayJobRunner(threading.Thread):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
job: DebugReplayJob,
|
job: DebugReplayJob,
|
||||||
|
source: DebugReplaySource,
|
||||||
frigate_config: FrigateConfig,
|
frigate_config: FrigateConfig,
|
||||||
config_publisher: CameraConfigUpdatePublisher,
|
config_publisher: CameraConfigUpdatePublisher,
|
||||||
replay_manager: "DebugReplayManager",
|
replay_manager: "DebugReplayManager",
|
||||||
@ -133,6 +254,7 @@ class DebugReplayJobRunner(threading.Thread):
|
|||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(daemon=True, name=f"debug_replay_{job.id}")
|
super().__init__(daemon=True, name=f"debug_replay_{job.id}")
|
||||||
self.job = job
|
self.job = job
|
||||||
|
self.source = source
|
||||||
self.frigate_config = frigate_config
|
self.frigate_config = frigate_config
|
||||||
self.config_publisher = config_publisher
|
self.config_publisher = config_publisher
|
||||||
self.replay_manager = replay_manager
|
self.replay_manager = replay_manager
|
||||||
@ -183,7 +305,6 @@ class DebugReplayJobRunner(threading.Thread):
|
|||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
replay_name = self.job.replay_camera_name
|
replay_name = self.job.replay_camera_name
|
||||||
os.makedirs(REPLAY_DIR, exist_ok=True)
|
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")
|
clip_path = os.path.join(REPLAY_DIR, f"{replay_name}.mp4")
|
||||||
|
|
||||||
self.job.status = JobStatusTypesEnum.running
|
self.job.status = JobStatusTypesEnum.running
|
||||||
@ -192,23 +313,13 @@ class DebugReplayJobRunner(threading.Thread):
|
|||||||
self._broadcast(force=True)
|
self._broadcast(force=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
recordings = query_recordings(
|
input_args = self.source.ffmpeg_input_args(REPLAY_DIR)
|
||||||
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")
|
|
||||||
|
|
||||||
ffmpeg_cmd = [
|
ffmpeg_cmd = [
|
||||||
self.frigate_config.ffmpeg.ffmpeg_path,
|
self.frigate_config.ffmpeg.ffmpeg_path,
|
||||||
"-hide_banner",
|
"-hide_banner",
|
||||||
"-y",
|
"-y",
|
||||||
"-f",
|
*input_args,
|
||||||
"concat",
|
|
||||||
"-safe",
|
|
||||||
"0",
|
|
||||||
"-i",
|
|
||||||
concat_file,
|
|
||||||
"-c",
|
"-c",
|
||||||
"copy",
|
"copy",
|
||||||
"-movflags",
|
"-movflags",
|
||||||
@ -285,7 +396,7 @@ class DebugReplayJobRunner(threading.Thread):
|
|||||||
self.replay_manager.clear_session()
|
self.replay_manager.clear_session()
|
||||||
_remove_silent(clip_path)
|
_remove_silent(clip_path)
|
||||||
finally:
|
finally:
|
||||||
_remove_silent(concat_file)
|
self.source.cleanup(REPLAY_DIR)
|
||||||
_set_active_runner(None)
|
_set_active_runner(None)
|
||||||
|
|
||||||
def _finalize_cancelled(self, clip_path: str) -> None:
|
def _finalize_cancelled(self, clip_path: str) -> None:
|
||||||
@ -309,52 +420,43 @@ def _remove_silent(path: str) -> None:
|
|||||||
|
|
||||||
def start_debug_replay_job(
|
def start_debug_replay_job(
|
||||||
*,
|
*,
|
||||||
source_camera: str,
|
source: DebugReplaySource,
|
||||||
start_ts: float,
|
|
||||||
end_ts: float,
|
|
||||||
frigate_config: FrigateConfig,
|
frigate_config: FrigateConfig,
|
||||||
config_publisher: CameraConfigUpdatePublisher,
|
config_publisher: CameraConfigUpdatePublisher,
|
||||||
replay_manager: "DebugReplayManager",
|
replay_manager: "DebugReplayManager",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Validate, create job, start runner. Returns the job id.
|
"""Validate, create job, start runner. Returns the job id.
|
||||||
|
|
||||||
Raises ValueError for bad params (camera missing, time range
|
Raises ValueError for an invalid source (camera missing, source has
|
||||||
invalid, no recordings) and RuntimeError if a session is already
|
no usable content) and RuntimeError if a session is already active.
|
||||||
active.
|
|
||||||
"""
|
"""
|
||||||
if job_is_running(JOB_TYPE) or replay_manager.active:
|
if job_is_running(JOB_TYPE) or replay_manager.active:
|
||||||
raise RuntimeError("A replay session is already active")
|
raise RuntimeError("A replay session is already active")
|
||||||
|
|
||||||
if source_camera not in frigate_config.cameras:
|
if source.source_camera not in frigate_config.cameras:
|
||||||
raise ValueError(f"Camera '{source_camera}' not found")
|
raise ValueError(f"Camera '{source.source_camera}' not found")
|
||||||
|
|
||||||
if end_ts <= start_ts:
|
source.validate()
|
||||||
raise ValueError("End time must be after start time")
|
|
||||||
|
|
||||||
recordings = query_recordings(source_camera, start_ts, end_ts)
|
replay_name = f"{REPLAY_CAMERA_PREFIX}{source.source_camera}"
|
||||||
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_manager.mark_starting(
|
replay_manager.mark_starting(
|
||||||
source_camera=source_camera,
|
source_camera=source.source_camera,
|
||||||
replay_camera_name=replay_name,
|
replay_camera_name=replay_name,
|
||||||
start_ts=start_ts,
|
start_ts=source.start_ts,
|
||||||
end_ts=end_ts,
|
end_ts=source.end_ts,
|
||||||
)
|
)
|
||||||
|
|
||||||
job = DebugReplayJob(
|
job = DebugReplayJob(
|
||||||
source_camera=source_camera,
|
source_camera=source.source_camera,
|
||||||
replay_camera_name=replay_name,
|
replay_camera_name=replay_name,
|
||||||
start_ts=start_ts,
|
start_ts=source.start_ts,
|
||||||
end_ts=end_ts,
|
end_ts=source.end_ts,
|
||||||
)
|
)
|
||||||
set_current_job(job)
|
set_current_job(job)
|
||||||
|
|
||||||
runner = DebugReplayJobRunner(
|
runner = DebugReplayJobRunner(
|
||||||
job=job,
|
job=job,
|
||||||
|
source=source,
|
||||||
frigate_config=frigate_config,
|
frigate_config=frigate_config,
|
||||||
config_publisher=config_publisher,
|
config_publisher=config_publisher,
|
||||||
replay_manager=replay_manager,
|
replay_manager=replay_manager,
|
||||||
|
|||||||
@ -15,11 +15,12 @@ class TestDebugReplayAPI(BaseTestHttp):
|
|||||||
# Stub the factory to skip validation/threading and just record the
|
# Stub the factory to skip validation/threading and just record the
|
||||||
# name on the manager the way the real factory's mark_starting would.
|
# name on the manager the way the real factory's mark_starting would.
|
||||||
def fake_start(**kwargs):
|
def fake_start(**kwargs):
|
||||||
|
source = kwargs["source"]
|
||||||
kwargs["replay_manager"].mark_starting(
|
kwargs["replay_manager"].mark_starting(
|
||||||
source_camera=kwargs["source_camera"],
|
source_camera=source.source_camera,
|
||||||
replay_camera_name="_replay_front",
|
replay_camera_name="_replay_front",
|
||||||
start_ts=kwargs["start_ts"],
|
start_ts=source.start_ts,
|
||||||
end_ts=kwargs["end_ts"],
|
end_ts=source.end_ts,
|
||||||
)
|
)
|
||||||
return "job-1234"
|
return "job-1234"
|
||||||
|
|
||||||
|
|||||||
@ -71,6 +71,14 @@ class TestDebugReplayManagerSession(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestDebugReplayManagerStop(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:
|
def test_stop_when_inactive_is_a_noop(self) -> None:
|
||||||
from frigate.debug_replay import DebugReplayManager
|
from frigate.debug_replay import DebugReplayManager
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
from frigate.debug_replay import DebugReplayManager
|
from frigate.debug_replay import DebugReplayManager
|
||||||
from frigate.jobs.debug_replay import (
|
from frigate.jobs.debug_replay import (
|
||||||
DebugReplayJob,
|
DebugReplayJob,
|
||||||
|
RecordingDebugReplaySource,
|
||||||
cancel_debug_replay_job,
|
cancel_debug_replay_job,
|
||||||
get_active_runner,
|
get_active_runner,
|
||||||
start_debug_replay_job,
|
start_debug_replay_job,
|
||||||
@ -99,9 +100,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
|||||||
def test_rejects_unknown_camera(self) -> None:
|
def test_rejects_unknown_camera(self) -> None:
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
start_debug_replay_job(
|
start_debug_replay_job(
|
||||||
source_camera="missing",
|
source=RecordingDebugReplaySource(
|
||||||
start_ts=100.0,
|
source_camera="missing", start_ts=100.0, end_ts=200.0
|
||||||
end_ts=200.0,
|
),
|
||||||
frigate_config=self.frigate_config,
|
frigate_config=self.frigate_config,
|
||||||
config_publisher=self.publisher,
|
config_publisher=self.publisher,
|
||||||
replay_manager=self.manager,
|
replay_manager=self.manager,
|
||||||
@ -110,9 +111,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
|||||||
def test_rejects_invalid_time_range(self) -> None:
|
def test_rejects_invalid_time_range(self) -> None:
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
start_debug_replay_job(
|
start_debug_replay_job(
|
||||||
source_camera="front",
|
source=RecordingDebugReplaySource(
|
||||||
start_ts=200.0,
|
source_camera="front", start_ts=200.0, end_ts=100.0
|
||||||
end_ts=100.0,
|
),
|
||||||
frigate_config=self.frigate_config,
|
frigate_config=self.frigate_config,
|
||||||
config_publisher=self.publisher,
|
config_publisher=self.publisher,
|
||||||
replay_manager=self.manager,
|
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 patch("frigate.jobs.debug_replay.query_recordings", return_value=empty_qs):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
start_debug_replay_job(
|
start_debug_replay_job(
|
||||||
source_camera="front",
|
source=RecordingDebugReplaySource(
|
||||||
start_ts=100.0,
|
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||||
end_ts=200.0,
|
),
|
||||||
frigate_config=self.frigate_config,
|
frigate_config=self.frigate_config,
|
||||||
config_publisher=self.publisher,
|
config_publisher=self.publisher,
|
||||||
replay_manager=self.manager,
|
replay_manager=self.manager,
|
||||||
@ -154,9 +155,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
|||||||
patch("builtins.open", unittest.mock.mock_open()),
|
patch("builtins.open", unittest.mock.mock_open()),
|
||||||
):
|
):
|
||||||
job_id = start_debug_replay_job(
|
job_id = start_debug_replay_job(
|
||||||
source_camera="front",
|
source=RecordingDebugReplaySource(
|
||||||
start_ts=100.0,
|
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||||
end_ts=200.0,
|
),
|
||||||
frigate_config=self.frigate_config,
|
frigate_config=self.frigate_config,
|
||||||
config_publisher=self.publisher,
|
config_publisher=self.publisher,
|
||||||
replay_manager=self.manager,
|
replay_manager=self.manager,
|
||||||
@ -191,9 +192,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
|||||||
patch("builtins.open", unittest.mock.mock_open()),
|
patch("builtins.open", unittest.mock.mock_open()),
|
||||||
):
|
):
|
||||||
start_debug_replay_job(
|
start_debug_replay_job(
|
||||||
source_camera="front",
|
source=RecordingDebugReplaySource(
|
||||||
start_ts=100.0,
|
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||||
end_ts=200.0,
|
),
|
||||||
frigate_config=self.frigate_config,
|
frigate_config=self.frigate_config,
|
||||||
config_publisher=self.publisher,
|
config_publisher=self.publisher,
|
||||||
replay_manager=self.manager,
|
replay_manager=self.manager,
|
||||||
@ -201,9 +202,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
|||||||
|
|
||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(RuntimeError):
|
||||||
start_debug_replay_job(
|
start_debug_replay_job(
|
||||||
source_camera="front",
|
source=RecordingDebugReplaySource(
|
||||||
start_ts=100.0,
|
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||||
end_ts=200.0,
|
),
|
||||||
frigate_config=self.frigate_config,
|
frigate_config=self.frigate_config,
|
||||||
config_publisher=self.publisher,
|
config_publisher=self.publisher,
|
||||||
replay_manager=self.manager,
|
replay_manager=self.manager,
|
||||||
@ -269,9 +270,9 @@ class TestRunnerHappyPath(unittest.TestCase):
|
|||||||
patch("builtins.open", unittest.mock.mock_open()),
|
patch("builtins.open", unittest.mock.mock_open()),
|
||||||
):
|
):
|
||||||
start_debug_replay_job(
|
start_debug_replay_job(
|
||||||
source_camera="front",
|
source=RecordingDebugReplaySource(
|
||||||
start_ts=100.0,
|
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||||
end_ts=200.0,
|
),
|
||||||
frigate_config=self.frigate_config,
|
frigate_config=self.frigate_config,
|
||||||
config_publisher=self.publisher,
|
config_publisher=self.publisher,
|
||||||
replay_manager=self.manager,
|
replay_manager=self.manager,
|
||||||
@ -340,9 +341,9 @@ class TestRunnerFailurePath(unittest.TestCase):
|
|||||||
patch("builtins.open", unittest.mock.mock_open()),
|
patch("builtins.open", unittest.mock.mock_open()),
|
||||||
):
|
):
|
||||||
start_debug_replay_job(
|
start_debug_replay_job(
|
||||||
source_camera="front",
|
source=RecordingDebugReplaySource(
|
||||||
start_ts=100.0,
|
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||||
end_ts=200.0,
|
),
|
||||||
frigate_config=self.frigate_config,
|
frigate_config=self.frigate_config,
|
||||||
config_publisher=self.publisher,
|
config_publisher=self.publisher,
|
||||||
replay_manager=self.manager,
|
replay_manager=self.manager,
|
||||||
@ -418,9 +419,9 @@ class TestRunnerCancellation(unittest.TestCase):
|
|||||||
patch("builtins.open", unittest.mock.mock_open()),
|
patch("builtins.open", unittest.mock.mock_open()),
|
||||||
):
|
):
|
||||||
start_debug_replay_job(
|
start_debug_replay_job(
|
||||||
source_camera="front",
|
source=RecordingDebugReplaySource(
|
||||||
start_ts=100.0,
|
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||||
end_ts=200.0,
|
),
|
||||||
frigate_config=self.frigate_config,
|
frigate_config=self.frigate_config,
|
||||||
config_publisher=self.publisher,
|
config_publisher=self.publisher,
|
||||||
replay_manager=self.manager,
|
replay_manager=self.manager,
|
||||||
|
|||||||
@ -230,7 +230,7 @@ class TestExportResolution(unittest.TestCase):
|
|||||||
id=export_id,
|
id=export_id,
|
||||||
camera=camera,
|
camera=camera,
|
||||||
name=f"export-{export_id}",
|
name=f"export-{export_id}",
|
||||||
date=datetime.datetime.now(),
|
date=int(datetime.datetime.now().timestamp()),
|
||||||
video_path=f"/media/frigate/exports/{filename}",
|
video_path=f"/media/frigate/exports/{filename}",
|
||||||
thumb_path=f"/media/frigate/exports/{filename}.jpg",
|
thumb_path=f"/media/frigate/exports/{filename}.jpg",
|
||||||
in_progress=False,
|
in_progress=False,
|
||||||
|
|||||||
@ -357,6 +357,9 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
|
|
||||||
def get_current_frame_time(self, camera: str) -> float:
|
def get_current_frame_time(self, camera: str) -> float:
|
||||||
"""Returns the latest frame time for a given camera."""
|
"""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
|
return self.camera_states[camera].current_frame_time
|
||||||
|
|
||||||
def set_sub_label(
|
def set_sub_label(
|
||||||
|
|||||||
@ -222,7 +222,7 @@
|
|||||||
"label": "Hide object path"
|
"label": "Hide object path"
|
||||||
},
|
},
|
||||||
"debugReplay": {
|
"debugReplay": {
|
||||||
"label": "Debug replay",
|
"label": "Debug Replay",
|
||||||
"aria": "View this tracked object in the debug replay view"
|
"aria": "View this tracked object in the debug replay view"
|
||||||
},
|
},
|
||||||
"more": {
|
"more": {
|
||||||
|
|||||||
@ -40,6 +40,11 @@
|
|||||||
"profilePrefix": "{{profile}} profile: {{fields}}"
|
"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": {
|
"menu": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"globalConfig": "Global configuration",
|
"globalConfig": "Global configuration",
|
||||||
@ -472,10 +477,13 @@
|
|||||||
"streams": {
|
"streams": {
|
||||||
"title": "Enable / Disable Cameras",
|
"title": "Enable / Disable Cameras",
|
||||||
"enableLabel": "Enabled 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",
|
"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.",
|
"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.",
|
"enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes.",
|
||||||
|
"reorderHandle": "Drag to reorder",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"saved": "Saved",
|
||||||
"friendlyName": {
|
"friendlyName": {
|
||||||
"edit": "Edit camera display name",
|
"edit": "Edit camera display name",
|
||||||
"title": "Edit Display Name",
|
"title": "Edit Display Name",
|
||||||
@ -1682,6 +1690,13 @@
|
|||||||
"objects": "Objects",
|
"objects": "Objects",
|
||||||
"motion": "Motion",
|
"motion": "Motion",
|
||||||
"continuous": "Continuous"
|
"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": {
|
"retainMode": {
|
||||||
|
|||||||
@ -10,15 +10,21 @@ export function WsProvider({ children }: { children: ReactNode }) {
|
|||||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const reconnectAttempt = useRef(0);
|
const reconnectAttempt = useRef(0);
|
||||||
const unmounted = useRef(false);
|
const unmounted = useRef(false);
|
||||||
|
const pendingSends = useRef<Map<string, unknown>>(new Map());
|
||||||
|
|
||||||
const sendJsonMessage = useCallback((msg: unknown) => {
|
const sendJsonMessage = useCallback((msg: unknown) => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
wsRef.current.send(JSON.stringify(msg));
|
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(() => {
|
useEffect(() => {
|
||||||
unmounted.current = false;
|
unmounted.current = false;
|
||||||
|
const queue = pendingSends.current;
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
if (unmounted.current) return;
|
if (unmounted.current) return;
|
||||||
@ -31,6 +37,10 @@ export function WsProvider({ children }: { children: ReactNode }) {
|
|||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({ topic: "onConnect", message: "", retain: false }),
|
JSON.stringify({ topic: "onConnect", message: "", retain: false }),
|
||||||
);
|
);
|
||||||
|
for (const queued of queue.values()) {
|
||||||
|
ws.send(JSON.stringify(queued));
|
||||||
|
}
|
||||||
|
queue.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event: MessageEvent) => {
|
ws.onmessage = (event: MessageEvent) => {
|
||||||
@ -64,6 +74,7 @@ export function WsProvider({ children }: { children: ReactNode }) {
|
|||||||
ws.onerror = null;
|
ws.onerror = null;
|
||||||
ws.close();
|
ws.close();
|
||||||
}
|
}
|
||||||
|
queue.clear();
|
||||||
resetWsStore();
|
resetWsStore();
|
||||||
};
|
};
|
||||||
}, [wsUrl]);
|
}, [wsUrl]);
|
||||||
|
|||||||
@ -32,6 +32,9 @@ import { FaFolder, FaVideo } from "react-icons/fa";
|
|||||||
import { HiSquare2Stack } from "react-icons/hi2";
|
import { HiSquare2Stack } from "react-icons/hi2";
|
||||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||||
import useContextMenu from "@/hooks/use-contextmenu";
|
import useContextMenu from "@/hooks/use-contextmenu";
|
||||||
|
import axios from "axios";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
type CaseCardProps = {
|
type CaseCardProps = {
|
||||||
className: string;
|
className: string;
|
||||||
@ -123,11 +126,63 @@ export function ExportCard({
|
|||||||
onAssignToCase,
|
onAssignToCase,
|
||||||
onRemoveFromCase,
|
onRemoveFromCase,
|
||||||
}: ExportCardProps) {
|
}: ExportCardProps) {
|
||||||
const { t } = useTranslation(["views/exports"]);
|
const { t } = useTranslation(["views/exports", "views/replay"]);
|
||||||
|
const navigate = useNavigate();
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
const [loading, setLoading] = useState(
|
const [loading, setLoading] = useState(
|
||||||
exportedRecording.thumb_path.length > 0,
|
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
|
// Resync the skeleton state whenever the backing export changes. The
|
||||||
// list keys by id now, so in practice the component remounts instead
|
// list keys by id now, so in practice the component remounts instead
|
||||||
@ -301,6 +356,21 @@ export function ExportCard({
|
|||||||
{t("tooltip.downloadVideo")}
|
{t("tooltip.downloadVideo")}
|
||||||
</a>
|
</a>
|
||||||
</DropdownMenuItem>
|
</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 && (
|
{isAdmin && onAssignToCase && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
|
|||||||
@ -19,7 +19,7 @@ const birdseye: SectionConfigOverrides = {
|
|||||||
],
|
],
|
||||||
restartRequired: [],
|
restartRequired: [],
|
||||||
fieldOrder: ["enabled", "mode", "order"],
|
fieldOrder: ["enabled", "mode", "order"],
|
||||||
hiddenFields: [],
|
hiddenFields: ["order"],
|
||||||
advancedFields: [],
|
advancedFields: [],
|
||||||
overrideFields: ["enabled", "mode"],
|
overrideFields: ["enabled", "mode"],
|
||||||
uiSchema: {
|
uiSchema: {
|
||||||
@ -56,6 +56,7 @@ const birdseye: SectionConfigOverrides = {
|
|||||||
uiSchema: {
|
uiSchema: {
|
||||||
mode: {
|
mode: {
|
||||||
"ui:size": "xs",
|
"ui:size": "xs",
|
||||||
|
"ui:after": { render: "BirdseyeCameraReorder" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -46,7 +46,11 @@ const record: SectionConfigOverrides = {
|
|||||||
uiSchema: {
|
uiSchema: {
|
||||||
export: {
|
export: {
|
||||||
hwaccel_args: {
|
hwaccel_args: {
|
||||||
"ui:options": { suppressMultiSchema: true, size: "lg" },
|
"ui:widget": "FfmpegArgsWidget",
|
||||||
|
"ui:options": {
|
||||||
|
suppressMultiSchema: true,
|
||||||
|
ffmpegPresetField: "hwaccel_args",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"alerts.retain.mode": {
|
"alerts.retain.mode": {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ const ui: SectionConfigOverrides = {
|
|||||||
sectionDocs: "/configuration/reference",
|
sectionDocs: "/configuration/reference",
|
||||||
restartRequired: [],
|
restartRequired: [],
|
||||||
fieldOrder: ["dashboard", "order"],
|
fieldOrder: ["dashboard", "order"],
|
||||||
hiddenFields: [],
|
hiddenFields: ["order"],
|
||||||
advancedFields: [],
|
advancedFields: [],
|
||||||
overrideFields: [],
|
overrideFields: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
|
|||||||
import CameraReviewStatusToggles from "./CameraReviewStatusToggles";
|
import CameraReviewStatusToggles from "./CameraReviewStatusToggles";
|
||||||
import ProxyRoleMap from "./ProxyRoleMap";
|
import ProxyRoleMap from "./ProxyRoleMap";
|
||||||
import NotificationsSettingsExtras from "./NotificationsSettingsExtras";
|
import NotificationsSettingsExtras from "./NotificationsSettingsExtras";
|
||||||
|
import BirdseyeCameraReorder from "./BirdseyeCameraReorder";
|
||||||
import type { ConfigFormContext } from "@/types/configForm";
|
import type { ConfigFormContext } from "@/types/configForm";
|
||||||
|
|
||||||
// Props that will be injected into all section renderers
|
// Props that will be injected into all section renderers
|
||||||
@ -52,6 +53,9 @@ export const sectionRenderers: SectionRenderers = {
|
|||||||
notifications: {
|
notifications: {
|
||||||
NotificationsSettingsExtras,
|
NotificationsSettingsExtras,
|
||||||
},
|
},
|
||||||
|
birdseye: {
|
||||||
|
BirdseyeCameraReorder,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default sectionRenderers;
|
export default sectionRenderers;
|
||||||
|
|||||||
@ -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 { Event } from "@/types/event";
|
||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { ReviewSegment, REVIEW_PADDING } from "@/types/review";
|
import { ReviewSegment, REVIEW_PADDING } from "@/types/review";
|
||||||
@ -12,6 +14,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuPortal,
|
DropdownMenuPortal,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { HiDotsHorizontal } from "react-icons/hi";
|
import { HiDotsHorizontal } from "react-icons/hi";
|
||||||
import { SearchResult } from "@/types/search";
|
import { SearchResult } from "@/types/search";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
@ -33,9 +36,14 @@ export default function DetailActionsMenu({
|
|||||||
setSearch,
|
setSearch,
|
||||||
setSimilarity,
|
setSimilarity,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation(["views/explore", "views/faceLibrary"]);
|
const { t } = useTranslation([
|
||||||
|
"views/explore",
|
||||||
|
"views/faceLibrary",
|
||||||
|
"views/replay",
|
||||||
|
]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isStarting, setIsStarting] = useState(false);
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
const clipTimeRange = useMemo(() => {
|
const clipTimeRange = useMemo(() => {
|
||||||
@ -49,6 +57,54 @@ export default function DetailActionsMenu({
|
|||||||
search.data?.type === "audio" ? null : [`review/event/${search.id}`],
|
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
|
// don't render menu at all if no options are available
|
||||||
const hasSemanticSearchOption =
|
const hasSemanticSearchOption =
|
||||||
config?.semantic_search.enabled &&
|
config?.semantic_search.enabled &&
|
||||||
@ -172,6 +228,24 @@ export default function DetailActionsMenu({
|
|||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenuPortal>
|
</DropdownMenuPortal>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@ -59,6 +59,64 @@ function stripHiddenPaths(value: JsonValue, hiddenFields: string[]): JsonValue {
|
|||||||
return cloned;
|
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
|
* Collapse null and empty-object values for override comparisons so
|
||||||
* semantically equivalent shapes match. The schema may default `mask: None`
|
* semantically equivalent shapes match. The schema may default `mask: None`
|
||||||
@ -234,10 +292,15 @@ export function useConfigOverride({
|
|||||||
collapseEmpty(normalizedGlobalValue),
|
collapseEmpty(normalizedGlobalValue),
|
||||||
hiddenFields,
|
hiddenFields,
|
||||||
);
|
);
|
||||||
const collapsedCamera = stripHiddenPaths(
|
const collapsedCameraRaw = stripHiddenPaths(
|
||||||
collapseEmpty(normalizedCameraValue),
|
collapseEmpty(normalizedCameraValue),
|
||||||
hiddenFields,
|
hiddenFields,
|
||||||
);
|
);
|
||||||
|
const collapsedCamera = stripAutoDerivedMissingFromGlobal(
|
||||||
|
sectionPath,
|
||||||
|
collapsedGlobal,
|
||||||
|
collapsedCameraRaw,
|
||||||
|
);
|
||||||
|
|
||||||
const comparisonGlobal = compareFields
|
const comparisonGlobal = compareFields
|
||||||
? pickFields(collapsedGlobal, compareFields)
|
? pickFields(collapsedGlobal, compareFields)
|
||||||
@ -258,6 +321,20 @@ export function useConfigOverride({
|
|||||||
const globalFieldValue = get(normalizedGlobalValue, fieldPath);
|
const globalFieldValue = get(normalizedGlobalValue, fieldPath);
|
||||||
const cameraFieldValue = get(normalizedCameraValue, fieldPath);
|
const cameraFieldValue = get(normalizedCameraValue, fieldPath);
|
||||||
|
|
||||||
|
if (
|
||||||
|
isAutoDerivedMissingFromGlobal(
|
||||||
|
sectionPath,
|
||||||
|
fieldPath,
|
||||||
|
normalizedGlobalValue,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
isOverridden: false,
|
||||||
|
globalValue: globalFieldValue,
|
||||||
|
cameraValue: cameraFieldValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isOverridden: !isEqual(
|
isOverridden: !isEqual(
|
||||||
collapseEmpty(globalFieldValue as JsonValue),
|
collapseEmpty(globalFieldValue as JsonValue),
|
||||||
@ -367,10 +444,15 @@ export function useAllCameraOverrides(
|
|||||||
collapseEmpty(globalValue),
|
collapseEmpty(globalValue),
|
||||||
hiddenFields,
|
hiddenFields,
|
||||||
);
|
);
|
||||||
const collapsedCamera = stripHiddenPaths(
|
const collapsedCameraRaw = stripHiddenPaths(
|
||||||
collapseEmpty(cameraValue),
|
collapseEmpty(cameraValue),
|
||||||
hiddenFields,
|
hiddenFields,
|
||||||
);
|
);
|
||||||
|
const collapsedCamera = stripAutoDerivedMissingFromGlobal(
|
||||||
|
key,
|
||||||
|
collapsedGlobal,
|
||||||
|
collapsedCameraRaw,
|
||||||
|
);
|
||||||
const comparisonGlobal = compareFields
|
const comparisonGlobal = compareFields
|
||||||
? pickFields(collapsedGlobal, compareFields)
|
? pickFields(collapsedGlobal, compareFields)
|
||||||
: collapsedGlobal;
|
: collapsedGlobal;
|
||||||
@ -615,7 +697,11 @@ export function useCamerasOverridingSection(
|
|||||||
const deltasByPath = new Map<string, FieldDelta>();
|
const deltasByPath = new Map<string, FieldDelta>();
|
||||||
|
|
||||||
// 1. Camera-level overrides (uses base_config when a profile is active)
|
// 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(
|
for (const delta of collectFieldDeltas(
|
||||||
globalValue,
|
globalValue,
|
||||||
cameraValue,
|
cameraValue,
|
||||||
@ -696,9 +782,13 @@ export function useCameraSectionDeltas(
|
|||||||
const globalValue = collapseEmpty(
|
const globalValue = collapseEmpty(
|
||||||
getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema),
|
getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema),
|
||||||
);
|
);
|
||||||
const cameraValue = collapseEmpty(
|
const cameraValue = stripAutoDerivedMissingFromGlobal(
|
||||||
normalizeConfigValue(
|
sectionPath,
|
||||||
getBaseCameraSectionValue(config, cameraName, sectionPath),
|
globalValue,
|
||||||
|
collapseEmpty(
|
||||||
|
normalizeConfigValue(
|
||||||
|
getBaseCameraSectionValue(config, cameraName, sectionPath),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import useSWR from "swr";
|
|||||||
import useDeepMemo from "./use-deep-memo";
|
import useDeepMemo from "./use-deep-memo";
|
||||||
import { capitalizeAll, capitalizeFirstLetter } from "@/utils/stringUtil";
|
import { capitalizeAll, capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||||
import { useFrigateStats } from "@/api/ws";
|
import { useFrigateStats, useJobStatus } from "@/api/ws";
|
||||||
import { useIsAdmin } from "./use-is-admin";
|
import { useIsAdmin } from "./use-is-admin";
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -19,11 +19,15 @@ export default function useStats(stats: FrigateStats | undefined) {
|
|||||||
const { t } = useTranslation(["views/system"]);
|
const { t } = useTranslation(["views/system"]);
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
const { data: debugReplayStatus } = useSWR(
|
|
||||||
isAdmin ? "debug_replay/status" : null,
|
// Pass isAdmin as revalidateOnFocus so non-admins never send the jobState snapshot pull
|
||||||
{
|
const { payload: replayJob } = useJobStatus("debug_replay", isAdmin);
|
||||||
revalidateOnFocus: false,
|
const replayActive = Boolean(
|
||||||
},
|
isAdmin &&
|
||||||
|
replayJob &&
|
||||||
|
(replayJob.status === "queued" ||
|
||||||
|
replayJob.status === "running" ||
|
||||||
|
replayJob.status === "success"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const memoizedStats = useDeepMemo(stats);
|
const memoizedStats = useDeepMemo(stats);
|
||||||
@ -102,6 +106,11 @@ export default function useStats(stats: FrigateStats | undefined) {
|
|||||||
|
|
||||||
// check camera cpu usages
|
// check camera cpu usages
|
||||||
Object.entries(memoizedStats["cameras"]).forEach(([name, cam]) => {
|
Object.entries(memoizedStats["cameras"]).forEach(([name, cam]) => {
|
||||||
|
// Skip replay cameras
|
||||||
|
if (isReplayCamera(name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const ffmpegAvg = parseFloat(
|
const ffmpegAvg = parseFloat(
|
||||||
memoizedStats["cpu_usages"][cam["ffmpeg_pid"]]?.cpu_average,
|
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;
|
const cameraName = config?.cameras?.[name]?.friendly_name ?? name;
|
||||||
|
|
||||||
// Skip ffmpeg warnings for replay cameras
|
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
|
||||||
if (
|
|
||||||
!isNaN(ffmpegAvg) &&
|
|
||||||
ffmpegAvg >= CameraFfmpegThreshold.error &&
|
|
||||||
!isReplayCamera(name)
|
|
||||||
) {
|
|
||||||
problems.push({
|
problems.push({
|
||||||
text: t("stats.ffmpegHighCpuUsage", {
|
text: t("stats.ffmpegHighCpuUsage", {
|
||||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||||
@ -140,7 +144,7 @@ export default function useStats(stats: FrigateStats | undefined) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Add message if debug replay is active
|
// Add message if debug replay is active
|
||||||
if (debugReplayStatus?.active) {
|
if (replayActive) {
|
||||||
problems.push({
|
problems.push({
|
||||||
text: t("stats.debugReplayActive", {
|
text: t("stats.debugReplayActive", {
|
||||||
defaultValue: "Debug replay session is active",
|
defaultValue: "Debug replay session is active",
|
||||||
@ -151,7 +155,7 @@ export default function useStats(stats: FrigateStats | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return problems;
|
return problems;
|
||||||
}, [config, memoizedStats, t, debugReplayStatus]);
|
}, [config, memoizedStats, t, replayActive]);
|
||||||
|
|
||||||
return { potentialProblems };
|
return { potentialProblems };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -106,6 +106,12 @@ import SaveAllPreviewPopover, {
|
|||||||
type SaveAllPreviewItem,
|
type SaveAllPreviewItem,
|
||||||
} from "@/components/overlay/detail/SaveAllPreviewPopover";
|
} from "@/components/overlay/detail/SaveAllPreviewPopover";
|
||||||
import { useRestart } from "@/api/ws";
|
import { useRestart } from "@/api/ws";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
const allSettingsViews = [
|
const allSettingsViews = [
|
||||||
"uiSettings",
|
"uiSettings",
|
||||||
@ -1505,10 +1511,20 @@ export default function Settings() {
|
|||||||
CAMERA_SECTION_KEYS.has(key) && status?.isOverridden;
|
CAMERA_SECTION_KEYS.has(key) && status?.isOverridden;
|
||||||
const showUnsavedDot = status?.hasChanges;
|
const showUnsavedDot = status?.hasChanges;
|
||||||
|
|
||||||
const dotColor =
|
const isProfileOverride =
|
||||||
status?.overrideSource === "profile" && activeProfileColor
|
status?.overrideSource === "profile" && activeProfileColor;
|
||||||
? activeProfileColor.dot
|
const dotColor = isProfileOverride
|
||||||
: "bg-selected";
|
? activeProfileColor.dot
|
||||||
|
: "bg-selected";
|
||||||
|
|
||||||
|
const overrideTooltip = isProfileOverride
|
||||||
|
? t("menuDot.overrideProfile", {
|
||||||
|
profile: activeEditingProfile
|
||||||
|
? (profileFriendlyNames.get(activeEditingProfile) ??
|
||||||
|
activeEditingProfile)
|
||||||
|
: "",
|
||||||
|
})
|
||||||
|
: t("menuDot.overrideGlobal");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full min-w-0 items-center justify-between pr-4 md:pr-0">
|
<div className="flex w-full min-w-0 items-center justify-between pr-4 md:pr-0">
|
||||||
@ -1518,19 +1534,46 @@ export default function Settings() {
|
|||||||
{(showOverrideDot || showUnsavedDot) && (
|
{(showOverrideDot || showUnsavedDot) && (
|
||||||
<div className="ml-2 flex shrink-0 items-center gap-2">
|
<div className="ml-2 flex shrink-0 items-center gap-2">
|
||||||
{showOverrideDot && (
|
{showOverrideDot && (
|
||||||
<span
|
<Tooltip>
|
||||||
className={cn("inline-block size-2 rounded-full", dotColor)}
|
<TooltipTrigger asChild>
|
||||||
/>
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block size-2 rounded-full",
|
||||||
|
dotColor,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipPortal>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
{overrideTooltip}
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{showUnsavedDot && (
|
{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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[sectionStatusByKey, t, activeProfileColor],
|
[
|
||||||
|
sectionStatusByKey,
|
||||||
|
t,
|
||||||
|
activeProfileColor,
|
||||||
|
activeEditingProfile,
|
||||||
|
profileFriendlyNames,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
CONTROL_COLUMN_CLASS_NAME,
|
CONTROL_COLUMN_CLASS_NAME,
|
||||||
SettingsGroupCard,
|
SettingsGroupCard,
|
||||||
@ -14,7 +14,15 @@ import { useTranslation } from "react-i18next";
|
|||||||
import CameraEditForm from "@/components/settings/CameraEditForm";
|
import CameraEditForm from "@/components/settings/CameraEditForm";
|
||||||
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
||||||
import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog";
|
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 { IoMdArrowRoundBack } from "react-icons/io";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
@ -45,6 +53,10 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
const REORDER_SAVED_INDICATOR_MS = 1500;
|
||||||
|
|
||||||
|
type ReorderSaveStatus = "idle" | "saving" | "saved";
|
||||||
|
|
||||||
type CameraManagementViewProps = {
|
type CameraManagementViewProps = {
|
||||||
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
profileState?: ProfileState;
|
profileState?: ProfileState;
|
||||||
@ -54,7 +66,7 @@ export default function CameraManagementView({
|
|||||||
setUnsavedChanges,
|
setUnsavedChanges,
|
||||||
profileState,
|
profileState,
|
||||||
}: CameraManagementViewProps) {
|
}: CameraManagementViewProps) {
|
||||||
const { t } = useTranslation(["views/settings"]);
|
const { t } = useTranslation(["views/settings", "common"]);
|
||||||
|
|
||||||
const { data: config, mutate: updateConfig } =
|
const { data: config, mutate: updateConfig } =
|
||||||
useSWR<FrigateConfig>("config");
|
useSWR<FrigateConfig>("config");
|
||||||
@ -72,16 +84,99 @@ export default function CameraManagementView({
|
|||||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||||
const { send: sendRestart } = useRestart();
|
const { send: sendRestart } = useRestart();
|
||||||
|
|
||||||
// List of cameras for dropdown
|
|
||||||
const enabledCameras = useMemo(() => {
|
const enabledCameras = useMemo(() => {
|
||||||
if (config) {
|
if (config) {
|
||||||
return Object.keys(config.cameras)
|
return Object.keys(config.cameras)
|
||||||
.filter((camera) => config.cameras[camera].enabled_in_config)
|
.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 [];
|
return [];
|
||||||
}, [config]);
|
}, [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(() => {
|
const disabledCameras = useMemo(() => {
|
||||||
if (config) {
|
if (config) {
|
||||||
return Object.keys(config.cameras)
|
return Object.keys(config.cameras)
|
||||||
@ -173,22 +268,26 @@ export default function CameraManagementView({
|
|||||||
</p>
|
</p>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
|
<div className="max-w-md space-y-1.5">
|
||||||
{enabledCameras.map((camera) => (
|
<Reorder.Group
|
||||||
<div
|
as="div"
|
||||||
key={camera}
|
axis="y"
|
||||||
className="flex flex-row items-center justify-between"
|
values={orderedCameras}
|
||||||
>
|
onReorder={setOrderedCameras}
|
||||||
<div className="flex items-center gap-1">
|
className="space-y-2 rounded-lg bg-secondary p-4"
|
||||||
<CameraNameLabel camera={camera} />
|
>
|
||||||
<CameraFriendlyNameEditor
|
{orderedCameras.map((camera) => (
|
||||||
cameraName={camera}
|
<EnabledCameraRow
|
||||||
onConfigChanged={updateConfig}
|
key={camera}
|
||||||
/>
|
camera={camera}
|
||||||
</div>
|
onConfigChanged={updateConfig}
|
||||||
<CameraEnableSwitch cameraName={camera} />
|
onDragEnd={handleReorderDragEnd}
|
||||||
</div>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</Reorder.Group>
|
||||||
|
<ReorderSaveStatusIndicator
|
||||||
|
status={reorderSaveStatus}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground md:hidden">
|
<p className="text-sm text-muted-foreground md:hidden">
|
||||||
<Trans ns="views/settings">
|
<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 = {
|
type CameraEnableSwitchProps = {
|
||||||
cameraName: string;
|
cameraName: string;
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user