mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 15:05:26 +03:00
Compare commits
No commits in common. "cf672badb558d3ff20ad9e1101a53a8196a15f04" and "ed9b098469cc2a08f3d5204c701e6151c309377a" have entirely different histories.
cf672badb5
...
ed9b098469
@ -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:
|
except RuntimeError as exc:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "A replay session is already active",
|
"message": str(exc),
|
||||||
},
|
},
|
||||||
status_code=409,
|
status_code=409,
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError as exc:
|
||||||
logger.exception("Rejected debug replay start request")
|
logger.info("Rejected debug replay start request: %s", exc)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Invalid debug replay parameters",
|
"message": str(exc),
|
||||||
},
|
},
|
||||||
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.0
|
retry_interval = 10
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@ -174,10 +174,12 @@ async def latest_frame(
|
|||||||
}
|
}
|
||||||
quality_params = get_image_quality_params(extension.value, params.quality)
|
quality_params = get_image_quality_params(extension.value, params.quality)
|
||||||
|
|
||||||
camera_config = request.app.frigate_config.cameras.get(camera_name)
|
if camera_name in request.app.frigate_config.cameras:
|
||||||
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(camera_config.ffmpeg.retry_interval or 10)
|
retry_interval = float(
|
||||||
|
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() > (
|
||||||
|
|||||||
@ -13,9 +13,7 @@ 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, cast
|
from typing import TYPE_CHECKING, Any, Optional
|
||||||
|
|
||||||
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
|
||||||
@ -92,12 +90,12 @@ class DebugReplayJob(Job):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> ModelSelect:
|
def query_recordings(source_camera: str, start_ts: float, end_ts: float):
|
||||||
"""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.
|
||||||
"""
|
"""
|
||||||
query = (
|
return (
|
||||||
Recordings.select(
|
Recordings.select(
|
||||||
Recordings.path,
|
Recordings.path,
|
||||||
Recordings.start_time,
|
Recordings.start_time,
|
||||||
@ -111,7 +109,6 @@ def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> Mode
|
|||||||
.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):
|
||||||
|
|||||||
@ -61,9 +61,7 @@ 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"])
|
||||||
# Message is hard-coded so we don't echo exception text back to clients
|
self.assertIn("missing", body["message"])
|
||||||
# (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(
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -21,6 +21,7 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -117,6 +117,8 @@ 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();
|
||||||
@ -143,6 +145,12 @@ 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);
|
||||||
@ -167,6 +175,9 @@ 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) => {
|
||||||
@ -194,10 +205,35 @@ 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 "";
|
||||||
@ -400,8 +436,6 @@ 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}>
|
||||||
{status.live_ready ? (
|
|
||||||
<>
|
|
||||||
<AutoUpdatingCameraImage
|
<AutoUpdatingCameraImage
|
||||||
className="size-full"
|
className="size-full"
|
||||||
cameraClasses="relative w-full h-full flex flex-col justify-start"
|
cameraClasses="relative w-full h-full flex flex-col justify-start"
|
||||||
@ -422,8 +456,7 @@ export default function Replay() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
{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">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user