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:
@@ -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()
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user