Files
vf-cuda-grid/controller/cuda_grid_controller/snapshot_keeper.py
T
gx d0e34c9d31 controller: persistent ffmpeg snapshot keeper — /snapshot latency 5s → 4ms
SnapshotKeeper class — single long-running ffmpeg subprocess на каждый
instance, читает output_rtsp_url непрерывно и dumps latest frame в файл
(/tmp/snapshot-keeper/<instance>.png) каждые 500 ms через ffmpeg fps=2
-update 1. /snapshot endpoint просто serves файл — disk read ~4 ms (было
~5 sec на cold ffmpeg start + RTSP negotiate + keyframe wait).

Auto-restart с exponential backoff при exit (RTSP source перезапустился,
network glitch). Cold ffmpeg fallback остаётся в endpoint — если keeper
ещё не успел dump первый PNG (первые ~1-2 sec после controller start).

UI: snapshot poll interval 700 → 250 ms (4 fps preview, было ~1.4 fps).
Keeper dump rate сейчас 2 fps — practical limit. При желании поднять
до 4 fps — fps=4 в snapshot_keeper.py (~5% доп CPU на ffmpeg PNG encode).

Применение: основной user-facing path = visual overlay editor http://controller:8083/.
Раньше polling 700 ms показывал тот же frame несколько раз пока ffmpeg
запускался. Сейчас preview почти real-time, drag-and-drop reference точный.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:16:45 +01:00

110 lines
4.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Persistent ffmpeg subprocess для low-latency /snapshot endpoint.
Cold ffmpeg start + RTSP negotiate + keyframe wait занимает ~5 sec — это
неприемлемо для UI editor где preview обновляется каждые ~700 ms. Заменяем
"per-request ffmpeg" на single long-running ffmpeg который непрерывно читает
RTSP и dump'ит latest frame в file (`fps=2 -update 1`). Endpoint просто
serves этот file — latency ~50 ms (disk read).
Auto-restart при exit (RTSP source перезапустился, network glitch и т.д.).
"""
from __future__ import annotations
import asyncio
from pathlib import Path
import structlog
from .config import Config
log = structlog.get_logger()
class SnapshotKeeper:
"""Persistent ffmpeg per instance dumping latest frame to PNG file."""
def __init__(self, cfg: Config, snapshot_dir: Path) -> None:
self.cfg = cfg
self.snapshot_dir = snapshot_dir
self.snapshot_dir.mkdir(parents=True, exist_ok=True)
self._procs: dict[str, asyncio.subprocess.Process] = {}
self._tasks: dict[str, asyncio.Task] = {}
self._stop = False
def path(self, instance: str) -> Path | None:
"""Возвращает Path к PNG если файл существует (хотя бы один кадр был
записан), иначе None — caller может fallback к cold ffmpeg."""
p = self.snapshot_dir / f"{instance}.png"
return p if p.exists() else None
async def start(self) -> None:
for inst in self.cfg.instances:
if not inst.output_rtsp_url:
continue
self._tasks[inst.name] = asyncio.create_task(
self._supervisor(inst.name, inst.output_rtsp_url))
log.info("snapshot_keeper.started",
instances=list(self._tasks.keys()))
async def stop(self) -> None:
self._stop = True
for name, proc in self._procs.items():
try:
proc.terminate()
await asyncio.wait_for(proc.wait(), timeout=2)
except (ProcessLookupError, asyncio.TimeoutError):
try: proc.kill()
except ProcessLookupError: pass
for task in self._tasks.values():
task.cancel()
for task in self._tasks.values():
try: await task
except (asyncio.CancelledError, Exception): pass
async def _supervisor(self, name: str, url: str) -> None:
"""Loop: launch ffmpeg, wait exit, retry с backoff."""
backoff = 3.0
while not self._stop:
try:
await self._launch_and_wait(name, url)
except Exception as e:
log.warning("snapshot_keeper.launch_fail",
instance=name, error=str(e))
if self._stop:
break
await asyncio.sleep(backoff)
backoff = min(backoff * 1.5, 30.0)
async def _launch_and_wait(self, name: str, url: str) -> None:
out_path = self.snapshot_dir / f"{name}.png"
# fps=2 — 2 PNG/sec обновления (preview latency ~500 ms).
# -update 1 — overwrite single file атомарно (rename trick внутри
# image2 muxer).
proc = await asyncio.create_subprocess_exec(
"ffmpeg",
"-hide_banner", "-loglevel", "error",
"-rtsp_transport", "tcp",
"-i", url,
"-an", # без аудио — не нужно для preview
"-vf", "fps=2",
"-update", "1",
"-f", "image2",
"-y", str(out_path),
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
self._procs[name] = proc
log.info("snapshot_keeper.launched", instance=name, pid=proc.pid)
rc = await proc.wait()
del self._procs[name]
# Read stderr (might give hint о cause)
try:
err = (await proc.stderr.read(2048)).decode(errors="replace") if proc.stderr else ""
except Exception:
err = ""
if not self._stop:
log.warning("snapshot_keeper.exited",
instance=name, returncode=rc, stderr_tail=err[-300:])