controller: snapshot history + layout_map + UI grid

snapshot_history.py — async periodic capture per instance:
  interval_sec / keep_last (FIFO eviction) / dir configurable
  GET /snapshots/{instance}?limit=N → list metadata
  GET /snapshots/{instance}/{filename} → image bytes
  Persisted в /var/lib/cuda-grid/snapshots/{instance}/<ts>.png

layout_map / layout_filter_target в InstanceCfg — для будущей runtime switch
архитектуры (через streamselect либо filter rework — выбор за Phase 7).
Текущий _set_layout dispatches к layout_filter_target c index из map.

UI:
  + Layout buttons (quad/single/main_plus_preview placeholder)
  + Snapshot history grid с thumbnails (loaded /snapshots/{inst}?limit=24)
  + "Reload history" button

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gx
2026-05-21 05:45:11 +01:00
parent d90c139dce
commit d7b3e34c6b
6 changed files with 228 additions and 9 deletions
+7 -1
View File
@@ -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()
+21
View File
@@ -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):
+4 -3
View File
@@ -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:
+25 -4
View File
@@ -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(...)
@@ -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
@@ -49,6 +49,12 @@ pre { background:#0f0f12; padding:8px; border-radius:4px; font-size:11px; overfl
</div>
<div class="controls">
<!-- Layout -->
<div class="card">
<h2>Layout</h2>
<div class="row" id="layout-buttons"></div>
</div>
<!-- Audio -->
<div class="card">
<h2>Audio source</h2>
@@ -68,8 +74,10 @@ pre { background:#0f0f12; padding:8px; border-radius:4px; font-size:11px; overfl
<div class="card">
<h2>Snapshot</h2>
<div class="row">
<button class="primary" onclick="snap()">Take snapshot</button>
<button class="primary" onclick="snap()">Take now</button>
<button onclick="loadHistory()">Reload history</button>
</div>
<div id="history" style="display:grid; grid-template-columns:repeat(3,1fr); gap:4px; max-height:200px; overflow-y:auto; margin-top:4px"></div>
</div>
<!-- Manual overlay -->
@@ -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 = '<span style="color:var(--muted); font-size:11px">disabled</span>'; 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);
</script>