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,
replay_manager=replay_manager,
)
except RuntimeError as exc:
except RuntimeError:
return JSONResponse(
content={
"success": False,
"message": str(exc),
"message": "A replay session is already active",
},
status_code=409,
)
except ValueError as exc:
logger.info("Rejected debug replay start request: %s", exc)
except ValueError:
logger.exception("Rejected debug replay start request")
return JSONResponse(
content={
"success": False,
"message": str(exc),
"message": "Invalid debug replay parameters",
},
status_code=400,
)
@ -137,7 +137,7 @@ def get_debug_replay_status(request: Request):
if frame is not None:
frame_time = frame_processor.get_current_frame_time(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:
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)
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)
retry_interval = float(
request.app.frigate_config.cameras.get(camera_name).ffmpeg.retry_interval
or 10
)
retry_interval = float(camera_config.ffmpeg.retry_interval or 10)
is_offline = False
if frame is None or datetime.now().timestamp() > (

View File

@ -13,7 +13,9 @@ import subprocess as sp
import threading
import time
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.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.
Module-level so tests can patch it without instantiating a runner.
"""
return (
query = (
Recordings.select(
Recordings.path,
Recordings.start_time,
@ -109,6 +111,7 @@ def query_recordings(source_camera: str, start_ts: float, end_ts: float):
.where(Recordings.camera == source_camera)
.order_by(Recordings.start_time.asc())
)
return cast(ModelSelect, query)
class DebugReplayJobRunner(threading.Thread):

View File

@ -61,7 +61,9 @@ class TestDebugReplayAPI(BaseTestHttp):
self.assertEqual(resp.status_code, 400)
body = resp.json()
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):
with patch(

View File

@ -53,10 +53,10 @@ logger = logging.getLogger(__name__)
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;
``-nostats`` suppresses the noisy default stats output. The output path
`-progress pipe:2` writes structured key=value lines to stderr;
`-nostats` suppresses the noisy default stats output. The output path
is conventionally the last token in an FFmpeg argv.
"""
if not cmd:
@ -73,24 +73,24 @@ def run_ffmpeg_with_progress(
process_started: Optional[Callable[[sp.Popen], None]] = None,
use_low_priority: bool = True,
) -> tuple[int, str]:
"""Run an ffmpeg command, streaming progress via ``-progress pipe:2``.
"""Run an ffmpeg command, streaming progress via `-progress pipe:2`.
Args:
cmd: ffmpeg argv. Output path must be the last token.
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].
Called once with 0.0 at start, again on each ``out_time_us=``
stderr line, and once with 100.0 on ``progress=end``.
Called once with 0.0 at start, again on each `out_time_us=`
stderr line, and once with 100.0 on `progress=end`.
stdin_payload: Optional string written to ffmpeg stdin (used by
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.
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.
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.
"""
full_cmd = inject_progress_flags(cmd)

View File

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

View File

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