diff --git a/controller/cuda_grid_controller/__main__.py b/controller/cuda_grid_controller/__main__.py index 432089a..bbea77c 100644 --- a/controller/cuda_grid_controller/__main__.py +++ b/controller/cuda_grid_controller/__main__.py @@ -17,6 +17,7 @@ from .dynamic_overlays import ChartCfg, ChatCfg, DynamicRenderer from .frigate_bridge import FrigateBridge, FrigateBridgeCfg from .http_api import create_app from .mqtt_loop import MqttLoop +from .snapshot_history import SnapshotHistory from .state import ControllerState cli = typer.Typer(add_completion=False) @@ -81,8 +82,11 @@ async def _run(cfg: Config) -> None: dispatcher.on_state_change = mqtt.publish_state dispatcher.on_event = mqtt.publish_event + # Snapshot history (Phase 6+) — periodic capture per instance + snapshot_hist = SnapshotHistory(cfg) + # HTTP REST - app = create_app(cfg, state, dispatcher) + app = create_app(cfg, state, dispatcher, snapshot_history=snapshot_hist) server = uvicorn.Server( uvicorn.Config( app, @@ -103,6 +107,7 @@ async def _run(cfg: Config) -> None: # Start dynamic renderer задачи (если есть) if dynamic_renderer: await dynamic_renderer.start() + await snapshot_hist.start() try: await asyncio.gather( @@ -114,6 +119,7 @@ async def _run(cfg: Config) -> None: finally: if dynamic_renderer: await dynamic_renderer.stop() + await snapshot_hist.stop() await dispatcher.close() await mqtt.stop() diff --git a/controller/cuda_grid_controller/config.py b/controller/cuda_grid_controller/config.py index 8dfd366..1a57637 100644 --- a/controller/cuda_grid_controller/config.py +++ b/controller/cuda_grid_controller/config.py @@ -93,6 +93,27 @@ class InstanceCfg(BaseModel): default=0.2, ge=0.0, le=1.0, description="Громкость music когда intercom активен (0.2 = -14 dB)", ) + layout_filter_target: str = Field( + default="streamselect@layout", + description="ZMQ target streamselect filter для runtime layout switching", + ) + layout_map: dict[str, int] = Field( + default_factory=lambda: {"quad": 0, "single": 1, "main_plus_preview": 2}, + description="layout name → streamselect map index (соответствует pipeline filter_complex)", + ) + snapshot_history: "SnapshotHistoryCfg" = Field( + default_factory=lambda: SnapshotHistoryCfg(), + description="Periodic snapshot capture в shared volume", + ) + + +class SnapshotHistoryCfg(BaseModel): + enabled: bool = False + interval_sec: int = Field(default=60, ge=5, le=3600) + keep_last: int = Field(default=120, ge=1, le=10000, + description="Сколько последних snapshots держать (FIFO eviction)") + dir: str = Field(default="/var/lib/cuda-grid/snapshots", + description="Базовая директория; instance-name становится поддиректорией") class AudioSourceCfg(BaseModel): diff --git a/controller/cuda_grid_controller/dispatch.py b/controller/cuda_grid_controller/dispatch.py index aa36366..5f52090 100644 --- a/controller/cuda_grid_controller/dispatch.py +++ b/controller/cuda_grid_controller/dispatch.py @@ -219,12 +219,12 @@ class CommandDispatcher: # ─── Layout ──────────────────────────────────────────────────── async def _set_layout(self, inst: InstanceCfg, layout: str) -> None: - if layout not in PREDEFINED_LAYOUTS: + if layout not in inst.layout_map: log.warning( "dispatch.unknown_layout", instance=inst.name, layout=layout, - available=PREDEFINED_LAYOUTS, + available=list(inst.layout_map.keys()), ) return @@ -232,12 +232,13 @@ class CommandDispatcher: client = self._client(inst) try: reply = await client.send_command( - inst.filter_target, "layout", layout + inst.layout_filter_target, "map", str(inst.layout_map[layout]) ) log.info( "dispatch.layout_set", instance=inst.name, layout=layout, + index=inst.layout_map[layout], ffmpeg_reply=reply, ) except (TimeoutError, Exception) as e: diff --git a/controller/cuda_grid_controller/http_api.py b/controller/cuda_grid_controller/http_api.py index cb59e08..fc3ad86 100644 --- a/controller/cuda_grid_controller/http_api.py +++ b/controller/cuda_grid_controller/http_api.py @@ -16,6 +16,7 @@ from .config import Config from .dispatch import CommandDispatcher from .layouts import PREDEFINED_LAYOUTS from .overlays import Overlay +from .snapshot_history import SnapshotHistory from .state import ControllerState log = structlog.get_logger() @@ -30,7 +31,8 @@ class AudioSetReq(BaseModel): def create_app( - cfg: Config, state: ControllerState, dispatcher: CommandDispatcher + cfg: Config, state: ControllerState, dispatcher: CommandDispatcher, + snapshot_history: SnapshotHistory | None = None, ) -> FastAPI: app = FastAPI( title="cuda-grid-controller", @@ -75,10 +77,10 @@ def create_app( @app.post("/layout/{instance}/set") async def set_layout(instance: str, req: LayoutSetReq) -> dict[str, Any]: - _check_instance(instance) - if req.layout not in PREDEFINED_LAYOUTS: + inst = _check_instance(instance) + if req.layout not in inst.layout_map: raise HTTPException( - 400, f"unknown layout '{req.layout}'. Доступны: {PREDEFINED_LAYOUTS}" + 400, f"unknown layout '{req.layout}'. Доступны: {list(inst.layout_map.keys())}" ) await dispatcher.handle(instance, "layout.set", req.layout) return {"ok": True, "instance": instance, "layout": req.layout} @@ -191,6 +193,25 @@ def create_app( log.info("snapshot.ok", instance=instance, bytes=len(png_data)) return Response(content=png_data, media_type="image/png") + # ─── Snapshot history (Phase 6+) ─────────────────────────────── + + @app.get("/snapshots/{instance}") + async def snapshots_list(instance: str, limit: int = 50) -> dict[str, Any]: + _check_instance(instance) + if snapshot_history is None: + raise HTTPException(404, "snapshot_history disabled") + return {"instance": instance, "items": snapshot_history.list_snapshots(instance, limit)} + + @app.get("/snapshots/{instance}/{filename}") + async def snapshots_get(instance: str, filename: str) -> Response: + _check_instance(instance) + if snapshot_history is None: + raise HTTPException(404, "snapshot_history disabled") + p = snapshot_history.path(instance, filename) + if p is None: + raise HTTPException(404, "snapshot not found") + return FileResponse(p, media_type="image/png") + @app.patch("/overlay/{instance}/{overlay_id}") async def overlay_update( instance: str, overlay_id: str, overlay: Overlay = Body(...) diff --git a/controller/cuda_grid_controller/snapshot_history.py b/controller/cuda_grid_controller/snapshot_history.py new file mode 100644 index 0000000..e28cdae --- /dev/null +++ b/controller/cuda_grid_controller/snapshot_history.py @@ -0,0 +1,128 @@ +"""Periodic snapshot capture + listing для каждой instance.""" + +from __future__ import annotations + +import asyncio +import time +from pathlib import Path +from typing import TYPE_CHECKING + +import structlog + +if TYPE_CHECKING: + from .config import Config, InstanceCfg + +log = structlog.get_logger() + + +class SnapshotHistory: + def __init__(self, cfg: "Config") -> None: + self.cfg = cfg + self._tasks: list[asyncio.Task] = [] + + def _dir_for(self, inst: "InstanceCfg") -> Path: + return Path(inst.snapshot_history.dir) / inst.name + + async def start(self) -> None: + for inst in self.cfg.instances: + if not inst.snapshot_history.enabled: + continue + if not inst.output_rtsp_url: + log.warning("snapshot.no_url", instance=inst.name) + continue + d = self._dir_for(inst) + d.mkdir(parents=True, exist_ok=True) + self._tasks.append(asyncio.create_task(self._loop(inst))) + log.info("snapshot_history.started", instance=inst.name, + interval=inst.snapshot_history.interval_sec, + keep_last=inst.snapshot_history.keep_last, dir=str(d)) + + async def stop(self) -> None: + for t in self._tasks: + t.cancel() + for t in self._tasks: + try: + await t + except asyncio.CancelledError: + pass + self._tasks.clear() + + async def _loop(self, inst: "InstanceCfg") -> None: + cfg = inst.snapshot_history + while True: + try: + await self._capture_one(inst) + self._evict(inst, cfg.keep_last) + except asyncio.CancelledError: + raise + except Exception as e: + log.error("snapshot_history.fail", instance=inst.name, error=str(e)) + await asyncio.sleep(cfg.interval_sec) + + async def _capture_one(self, inst: "InstanceCfg") -> None: + d = self._dir_for(inst) + ts = int(time.time()) + path = d / f"{ts}.png" + tmp = path.with_suffix(".png.tmp") + proc = await asyncio.create_subprocess_exec( + "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + "-rtsp_transport", "tcp", + "-i", inst.output_rtsp_url, + "-frames:v", "1", + "-f", "image2", "-c:v", "png", + str(tmp), + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + try: + _, err = await asyncio.wait_for(proc.communicate(), timeout=15) + except asyncio.TimeoutError: + proc.kill() + log.warning("snapshot_history.timeout", instance=inst.name) + tmp.unlink(missing_ok=True) + return + if proc.returncode != 0 or not tmp.exists(): + log.warning("snapshot_history.ffmpeg_fail", + instance=inst.name, + err=(err.decode(errors="replace")[:200] if err else "")) + tmp.unlink(missing_ok=True) + return + tmp.rename(path) + log.debug("snapshot_history.saved", instance=inst.name, + file=path.name, size=path.stat().st_size) + + def _evict(self, inst: "InstanceCfg", keep_last: int) -> None: + d = self._dir_for(inst) + files = sorted(d.glob("*.png")) + excess = len(files) - keep_last + if excess > 0: + for old in files[:excess]: + old.unlink(missing_ok=True) + + def list_snapshots(self, instance: str, limit: int = 50) -> list[dict]: + inst = next((i for i in self.cfg.instances if i.name == instance), None) + if inst is None or not inst.snapshot_history.enabled: + return [] + d = self._dir_for(inst) + if not d.exists(): + return [] + files = sorted(d.glob("*.png"), reverse=True)[:limit] + return [ + { + "filename": f.name, + "timestamp": int(f.stem), + "size": f.stat().st_size, + "url": f"/snapshots/{instance}/{f.name}", + } + for f in files + ] + + def path(self, instance: str, filename: str) -> Path | None: + inst = next((i for i in self.cfg.instances if i.name == instance), None) + if inst is None: + return None + # Защита от path traversal — only allow simple .png filenames + if "/" in filename or ".." in filename or not filename.endswith(".png"): + return None + p = self._dir_for(inst) / filename + return p if p.exists() else None diff --git a/controller/cuda_grid_controller/static/index.html b/controller/cuda_grid_controller/static/index.html index b75db12..a6f4b2e 100644 --- a/controller/cuda_grid_controller/static/index.html +++ b/controller/cuda_grid_controller/static/index.html @@ -49,6 +49,12 @@ pre { background:#0f0f12; padding:8px; border-radius:4px; font-size:11px; overfl
+ +
+

Layout

+
+
+

Audio source

@@ -68,8 +74,10 @@ pre { background:#0f0f12; padding:8px; border-radius:4px; font-size:11px; overfl

Snapshot

- + +
+
@@ -161,6 +169,22 @@ async function api(method, path, body=null, okMsg='ok') { } catch (e) { toast('fail: '+e.message, false); } } +// ── Layout buttons ──────────────────────────────────── +async function loadLayouts() { + const r = await fetch('/state'); + if (!r.ok) return; + // Hardcode для simplicity — реальный list в controller config layout_map + const layouts = ['quad', 'single', 'main_plus_preview']; + const box = document.getElementById('layout-buttons'); + box.innerHTML = ''; + layouts.forEach(name => { + const b = document.createElement('button'); + b.textContent = name; + b.onclick = () => api('POST', `/layout/${INSTANCE}/set`, {layout:name}, '→ '+name); + box.appendChild(b); + }); +} + // ── Audio buttons (build dynamically) ───────────────── async function loadAudio() { const r = await fetch(`/audio/${INSTANCE}`); @@ -184,6 +208,22 @@ async function snap() { const url = URL.createObjectURL(blob); window.open(url, '_blank'); } +async function loadHistory() { + const r = await fetch(`/snapshots/${INSTANCE}?limit=24`); + if (!r.ok) { document.getElementById('history').innerHTML = 'disabled'; return; } + const d = await r.json(); + const box = document.getElementById('history'); + box.innerHTML = ''; + d.items.forEach(it => { + const a = document.createElement('a'); + a.href = it.url; a.target = '_blank'; + a.title = new Date(it.timestamp*1000).toLocaleString(); + const img = document.createElement('img'); + img.src = it.url; img.style.cssText = 'width:100%; border-radius:2px; cursor:pointer'; + a.appendChild(img); + box.appendChild(a); + }); +} // ── Manual overlay ──────────────────────────────────── function ovTypeChanged() { @@ -266,7 +306,9 @@ async function refreshState() { // ── Init ────────────────────────────────────────────── ovTypeChanged(); initVideo(); +loadLayouts(); loadAudio(); +loadHistory(); refreshState(); setInterval(refreshState, 2000);