Compare commits

...

9 Commits

Author SHA1 Message Date
Josh Hawkins
cf672badb5 remove toast when successfully stopping
it gets hidden almost immediately
2026-05-03 13:01:04 -05:00
Josh Hawkins
dfaa178c0e fix race in latest_frame on debug replay shutdown 2026-05-03 12:51:06 -05:00
Josh Hawkins
51624f755f simplify loading logic 2026-05-03 12:46:20 -05:00
Josh Hawkins
e86c94f294 don't try to show camera image until status reports ready 2026-05-03 12:40:56 -05:00
Josh Hawkins
5ddd824b49 fix test 2026-05-03 12:32:38 -05:00
Josh Hawkins
ccbe2fda9a fix 2026-05-03 12:25:27 -05:00
Josh Hawkins
2ec94bea13 formatting 2026-05-03 12:18:30 -05:00
Josh Hawkins
513cc18715 clean up 2026-05-03 12:17:25 -05:00
Josh Hawkins
14da821956 mypy 2026-05-03 12:17:13 -05:00
7 changed files with 52 additions and 83 deletions

View File

@ -84,20 +84,20 @@ async def start_debug_replay(request: Request, body: DebugReplayStartBody):
config_publisher=request.app.config_publisher, config_publisher=request.app.config_publisher,
replay_manager=replay_manager, replay_manager=replay_manager,
) )
except RuntimeError as exc: except RuntimeError:
return JSONResponse( return JSONResponse(
content={ content={
"success": False, "success": False,
"message": str(exc), "message": "A replay session is already active",
}, },
status_code=409, status_code=409,
) )
except ValueError as exc: except ValueError:
logger.info("Rejected debug replay start request: %s", exc) logger.exception("Rejected debug replay start request")
return JSONResponse( return JSONResponse(
content={ content={
"success": False, "success": False,
"message": str(exc), "message": "Invalid debug replay parameters",
}, },
status_code=400, status_code=400,
) )
@ -137,7 +137,7 @@ def get_debug_replay_status(request: Request):
if frame is not None: if frame is not None:
frame_time = frame_processor.get_current_frame_time(replay_camera) frame_time = frame_processor.get_current_frame_time(replay_camera)
camera_config = request.app.frigate_config.cameras.get(replay_camera) camera_config = request.app.frigate_config.cameras.get(replay_camera)
retry_interval = 10 retry_interval = 10.0
if camera_config is not None: if camera_config is not None:
retry_interval = float(camera_config.ffmpeg.retry_interval or 10) retry_interval = float(camera_config.ffmpeg.retry_interval or 10)

View File

@ -174,12 +174,10 @@ async def latest_frame(
} }
quality_params = get_image_quality_params(extension.value, params.quality) quality_params = get_image_quality_params(extension.value, params.quality)
if camera_name in request.app.frigate_config.cameras: camera_config = request.app.frigate_config.cameras.get(camera_name)
if camera_config is not None:
frame = frame_processor.get_current_frame(camera_name, draw_options) frame = frame_processor.get_current_frame(camera_name, draw_options)
retry_interval = float( retry_interval = float(camera_config.ffmpeg.retry_interval or 10)
request.app.frigate_config.cameras.get(camera_name).ffmpeg.retry_interval
or 10
)
is_offline = False is_offline = False
if frame is None or datetime.now().timestamp() > ( if frame is None or datetime.now().timestamp() > (

View File

@ -13,7 +13,9 @@ import subprocess as sp
import threading import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Optional from typing import TYPE_CHECKING, Any, Optional, cast
from peewee import ModelSelect
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher from frigate.config.camera.updater import CameraConfigUpdatePublisher
@ -90,12 +92,12 @@ class DebugReplayJob(Job):
} }
def query_recordings(source_camera: str, start_ts: float, end_ts: float): def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> ModelSelect:
"""Return the Recordings query for the time range. """Return the Recordings query for the time range.
Module-level so tests can patch it without instantiating a runner. Module-level so tests can patch it without instantiating a runner.
""" """
return ( query = (
Recordings.select( Recordings.select(
Recordings.path, Recordings.path,
Recordings.start_time, Recordings.start_time,
@ -109,6 +111,7 @@ def query_recordings(source_camera: str, start_ts: float, end_ts: float):
.where(Recordings.camera == source_camera) .where(Recordings.camera == source_camera)
.order_by(Recordings.start_time.asc()) .order_by(Recordings.start_time.asc())
) )
return cast(ModelSelect, query)
class DebugReplayJobRunner(threading.Thread): class DebugReplayJobRunner(threading.Thread):

View File

@ -61,7 +61,9 @@ class TestDebugReplayAPI(BaseTestHttp):
self.assertEqual(resp.status_code, 400) self.assertEqual(resp.status_code, 400)
body = resp.json() body = resp.json()
self.assertFalse(body["success"]) self.assertFalse(body["success"])
self.assertIn("missing", body["message"]) # Message is hard-coded so we don't echo exception text back to clients
# (CodeQL: information exposure through an exception).
self.assertEqual(body["message"], "Invalid debug replay parameters")
def test_start_returns_409_when_session_already_active(self): def test_start_returns_409_when_session_already_active(self):
with patch( with patch(

View File

@ -53,10 +53,10 @@ logger = logging.getLogger(__name__)
def inject_progress_flags(cmd: list[str]) -> list[str]: def inject_progress_flags(cmd: list[str]) -> list[str]:
"""Insert ``-progress pipe:2 -nostats`` immediately before the output path. """Insert `-progress pipe:2 -nostats` immediately before the output path.
``-progress pipe:2`` writes structured key=value lines to stderr; `-progress pipe:2` writes structured key=value lines to stderr;
``-nostats`` suppresses the noisy default stats output. The output path `-nostats` suppresses the noisy default stats output. The output path
is conventionally the last token in an FFmpeg argv. is conventionally the last token in an FFmpeg argv.
""" """
if not cmd: if not cmd:
@ -73,24 +73,24 @@ def run_ffmpeg_with_progress(
process_started: Optional[Callable[[sp.Popen], None]] = None, process_started: Optional[Callable[[sp.Popen], None]] = None,
use_low_priority: bool = True, use_low_priority: bool = True,
) -> tuple[int, str]: ) -> tuple[int, str]:
"""Run an ffmpeg command, streaming progress via ``-progress pipe:2``. """Run an ffmpeg command, streaming progress via `-progress pipe:2`.
Args: Args:
cmd: ffmpeg argv. Output path must be the last token. cmd: ffmpeg argv. Output path must be the last token.
expected_duration_seconds: Duration of the expected output clip in expected_duration_seconds: Duration of the expected output clip in
seconds. Used to convert ffmpeg's ``out_time_us`` into a percent. seconds. Used to convert ffmpeg's `out_time_us` into a percent.
on_progress: Optional callback invoked with a percent in [0, 100]. on_progress: Optional callback invoked with a percent in [0, 100].
Called once with 0.0 at start, again on each ``out_time_us=`` Called once with 0.0 at start, again on each `out_time_us=`
stderr line, and once with 100.0 on ``progress=end``. stderr line, and once with 100.0 on `progress=end`.
stdin_payload: Optional string written to ffmpeg stdin (used by stdin_payload: Optional string written to ffmpeg stdin (used by
export for concat playlists). export for concat playlists).
process_started: Optional callback invoked with the live ``Popen`` process_started: Optional callback invoked with the live `Popen`
once spawned lets callers store the ref for cancellation. once spawned lets callers store the ref for cancellation.
use_low_priority: When True, prepend ``nice -n PROCESS_PRIORITY_LOW`` use_low_priority: When True, prepend `nice -n PROCESS_PRIORITY_LOW`
so concat doesn't starve detection. so concat doesn't starve detection.
Returns: Returns:
Tuple of ``(returncode, captured_stderr)``. Stdout is left attached Tuple of `(returncode, captured_stderr)`. Stdout is left attached
to the parent process to avoid buffer-full deadlocks. to the parent process to avoid buffer-full deadlocks.
""" """
full_cmd = inject_progress_flags(cmd) full_cmd = inject_progress_flags(cmd)

View File

@ -21,7 +21,6 @@
"toast": { "toast": {
"error": "Failed to start debug replay: {{error}}", "error": "Failed to start debug replay: {{error}}",
"alreadyActive": "A replay session is already active", "alreadyActive": "A replay session is already active",
"stopped": "Debug replay stopped",
"stopError": "Failed to stop debug replay: {{error}}", "stopError": "Failed to stop debug replay: {{error}}",
"goToReplay": "Go to Replay" "goToReplay": "Go to Replay"
} }

View File

@ -117,8 +117,6 @@ const DEBUG_OPTION_I18N_KEY: Record<keyof DebugOptions, string> = {
paths: "paths", paths: "paths",
}; };
const REPLAY_INIT_SKELETON_TIMEOUT_MS = 8000;
export default function Replay() { export default function Replay() {
const { t } = useTranslation(["views/replay", "views/settings", "common"]); const { t } = useTranslation(["views/replay", "views/settings", "common"]);
const navigate = useNavigate(); const navigate = useNavigate();
@ -145,12 +143,6 @@ export default function Replay() {
initializeStatus(); initializeStatus();
}, [refreshStatus]); }, [refreshStatus]);
useEffect(() => {
if (status?.live_ready) {
setShowReplayInitSkeleton(false);
}
}, [status?.live_ready]);
const [options, setOptions] = useState<DebugOptions>(DEFAULT_OPTIONS); const [options, setOptions] = useState<DebugOptions>(DEFAULT_OPTIONS);
const [isStopping, setIsStopping] = useState(false); const [isStopping, setIsStopping] = useState(false);
const [configDialogOpen, setConfigDialogOpen] = useState(false); const [configDialogOpen, setConfigDialogOpen] = useState(false);
@ -175,9 +167,6 @@ export default function Replay() {
axios axios
.post("debug_replay/stop") .post("debug_replay/stop")
.then(() => { .then(() => {
toast.success(t("dialog.toast.stopped"), {
position: "top-center",
});
refreshStatus(); refreshStatus();
}) })
.catch((error) => { .catch((error) => {
@ -205,35 +194,10 @@ export default function Replay() {
const { objects } = useCameraActivity(replayCameraConfig); const { objects } = useCameraActivity(replayCameraConfig);
const [showReplayInitSkeleton, setShowReplayInitSkeleton] = useState(false);
// debug draw // debug draw
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [debugDraw, setDebugDraw] = useState(false); const [debugDraw, setDebugDraw] = useState(false);
useEffect(() => {
if (!status?.active || !status.replay_camera) {
setShowReplayInitSkeleton(false);
return;
}
setShowReplayInitSkeleton(true);
const timeout = window.setTimeout(() => {
setShowReplayInitSkeleton(false);
}, REPLAY_INIT_SKELETON_TIMEOUT_MS);
return () => {
window.clearTimeout(timeout);
};
}, [status?.active, status?.replay_camera]);
useEffect(() => {
if (status?.live_ready) {
setShowReplayInitSkeleton(false);
}
}, [status?.live_ready]);
// Format time range for display // Format time range for display
const timeRangeDisplay = useMemo(() => { const timeRangeDisplay = useMemo(() => {
if (!status?.start_time || !status?.end_time) return ""; if (!status?.start_time || !status?.end_time) return "";
@ -436,27 +400,30 @@ export default function Replay() {
) : ( ) : (
status.replay_camera && ( status.replay_camera && (
<div className="relative size-full min-h-10" ref={containerRef}> <div className="relative size-full min-h-10" ref={containerRef}>
<AutoUpdatingCameraImage {status.live_ready ? (
className="size-full" <>
cameraClasses="relative w-full h-full flex flex-col justify-start" <AutoUpdatingCameraImage
searchParams={searchParams} className="size-full"
camera={status.replay_camera} cameraClasses="relative w-full h-full flex flex-col justify-start"
showFps={false} searchParams={searchParams}
/> camera={status.replay_camera}
{debugDraw && ( showFps={false}
<DebugDrawingLayer />
containerRef={containerRef} {debugDraw && (
cameraWidth={ <DebugDrawingLayer
config?.cameras?.[status.source_camera ?? ""]?.detect containerRef={containerRef}
.width ?? 1280 cameraWidth={
} config?.cameras?.[status.source_camera ?? ""]?.detect
cameraHeight={ .width ?? 1280
config?.cameras?.[status.source_camera ?? ""]?.detect }
.height ?? 720 cameraHeight={
} config?.cameras?.[status.source_camera ?? ""]?.detect
/> .height ?? 720
)} }
{showReplayInitSkeleton && ( />
)}
</>
) : (
<div className="pointer-events-none absolute inset-0 z-10 size-full rounded-lg bg-background"> <div className="pointer-events-none absolute inset-0 z-10 size-full rounded-lg bg-background">
<Skeleton className="size-full rounded-lg" /> <Skeleton className="size-full rounded-lg" />
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center gap-2"> <div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center gap-2">