mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-07 03:11:15 +03:00
Miscellaneous fixes (#23238)
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
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
CI / AMD64 Build (push) Waiting to run
* 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:
co-authored by
Nicolas Mowen
parent
d968f00500
commit
43d97acd21
@@ -6,11 +6,18 @@ from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from peewee import DoesNotExist
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.jobs.debug_replay import start_debug_replay_job
|
||||
from frigate.jobs.debug_replay import (
|
||||
ExportDebugReplaySource,
|
||||
RecordingDebugReplaySource,
|
||||
start_debug_replay_job,
|
||||
)
|
||||
from frigate.models import Export
|
||||
from frigate.util.services import get_video_properties
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,6 +32,12 @@ class DebugReplayStartBody(BaseModel):
|
||||
end_time: float = Field(title="End timestamp")
|
||||
|
||||
|
||||
class DebugReplayStartFromExportBody(BaseModel):
|
||||
"""Request body for starting a debug replay session from an export."""
|
||||
|
||||
export_id: str = Field(title="Export id")
|
||||
|
||||
|
||||
class DebugReplayStartResponse(BaseModel):
|
||||
"""Response for starting a debug replay session."""
|
||||
|
||||
@@ -73,13 +86,95 @@ class DebugReplayStopResponse(BaseModel):
|
||||
async def start_debug_replay(request: Request, body: DebugReplayStartBody):
|
||||
"""Start a debug replay session asynchronously."""
|
||||
replay_manager = request.app.replay_manager
|
||||
source = RecordingDebugReplaySource(
|
||||
source_camera=body.camera,
|
||||
start_ts=body.start_time,
|
||||
end_ts=body.end_time,
|
||||
)
|
||||
|
||||
try:
|
||||
job_id = await asyncio.to_thread(
|
||||
start_debug_replay_job,
|
||||
source_camera=body.camera,
|
||||
start_ts=body.start_time,
|
||||
end_ts=body.end_time,
|
||||
source=source,
|
||||
frigate_config=request.app.frigate_config,
|
||||
config_publisher=request.app.config_publisher,
|
||||
replay_manager=replay_manager,
|
||||
)
|
||||
except RuntimeError:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "A replay session is already active",
|
||||
},
|
||||
status_code=409,
|
||||
)
|
||||
except ValueError:
|
||||
logger.exception("Rejected debug replay start request")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Invalid debug replay parameters",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"replay_camera": replay_manager.replay_camera_name,
|
||||
"job_id": job_id,
|
||||
},
|
||||
status_code=202,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/debug_replay/start_from_export",
|
||||
response_model=DebugReplayStartResponse,
|
||||
status_code=202,
|
||||
responses={
|
||||
400: {"description": "Invalid export, time range, or no recordings"},
|
||||
404: {"description": "Export not found"},
|
||||
409: {"description": "A replay session is already active"},
|
||||
},
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Start debug replay from an export",
|
||||
description="Start a debug replay session covering an existing export's "
|
||||
"time range. The end time is derived from the export's video duration.",
|
||||
)
|
||||
async def start_debug_replay_from_export(
|
||||
request: Request, body: DebugReplayStartFromExportBody
|
||||
):
|
||||
"""Start a debug replay session from an existing export."""
|
||||
try:
|
||||
export: Export = Export.get(Export.id == body.export_id)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Export not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
properties = await get_video_properties(
|
||||
request.app.frigate_config.ffmpeg, export.video_path, get_duration=True
|
||||
)
|
||||
duration = properties.get("duration", -1)
|
||||
|
||||
if duration is None or duration <= 0:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Could not determine export duration",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
replay_manager = request.app.replay_manager
|
||||
source = ExportDebugReplaySource(export=export, duration=float(duration))
|
||||
|
||||
try:
|
||||
job_id = await asyncio.to_thread(
|
||||
start_debug_replay_job,
|
||||
source=source,
|
||||
frigate_config=request.app.frigate_config,
|
||||
config_publisher=request.app.config_publisher,
|
||||
replay_manager=replay_manager,
|
||||
|
||||
@@ -398,7 +398,7 @@ class _StreamingZipBuffer:
|
||||
def _unique_archive_name(export: Export, used: set[str]) -> str:
|
||||
base = sanitize_filename(export.name) if export.name else None
|
||||
if not base:
|
||||
base = f"{export.camera}_{int(datetime.datetime.timestamp(export.date))}"
|
||||
base = f"{export.camera}_{int(export.date)}"
|
||||
|
||||
candidate = f"{base}.mp4"
|
||||
counter = 1
|
||||
|
||||
+27
-1
@@ -9,6 +9,7 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
@@ -25,7 +26,15 @@ from frigate.const import (
|
||||
REPLAY_DIR,
|
||||
THUMB_DIR,
|
||||
)
|
||||
from frigate.jobs.debug_replay import cancel_debug_replay_job, wait_for_runner
|
||||
from frigate.jobs.debug_replay import (
|
||||
JOB_TYPE as DEBUG_REPLAY_JOB_TYPE,
|
||||
)
|
||||
from frigate.jobs.debug_replay import (
|
||||
cancel_debug_replay_job,
|
||||
wait_for_runner,
|
||||
)
|
||||
from frigate.jobs.export import JobStatePublisher
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||
from frigate.util.config import find_config_file
|
||||
|
||||
@@ -49,6 +58,7 @@ class DebugReplayManager:
|
||||
self.clip_path: str | None = None
|
||||
self.start_ts: float | None = None
|
||||
self.end_ts: float | None = None
|
||||
self._job_state_publisher = JobStatePublisher()
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
@@ -150,6 +160,7 @@ class DebugReplayManager:
|
||||
return
|
||||
|
||||
replay_name = self.replay_camera_name
|
||||
source_camera = self.source_camera
|
||||
|
||||
# Only publish remove if the camera was actually added to the live
|
||||
# config (i.e. the runner reached the starting_camera phase).
|
||||
@@ -163,6 +174,21 @@ class DebugReplayManager:
|
||||
self._cleanup_db(replay_name)
|
||||
self._cleanup_files(replay_name)
|
||||
|
||||
self._job_state_publisher.publish(
|
||||
{
|
||||
"id": "stopped",
|
||||
"job_type": DEBUG_REPLAY_JOB_TYPE,
|
||||
"status": JobStatusTypesEnum.cancelled,
|
||||
"start_time": None,
|
||||
"end_time": time.time(),
|
||||
"error_message": None,
|
||||
"results": {
|
||||
"source_camera": source_camera,
|
||||
"replay_camera_name": replay_name,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self._clear_locked()
|
||||
|
||||
logger.info("Debug replay stopped and cleaned up: %s", replay_name)
|
||||
|
||||
@@ -282,6 +282,13 @@ class OpenVINOModelRunner(BaseModelRunner):
|
||||
EnrichmentModelTypeEnum.arcface.value,
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def is_detection_model(model_type: str) -> bool:
|
||||
# Import here to avoid circular imports
|
||||
from frigate.detectors.detector_config import ModelTypeEnum
|
||||
|
||||
return model_type in [m.value for m in ModelTypeEnum]
|
||||
|
||||
def __init__(self, model_path: str, device: str, model_type: str, **kwargs):
|
||||
self.model_path = model_path
|
||||
self.device = device
|
||||
@@ -310,9 +317,15 @@ class OpenVINOModelRunner(BaseModelRunner):
|
||||
# Apply performance optimization
|
||||
self.ov_core.set_property(device, {"PERF_COUNT": "NO"})
|
||||
|
||||
if device in ["GPU", "AUTO"]:
|
||||
if device in ["GPU", "AUTO", "NPU"]:
|
||||
self.ov_core.set_property(device, {"PERFORMANCE_HINT": "LATENCY"})
|
||||
|
||||
if device == "NPU" and OpenVINOModelRunner.is_detection_model(model_type):
|
||||
try:
|
||||
self.ov_core.set_property(device, {"NPU_TURBO": "YES"})
|
||||
except Exception as e:
|
||||
logger.debug(f"NPU_TURBO not supported by driver: {e}")
|
||||
|
||||
# Compile model
|
||||
self.compiled_model = self.ov_core.compile_model(
|
||||
model=model_path, device_name=device
|
||||
|
||||
@@ -232,7 +232,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
)
|
||||
)
|
||||
|
||||
if self.config.audio_transcription.enabled and any(
|
||||
if any(
|
||||
c.enabled_in_config and c.audio_transcription.enabled
|
||||
for c in self.config.cameras.values()
|
||||
):
|
||||
|
||||
@@ -100,7 +100,10 @@ class AudioProcessor(FrigateProcess):
|
||||
|
||||
threading.current_thread().name = "process:audio_manager"
|
||||
|
||||
if self.config.audio_transcription.enabled:
|
||||
if any(
|
||||
c.enabled_in_config and c.audio_transcription.enabled
|
||||
for c in self.config.cameras.values()
|
||||
):
|
||||
self.transcription_model_runner: AudioTranscriptionModelRunner | None = (
|
||||
AudioTranscriptionModelRunner(
|
||||
self.config.audio_transcription.device or "AUTO",
|
||||
@@ -206,7 +209,7 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio.value)
|
||||
|
||||
if (
|
||||
self.config.audio_transcription.enabled
|
||||
self.camera_config.audio_transcription.enabled
|
||||
and self.audio_transcription_model_runner is not None
|
||||
):
|
||||
# init the transcription processor for this camera
|
||||
|
||||
+83
-33
@@ -4,7 +4,7 @@ import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
from typing import Any, AsyncGenerator, Optional, cast
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
@@ -75,6 +75,29 @@ def _parse_launch_arg(args: list[str], flag: str) -> str | None:
|
||||
return args[idx + 1]
|
||||
|
||||
|
||||
def _fetch_llama_props(base_url: str, model: str) -> dict[str, Any]:
|
||||
"""Fetch /props from a llama.cpp server, with llama-swap fallback.
|
||||
|
||||
Raises the underlying RequestException if both endpoints fail; callers
|
||||
decide how to surface the failure.
|
||||
"""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{base_url}/props",
|
||||
params={"model": model},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return cast(dict[str, Any], response.json())
|
||||
except Exception:
|
||||
response = requests.get(
|
||||
f"{base_url}/upstream/{model}/props",
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return cast(dict[str, Any], response.json())
|
||||
|
||||
|
||||
def _to_jpeg(img_bytes: bytes) -> bytes | None:
|
||||
"""Convert image bytes to JPEG. llama.cpp/STB does not support WebP."""
|
||||
try:
|
||||
@@ -239,21 +262,7 @@ class LlamaCppClient(GenAIClient):
|
||||
info["supports_tools"] = True
|
||||
|
||||
try:
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{base_url}/props",
|
||||
params={"model": configured_model},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
props = response.json()
|
||||
except Exception:
|
||||
response = requests.get(
|
||||
f"{base_url}/upstream/{configured_model}/props",
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
props = response.json()
|
||||
props = _fetch_llama_props(base_url, configured_model)
|
||||
|
||||
if info["context_size"] is None:
|
||||
default_settings = props.get("default_generation_settings", {})
|
||||
@@ -559,6 +568,31 @@ class LlamaCppClient(GenAIClient):
|
||||
)
|
||||
return result if result else None
|
||||
|
||||
def _refresh_media_marker(self) -> bool:
|
||||
"""Re-fetch /props and update the cached media marker if it changed.
|
||||
|
||||
The server randomizes the marker per startup (unless LLAMA_MEDIA_MARKER
|
||||
is set), so a stale marker indicates a restart. Returns True iff the
|
||||
marker was updated to a new value — used to gate a one-shot retry of
|
||||
a failed embeddings request.
|
||||
"""
|
||||
if self.provider is None:
|
||||
return False
|
||||
try:
|
||||
props = _fetch_llama_props(self.provider, self.genai_config.model)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to refresh llama.cpp media marker: %s", e)
|
||||
return False
|
||||
|
||||
marker = props.get("media_marker")
|
||||
|
||||
if not isinstance(marker, str) or not marker or marker == self._media_marker:
|
||||
return False
|
||||
|
||||
logger.info("llama.cpp media marker changed (server restart); refreshed")
|
||||
self._media_marker = marker
|
||||
return True
|
||||
|
||||
def embed(
|
||||
self,
|
||||
texts: list[str] | None = None,
|
||||
@@ -583,30 +617,46 @@ class LlamaCppClient(GenAIClient):
|
||||
|
||||
EMBEDDING_DIM = 768
|
||||
|
||||
content = []
|
||||
for text in texts:
|
||||
content.append({"prompt_string": text})
|
||||
encoded_images: list[str] = []
|
||||
for img in images:
|
||||
# llama.cpp uses STB which does not support WebP; convert to JPEG
|
||||
jpeg_bytes = _to_jpeg(img)
|
||||
to_encode = jpeg_bytes if jpeg_bytes is not None else img
|
||||
encoded = base64.b64encode(to_encode).decode("utf-8")
|
||||
# prompt_string must contain the server's media marker placeholder.
|
||||
# The marker is randomized per server startup (read from /props).
|
||||
content.append(
|
||||
{
|
||||
"prompt_string": f"{self._media_marker}\n",
|
||||
"multimodal_data": [encoded], # type: ignore[dict-item]
|
||||
}
|
||||
encoded_images.append(base64.b64encode(to_encode).decode("utf-8"))
|
||||
|
||||
def build_content() -> list[dict[str, Any]]:
|
||||
# prompt_string must contain the server's media marker placeholder
|
||||
# for each image. The marker is randomized per server startup.
|
||||
content: list[dict[str, Any]] = []
|
||||
for text in texts:
|
||||
content.append({"prompt_string": text})
|
||||
for encoded in encoded_images:
|
||||
content.append(
|
||||
{
|
||||
"prompt_string": f"{self._media_marker}\n",
|
||||
"multimodal_data": [encoded],
|
||||
}
|
||||
)
|
||||
return content
|
||||
|
||||
def post_embeddings() -> requests.Response:
|
||||
return requests.post(
|
||||
f"{self.provider}/embeddings",
|
||||
json={"model": self.genai_config.model, "content": build_content()},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.provider}/embeddings",
|
||||
json={"model": self.genai_config.model, "content": content},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
response = post_embeddings()
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException:
|
||||
# The server may have restarted with a new media marker.
|
||||
# Refresh from /props; only retry if the marker actually changed.
|
||||
if not encoded_images or not self._refresh_media_marker():
|
||||
raise
|
||||
response = post_embeddings()
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
items = result.get("data", result) if isinstance(result, dict) else result
|
||||
|
||||
+140
-38
@@ -12,6 +12,7 @@ import os
|
||||
import subprocess as sp
|
||||
import threading
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Optional, cast
|
||||
|
||||
@@ -23,7 +24,7 @@ from frigate.const import REPLAY_CAMERA_PREFIX, REPLAY_DIR
|
||||
from frigate.jobs.export import JobStatePublisher
|
||||
from frigate.jobs.job import Job
|
||||
from frigate.jobs.manager import job_is_running, set_current_job
|
||||
from frigate.models import Recordings
|
||||
from frigate.models import Export, Recordings
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
from frigate.util.ffmpeg import run_ffmpeg_with_progress
|
||||
|
||||
@@ -114,6 +115,125 @@ def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> Mode
|
||||
return cast(ModelSelect, query)
|
||||
|
||||
|
||||
class DebugReplaySource(ABC):
|
||||
"""Abstract source for a debug replay session.
|
||||
|
||||
Provides the camera identity and time range the replay represents,
|
||||
validates that usable content exists, and supplies the ffmpeg input
|
||||
args used to build the replay clip.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def source_camera(self) -> str:
|
||||
"""Camera name the replay is derived from."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def start_ts(self) -> float:
|
||||
"""Unix timestamp marking the start of the replay range."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def end_ts(self) -> float:
|
||||
"""Unix timestamp marking the end of the replay range."""
|
||||
|
||||
@abstractmethod
|
||||
def validate(self) -> None:
|
||||
"""Raise ValueError if the source has no usable content."""
|
||||
|
||||
@abstractmethod
|
||||
def ffmpeg_input_args(self, working_dir: str) -> list[str]:
|
||||
"""Return ffmpeg input args (including -i). May write temp files in working_dir."""
|
||||
|
||||
def cleanup(self, working_dir: str) -> None:
|
||||
"""Remove any temp files the source created in working_dir. Default no-op."""
|
||||
|
||||
|
||||
class RecordingDebugReplaySource(DebugReplaySource):
|
||||
"""Replay source backed by the Recordings table.
|
||||
|
||||
Builds a concat playlist of recording files covering the time range
|
||||
and feeds it to ffmpeg's concat demuxer.
|
||||
"""
|
||||
|
||||
def __init__(self, source_camera: str, start_ts: float, end_ts: float) -> None:
|
||||
self._camera = source_camera
|
||||
self._start_ts = start_ts
|
||||
self._end_ts = end_ts
|
||||
self._concat_file: Optional[str] = None
|
||||
|
||||
@property
|
||||
def source_camera(self) -> str:
|
||||
return self._camera
|
||||
|
||||
@property
|
||||
def start_ts(self) -> float:
|
||||
return self._start_ts
|
||||
|
||||
@property
|
||||
def end_ts(self) -> float:
|
||||
return self._end_ts
|
||||
|
||||
def validate(self) -> None:
|
||||
if self._end_ts <= self._start_ts:
|
||||
raise ValueError("End time must be after start time")
|
||||
|
||||
if not query_recordings(self._camera, self._start_ts, self._end_ts).count():
|
||||
raise ValueError(
|
||||
f"No recordings found for camera '{self._camera}' in the specified time range"
|
||||
)
|
||||
|
||||
def ffmpeg_input_args(self, working_dir: str) -> list[str]:
|
||||
replay_name = f"{REPLAY_CAMERA_PREFIX}{self._camera}"
|
||||
concat_file = os.path.join(working_dir, f"{replay_name}_concat.txt")
|
||||
recordings = query_recordings(self._camera, self._start_ts, self._end_ts)
|
||||
with open(concat_file, "w") as f:
|
||||
for recording in recordings:
|
||||
f.write(f"file '{recording.path}'\n")
|
||||
self._concat_file = concat_file
|
||||
return ["-f", "concat", "-safe", "0", "-i", concat_file]
|
||||
|
||||
def cleanup(self, working_dir: str) -> None:
|
||||
if self._concat_file:
|
||||
_remove_silent(self._concat_file)
|
||||
|
||||
|
||||
class ExportDebugReplaySource(DebugReplaySource):
|
||||
"""Replay source backed by an existing Export.
|
||||
|
||||
Uses the export's video file directly as the ffmpeg input — does not
|
||||
require recordings to still exist for the time range.
|
||||
"""
|
||||
|
||||
def __init__(self, export: Export, duration: float) -> None:
|
||||
self._camera = cast(str, export.camera)
|
||||
# Export.date is declared DateTimeField but Frigate writes raw unix
|
||||
# timestamps to the column.
|
||||
self._start_ts = float(cast(Any, export.date))
|
||||
self._video_path = cast(str, export.video_path)
|
||||
self._duration = duration
|
||||
|
||||
@property
|
||||
def source_camera(self) -> str:
|
||||
return self._camera
|
||||
|
||||
@property
|
||||
def start_ts(self) -> float:
|
||||
return self._start_ts
|
||||
|
||||
@property
|
||||
def end_ts(self) -> float:
|
||||
return self._start_ts + self._duration
|
||||
|
||||
def validate(self) -> None:
|
||||
if not os.path.exists(self._video_path):
|
||||
raise ValueError(f"Export video file not found: {self._video_path}")
|
||||
|
||||
def ffmpeg_input_args(self, working_dir: str) -> list[str]:
|
||||
return ["-i", self._video_path]
|
||||
|
||||
|
||||
class DebugReplayJobRunner(threading.Thread):
|
||||
"""Worker thread that drives the startup job to completion.
|
||||
|
||||
@@ -126,6 +246,7 @@ class DebugReplayJobRunner(threading.Thread):
|
||||
def __init__(
|
||||
self,
|
||||
job: DebugReplayJob,
|
||||
source: DebugReplaySource,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
replay_manager: "DebugReplayManager",
|
||||
@@ -133,6 +254,7 @@ class DebugReplayJobRunner(threading.Thread):
|
||||
) -> None:
|
||||
super().__init__(daemon=True, name=f"debug_replay_{job.id}")
|
||||
self.job = job
|
||||
self.source = source
|
||||
self.frigate_config = frigate_config
|
||||
self.config_publisher = config_publisher
|
||||
self.replay_manager = replay_manager
|
||||
@@ -183,7 +305,6 @@ class DebugReplayJobRunner(threading.Thread):
|
||||
def run(self) -> None:
|
||||
replay_name = self.job.replay_camera_name
|
||||
os.makedirs(REPLAY_DIR, exist_ok=True)
|
||||
concat_file = os.path.join(REPLAY_DIR, f"{replay_name}_concat.txt")
|
||||
clip_path = os.path.join(REPLAY_DIR, f"{replay_name}.mp4")
|
||||
|
||||
self.job.status = JobStatusTypesEnum.running
|
||||
@@ -192,23 +313,13 @@ class DebugReplayJobRunner(threading.Thread):
|
||||
self._broadcast(force=True)
|
||||
|
||||
try:
|
||||
recordings = query_recordings(
|
||||
self.job.source_camera, self.job.start_ts, self.job.end_ts
|
||||
)
|
||||
with open(concat_file, "w") as f:
|
||||
for recording in recordings:
|
||||
f.write(f"file '{recording.path}'\n")
|
||||
input_args = self.source.ffmpeg_input_args(REPLAY_DIR)
|
||||
|
||||
ffmpeg_cmd = [
|
||||
self.frigate_config.ffmpeg.ffmpeg_path,
|
||||
"-hide_banner",
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
concat_file,
|
||||
*input_args,
|
||||
"-c",
|
||||
"copy",
|
||||
"-movflags",
|
||||
@@ -285,7 +396,7 @@ class DebugReplayJobRunner(threading.Thread):
|
||||
self.replay_manager.clear_session()
|
||||
_remove_silent(clip_path)
|
||||
finally:
|
||||
_remove_silent(concat_file)
|
||||
self.source.cleanup(REPLAY_DIR)
|
||||
_set_active_runner(None)
|
||||
|
||||
def _finalize_cancelled(self, clip_path: str) -> None:
|
||||
@@ -309,52 +420,43 @@ def _remove_silent(path: str) -> None:
|
||||
|
||||
def start_debug_replay_job(
|
||||
*,
|
||||
source_camera: str,
|
||||
start_ts: float,
|
||||
end_ts: float,
|
||||
source: DebugReplaySource,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
replay_manager: "DebugReplayManager",
|
||||
) -> str:
|
||||
"""Validate, create job, start runner. Returns the job id.
|
||||
|
||||
Raises ValueError for bad params (camera missing, time range
|
||||
invalid, no recordings) and RuntimeError if a session is already
|
||||
active.
|
||||
Raises ValueError for an invalid source (camera missing, source has
|
||||
no usable content) and RuntimeError if a session is already active.
|
||||
"""
|
||||
if job_is_running(JOB_TYPE) or replay_manager.active:
|
||||
raise RuntimeError("A replay session is already active")
|
||||
|
||||
if source_camera not in frigate_config.cameras:
|
||||
raise ValueError(f"Camera '{source_camera}' not found")
|
||||
if source.source_camera not in frigate_config.cameras:
|
||||
raise ValueError(f"Camera '{source.source_camera}' not found")
|
||||
|
||||
if end_ts <= start_ts:
|
||||
raise ValueError("End time must be after start time")
|
||||
source.validate()
|
||||
|
||||
recordings = query_recordings(source_camera, start_ts, end_ts)
|
||||
if not recordings.count():
|
||||
raise ValueError(
|
||||
f"No recordings found for camera '{source_camera}' in the specified time range"
|
||||
)
|
||||
|
||||
replay_name = f"{REPLAY_CAMERA_PREFIX}{source_camera}"
|
||||
replay_name = f"{REPLAY_CAMERA_PREFIX}{source.source_camera}"
|
||||
replay_manager.mark_starting(
|
||||
source_camera=source_camera,
|
||||
source_camera=source.source_camera,
|
||||
replay_camera_name=replay_name,
|
||||
start_ts=start_ts,
|
||||
end_ts=end_ts,
|
||||
start_ts=source.start_ts,
|
||||
end_ts=source.end_ts,
|
||||
)
|
||||
|
||||
job = DebugReplayJob(
|
||||
source_camera=source_camera,
|
||||
source_camera=source.source_camera,
|
||||
replay_camera_name=replay_name,
|
||||
start_ts=start_ts,
|
||||
end_ts=end_ts,
|
||||
start_ts=source.start_ts,
|
||||
end_ts=source.end_ts,
|
||||
)
|
||||
set_current_job(job)
|
||||
|
||||
runner = DebugReplayJobRunner(
|
||||
job=job,
|
||||
source=source,
|
||||
frigate_config=frigate_config,
|
||||
config_publisher=config_publisher,
|
||||
replay_manager=replay_manager,
|
||||
|
||||
@@ -15,11 +15,12 @@ class TestDebugReplayAPI(BaseTestHttp):
|
||||
# Stub the factory to skip validation/threading and just record the
|
||||
# name on the manager the way the real factory's mark_starting would.
|
||||
def fake_start(**kwargs):
|
||||
source = kwargs["source"]
|
||||
kwargs["replay_manager"].mark_starting(
|
||||
source_camera=kwargs["source_camera"],
|
||||
source_camera=source.source_camera,
|
||||
replay_camera_name="_replay_front",
|
||||
start_ts=kwargs["start_ts"],
|
||||
end_ts=kwargs["end_ts"],
|
||||
start_ts=source.start_ts,
|
||||
end_ts=source.end_ts,
|
||||
)
|
||||
return "job-1234"
|
||||
|
||||
|
||||
@@ -71,6 +71,14 @@ class TestDebugReplayManagerSession(unittest.TestCase):
|
||||
|
||||
|
||||
class TestDebugReplayManagerStop(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
# stop() publishes a terminal job_state via a real JobStatePublisher,
|
||||
# which opens a ZMQ REQ socket and blocks on REP. No dispatcher runs
|
||||
# in unit tests, so substitute a no-op publisher.
|
||||
patcher = patch("frigate.debug_replay.JobStatePublisher")
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_stop_when_inactive_is_a_noop(self) -> None:
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
from frigate.jobs.debug_replay import (
|
||||
DebugReplayJob,
|
||||
RecordingDebugReplaySource,
|
||||
cancel_debug_replay_job,
|
||||
get_active_runner,
|
||||
start_debug_replay_job,
|
||||
@@ -99,9 +100,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
def test_rejects_unknown_camera(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
start_debug_replay_job(
|
||||
source_camera="missing",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="missing", start_ts=100.0, end_ts=200.0
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
@@ -110,9 +111,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
def test_rejects_invalid_time_range(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=200.0,
|
||||
end_ts=100.0,
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=200.0, end_ts=100.0
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
@@ -124,9 +125,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
with patch("frigate.jobs.debug_replay.query_recordings", return_value=empty_qs):
|
||||
with self.assertRaises(ValueError):
|
||||
start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
@@ -154,9 +155,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
patch("builtins.open", unittest.mock.mock_open()),
|
||||
):
|
||||
job_id = start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
@@ -191,9 +192,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
patch("builtins.open", unittest.mock.mock_open()),
|
||||
):
|
||||
start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
@@ -201,9 +202,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
|
||||
with self.assertRaises(RuntimeError):
|
||||
start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
@@ -269,9 +270,9 @@ class TestRunnerHappyPath(unittest.TestCase):
|
||||
patch("builtins.open", unittest.mock.mock_open()),
|
||||
):
|
||||
start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
@@ -340,9 +341,9 @@ class TestRunnerFailurePath(unittest.TestCase):
|
||||
patch("builtins.open", unittest.mock.mock_open()),
|
||||
):
|
||||
start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
@@ -418,9 +419,9 @@ class TestRunnerCancellation(unittest.TestCase):
|
||||
patch("builtins.open", unittest.mock.mock_open()),
|
||||
):
|
||||
start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
|
||||
@@ -230,7 +230,7 @@ class TestExportResolution(unittest.TestCase):
|
||||
id=export_id,
|
||||
camera=camera,
|
||||
name=f"export-{export_id}",
|
||||
date=datetime.datetime.now(),
|
||||
date=int(datetime.datetime.now().timestamp()),
|
||||
video_path=f"/media/frigate/exports/{filename}",
|
||||
thumb_path=f"/media/frigate/exports/{filename}.jpg",
|
||||
in_progress=False,
|
||||
|
||||
@@ -357,6 +357,9 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
|
||||
def get_current_frame_time(self, camera: str) -> float:
|
||||
"""Returns the latest frame time for a given camera."""
|
||||
if camera not in self.camera_states:
|
||||
return 0.0
|
||||
|
||||
return self.camera_states[camera].current_frame_time
|
||||
|
||||
def set_sub_label(
|
||||
|
||||
Reference in New Issue
Block a user