mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-28 17:34:41 +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
+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,
|
||||
|
||||
Reference in New Issue
Block a user