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>
This commit is contained in:
@@ -21,6 +21,7 @@ from .frigate_bridge import FrigateBridge, FrigateBridgeCfg
|
|||||||
from .http_api import create_app
|
from .http_api import create_app
|
||||||
from .mqtt_loop import MqttLoop
|
from .mqtt_loop import MqttLoop
|
||||||
from .snapshot_history import SnapshotHistory
|
from .snapshot_history import SnapshotHistory
|
||||||
|
from .snapshot_keeper import SnapshotKeeper
|
||||||
from .state import ControllerState
|
from .state import ControllerState
|
||||||
from .watchdog import StreamWatchdog, WatchdogCfg
|
from .watchdog import StreamWatchdog, WatchdogCfg
|
||||||
|
|
||||||
@@ -111,6 +112,10 @@ async def _run(cfg: Config) -> None:
|
|||||||
# Snapshot history (Phase 6+) — periodic capture per instance
|
# Snapshot history (Phase 6+) — periodic capture per instance
|
||||||
snapshot_hist = SnapshotHistory(cfg)
|
snapshot_hist = SnapshotHistory(cfg)
|
||||||
|
|
||||||
|
# Snapshot keeper — persistent ffmpeg per instance dumping latest frame
|
||||||
|
# к PNG (для low-latency /snapshot UI polling).
|
||||||
|
snapshot_keeper = SnapshotKeeper(cfg, Path("/tmp/snapshot-keeper"))
|
||||||
|
|
||||||
# Stream watchdog (Phase 1 resilience, issue #3) — monitor mediamtx paths
|
# Stream watchdog (Phase 1 resilience, issue #3) — monitor mediamtx paths
|
||||||
watchdog: StreamWatchdog | None = None
|
watchdog: StreamWatchdog | None = None
|
||||||
if cfg.watchdog:
|
if cfg.watchdog:
|
||||||
@@ -125,6 +130,7 @@ async def _run(cfg: Config) -> None:
|
|||||||
# HTTP REST
|
# HTTP REST
|
||||||
app = create_app(cfg, state, dispatcher,
|
app = create_app(cfg, state, dispatcher,
|
||||||
snapshot_history=snapshot_hist,
|
snapshot_history=snapshot_hist,
|
||||||
|
snapshot_keeper=snapshot_keeper,
|
||||||
frigate_bridge=frigate_bridge)
|
frigate_bridge=frigate_bridge)
|
||||||
server = uvicorn.Server(
|
server = uvicorn.Server(
|
||||||
uvicorn.Config(
|
uvicorn.Config(
|
||||||
@@ -149,6 +155,7 @@ async def _run(cfg: Config) -> None:
|
|||||||
if browser_renderer:
|
if browser_renderer:
|
||||||
await browser_renderer.start()
|
await browser_renderer.start()
|
||||||
await snapshot_hist.start()
|
await snapshot_hist.start()
|
||||||
|
await snapshot_keeper.start()
|
||||||
if watchdog:
|
if watchdog:
|
||||||
watchdog._publish_event = mqtt.publish_event
|
watchdog._publish_event = mqtt.publish_event
|
||||||
await watchdog.start()
|
await watchdog.start()
|
||||||
@@ -178,6 +185,7 @@ async def _run(cfg: Config) -> None:
|
|||||||
if browser_renderer:
|
if browser_renderer:
|
||||||
await browser_renderer.stop()
|
await browser_renderer.stop()
|
||||||
await snapshot_hist.stop()
|
await snapshot_hist.stop()
|
||||||
|
await snapshot_keeper.stop()
|
||||||
if watchdog:
|
if watchdog:
|
||||||
await watchdog.stop()
|
await watchdog.stop()
|
||||||
await dispatcher.close()
|
await dispatcher.close()
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class AudioOutputReq(BaseModel):
|
|||||||
def create_app(
|
def create_app(
|
||||||
cfg: Config, state: ControllerState, dispatcher: CommandDispatcher,
|
cfg: Config, state: ControllerState, dispatcher: CommandDispatcher,
|
||||||
snapshot_history: SnapshotHistory | None = None,
|
snapshot_history: SnapshotHistory | None = None,
|
||||||
|
snapshot_keeper=None,
|
||||||
frigate_bridge=None,
|
frigate_bridge=None,
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -221,11 +222,23 @@ def create_app(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def snapshot(instance: str) -> Response:
|
async def snapshot(instance: str) -> Response:
|
||||||
"""Capture single frame от output_rtsp_url. Returns PNG bytes (image/png)."""
|
"""Capture single frame от output_rtsp_url. Returns PNG bytes (image/png).
|
||||||
|
|
||||||
|
Fast path: persistent ffmpeg-keeper subprocess дампит latest frame в
|
||||||
|
файл каждые 500 ms — endpoint просто читает (latency ~10 ms). Cold path:
|
||||||
|
fork ffmpeg на каждый request (~5 sec) — fallback если keeper ещё не
|
||||||
|
создал файл (первые 1-2 sec после controller start) или у инстанса
|
||||||
|
нет output_rtsp_url.
|
||||||
|
"""
|
||||||
inst = _check_instance(instance)
|
inst = _check_instance(instance)
|
||||||
if not inst.output_rtsp_url:
|
if not inst.output_rtsp_url:
|
||||||
raise HTTPException(400, f"instance '{instance}' has no output_rtsp_url configured")
|
raise HTTPException(400, f"instance '{instance}' has no output_rtsp_url configured")
|
||||||
|
|
||||||
|
if snapshot_keeper:
|
||||||
|
path = snapshot_keeper.path(instance)
|
||||||
|
if path:
|
||||||
|
return FileResponse(path, media_type="image/png")
|
||||||
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||||
"-rtsp_transport", "tcp",
|
"-rtsp_transport", "tcp",
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""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:])
|
||||||
@@ -225,7 +225,10 @@ function initVideo() {
|
|||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
tick();
|
tick();
|
||||||
setInterval(tick, 700);
|
// 250 ms = 4 fps preview. Keeper dumps PNG @ 2 fps в файл, /snapshot reads
|
||||||
|
// disk за ~4 ms — practical limit это keeper update rate (можно поднять
|
||||||
|
// до fps=4 в snapshot_keeper.py если нужно ещё быстрее).
|
||||||
|
setInterval(tick, 250);
|
||||||
// expose для syncEditorBounds
|
// expose для syncEditorBounds
|
||||||
window.__previewEl = img;
|
window.__previewEl = img;
|
||||||
window.__previewSize = () => ({w: nativeW, h: nativeH});
|
window.__previewSize = () => ({w: nativeW, h: nativeH});
|
||||||
|
|||||||
Reference in New Issue
Block a user