Debug replay fixes (#23270)

* ensure motion masks from source camera are copied to replay

* stop polling debug_replay/status after live_ready

* use vod for constructing replay clips
This commit is contained in:
Josh Hawkins 2026-05-20 16:37:02 -05:00 committed by GitHub
parent a576ad5218
commit 5ef8b9b924
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 77 additions and 28 deletions

View File

@ -86,10 +86,15 @@ class DebugReplayStopResponse(BaseModel):
async def start_debug_replay(request: Request, body: DebugReplayStartBody): async def start_debug_replay(request: Request, body: DebugReplayStartBody):
"""Start a debug replay session asynchronously.""" """Start a debug replay session asynchronously."""
replay_manager = request.app.replay_manager replay_manager = request.app.replay_manager
internal_port = request.app.frigate_config.networking.listen.internal
if type(internal_port) is str:
internal_port = int(internal_port.split(":")[-1])
source = RecordingDebugReplaySource( source = RecordingDebugReplaySource(
source_camera=body.camera, source_camera=body.camera,
start_ts=body.start_time, start_ts=body.start_time,
end_ts=body.end_time, end_ts=body.end_time,
internal_port=internal_port,
) )
try: try:

View File

@ -245,11 +245,23 @@ class DebugReplayManager:
"frame_shape", "frame_shape",
"raw_mask", "raw_mask",
"mask", "mask",
"improved_contrast_enabled", "enabled_in_config",
"rasterized_mask", "rasterized_mask",
} }
) )
if source_config.motion.mask:
motion_dict["mask"] = {
mask_id: (
mask_cfg.model_dump(
exclude={"raw_coordinates", "enabled_in_config"}
)
if mask_cfg is not None
else None
)
for mask_id, mask_cfg in source_config.motion.mask.items()
}
return { return {
"enabled": True, "enabled": True,
"ffmpeg": { "ffmpeg": {

View File

@ -1,4 +1,4 @@
"""Debug replay startup job: ffmpeg concat + camera config publish. """Debug replay startup job: ffmpeg remux + camera config publish.
The runner orchestrates the async portion of starting a debug replay The runner orchestrates the async portion of starting a debug replay
session. The DebugReplayManager (in frigate.debug_replay) owns session session. The DebugReplayManager (in frigate.debug_replay) owns session
@ -153,15 +153,22 @@ class DebugReplaySource(ABC):
class RecordingDebugReplaySource(DebugReplaySource): class RecordingDebugReplaySource(DebugReplaySource):
"""Replay source backed by the Recordings table. """Replay source backed by the Recordings table.
Builds a concat playlist of recording files covering the time range Feeds ffmpeg the internal VOD endpoint so segments with mismatched
and feeds it to ffmpeg's concat demuxer. SPS/PPS (e.g. across day/night transitions) stitch cleanly via HLS
discontinuities.
""" """
def __init__(self, source_camera: str, start_ts: float, end_ts: float) -> None: def __init__(
self,
source_camera: str,
start_ts: float,
end_ts: float,
internal_port: int,
) -> None:
self._camera = source_camera self._camera = source_camera
self._start_ts = start_ts self._start_ts = start_ts
self._end_ts = end_ts self._end_ts = end_ts
self._concat_file: Optional[str] = None self._internal_port = internal_port
@property @property
def source_camera(self) -> str: def source_camera(self) -> str:
@ -185,18 +192,16 @@ class RecordingDebugReplaySource(DebugReplaySource):
) )
def ffmpeg_input_args(self, working_dir: str) -> list[str]: def ffmpeg_input_args(self, working_dir: str) -> list[str]:
replay_name = f"{REPLAY_CAMERA_PREFIX}{self._camera}" playlist_url = (
concat_file = os.path.join(working_dir, f"{replay_name}_concat.txt") f"http://127.0.0.1:{self._internal_port}/vod/{self._camera}"
recordings = query_recordings(self._camera, self._start_ts, self._end_ts) f"/start/{self._start_ts}/end/{self._end_ts}/index.m3u8"
with open(concat_file, "w") as f: )
for recording in recordings: return [
f.write(f"file '{recording.path}'\n") "-protocol_whitelist",
self._concat_file = concat_file "pipe,file,http,tcp",
return ["-f", "concat", "-safe", "0", "-i", concat_file] "-i",
playlist_url,
def cleanup(self, working_dir: str) -> None: ]
if self._concat_file:
_remove_silent(self._concat_file)
class ExportDebugReplaySource(DebugReplaySource): class ExportDebugReplaySource(DebugReplaySource):

View File

@ -101,7 +101,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
start_debug_replay_job( start_debug_replay_job(
source=RecordingDebugReplaySource( source=RecordingDebugReplaySource(
source_camera="missing", start_ts=100.0, end_ts=200.0 source_camera="missing",
start_ts=100.0,
end_ts=200.0,
internal_port=5000,
), ),
frigate_config=self.frigate_config, frigate_config=self.frigate_config,
config_publisher=self.publisher, config_publisher=self.publisher,
@ -112,7 +115,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
start_debug_replay_job( start_debug_replay_job(
source=RecordingDebugReplaySource( source=RecordingDebugReplaySource(
source_camera="front", start_ts=200.0, end_ts=100.0 source_camera="front",
start_ts=200.0,
end_ts=100.0,
internal_port=5000,
), ),
frigate_config=self.frigate_config, frigate_config=self.frigate_config,
config_publisher=self.publisher, config_publisher=self.publisher,
@ -126,7 +132,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
start_debug_replay_job( start_debug_replay_job(
source=RecordingDebugReplaySource( source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0 source_camera="front",
start_ts=100.0,
end_ts=200.0,
internal_port=5000,
), ),
frigate_config=self.frigate_config, frigate_config=self.frigate_config,
config_publisher=self.publisher, config_publisher=self.publisher,
@ -156,7 +165,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
): ):
job_id = start_debug_replay_job( job_id = start_debug_replay_job(
source=RecordingDebugReplaySource( source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0 source_camera="front",
start_ts=100.0,
end_ts=200.0,
internal_port=5000,
), ),
frigate_config=self.frigate_config, frigate_config=self.frigate_config,
config_publisher=self.publisher, config_publisher=self.publisher,
@ -193,7 +205,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
): ):
start_debug_replay_job( start_debug_replay_job(
source=RecordingDebugReplaySource( source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0 source_camera="front",
start_ts=100.0,
end_ts=200.0,
internal_port=5000,
), ),
frigate_config=self.frigate_config, frigate_config=self.frigate_config,
config_publisher=self.publisher, config_publisher=self.publisher,
@ -203,7 +218,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError):
start_debug_replay_job( start_debug_replay_job(
source=RecordingDebugReplaySource( source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0 source_camera="front",
start_ts=100.0,
end_ts=200.0,
internal_port=5000,
), ),
frigate_config=self.frigate_config, frigate_config=self.frigate_config,
config_publisher=self.publisher, config_publisher=self.publisher,
@ -271,7 +289,10 @@ class TestRunnerHappyPath(unittest.TestCase):
): ):
start_debug_replay_job( start_debug_replay_job(
source=RecordingDebugReplaySource( source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0 source_camera="front",
start_ts=100.0,
end_ts=200.0,
internal_port=5000,
), ),
frigate_config=self.frigate_config, frigate_config=self.frigate_config,
config_publisher=self.publisher, config_publisher=self.publisher,
@ -342,7 +363,10 @@ class TestRunnerFailurePath(unittest.TestCase):
): ):
start_debug_replay_job( start_debug_replay_job(
source=RecordingDebugReplaySource( source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0 source_camera="front",
start_ts=100.0,
end_ts=200.0,
internal_port=5000,
), ),
frigate_config=self.frigate_config, frigate_config=self.frigate_config,
config_publisher=self.publisher, config_publisher=self.publisher,
@ -420,7 +444,10 @@ class TestRunnerCancellation(unittest.TestCase):
): ):
start_debug_replay_job( start_debug_replay_job(
source=RecordingDebugReplaySource( source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0 source_camera="front",
start_ts=100.0,
end_ts=200.0,
internal_port=5000,
), ),
frigate_config=self.frigate_config, frigate_config=self.frigate_config,
config_publisher=self.publisher, config_publisher=self.publisher,

View File

@ -121,7 +121,7 @@ export default function Replay() {
mutate: refreshStatus, mutate: refreshStatus,
isLoading, isLoading,
} = useSWR<DebugReplayStatus>("debug_replay/status", { } = useSWR<DebugReplayStatus>("debug_replay/status", {
refreshInterval: 1000, refreshInterval: (latestData) => (latestData?.live_ready ? 0 : 1000),
}); });
const { payload: replayJob } = const { payload: replayJob } =
useJobStatus<DebugReplayJobResults>("debug_replay"); useJobStatus<DebugReplayJobResults>("debug_replay");