a1090a5f4c
Phase 4a deliverable (no filter rendering yet — это Phase 4b).
End-to-end pipeline: HA/HTTP/MQTT → controller → ZMQ → FFmpeg (logged).
Modules:
- overlays.py — 7 discriminated union types через pydantic:
rect, text, icon, image, dim, graph, chat. Normalized coords (0.0-1.0),
optional cell binding, z_order, opacity, visible.
- state.py — overlay storage per instance (CRUD: add/remove/update/get/clear)
- dispatch.py — overlay.add/remove/clear actions:
- parses JSON payload в Overlay через TypeAdapter
- serializes to ZMQ string: "<id> <type> <full-json>"
- sends via FFmpeg process_command (filter will парсить в Phase 4b)
- updates state + publishes events (overlay_added, overlay_removed, overlays_cleared)
- http_api.py — REST endpoints:
- POST /overlay/{inst}/add (body = Overlay JSON, returns id)
- GET /overlay/{inst} — list all
- DELETE /overlay/{inst}/{id} — single
- DELETE /overlay/{inst} — clear all
- PATCH /overlay/{inst}/{id} — update
- mqtt_loop.py — already subscribes cuda_grid/cmd/<inst>/+/+; teper handles
overlay/add (JSON payload), overlay/remove (id), overlay/clear
- frigate_bridge.py — FrigateBridge skeleton:
- subscribe frigate/+/motion + frigate/events
- mapping camera_name → target_instance + cell index
- Phase 4a: log received events (rendering в Phase 4b)
- config.py — frigate: optional section
- examples/controller.yaml — frigate mappings для 4 наших камер
State management:
- ControllerState.add/remove/update/get/clear_overlay (asyncio.Lock guarded)
- InstanceState.overlays: dict[str, Overlay]
- IDs generated via uuid4()[:8]
Phase 4a limitations:
- Filter side ничего не рендерит (just logs ZMQ commands)
- Frigate bridge принимает events но не auto-generates overlays
- HA Discovery не имеет overlay-specific entities (overlays через REST API)
Phase 4b: filter-side AVFrame side data + CUDA kernels (rect first, NPP-based,
потом text via freetype atlas, потом icon sprite blit).
117 lines
3.9 KiB
Python
117 lines
3.9 KiB
Python
"""HTTP REST API (FastAPI)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import structlog
|
|
from fastapi import Body, FastAPI, HTTPException
|
|
from pydantic import BaseModel, TypeAdapter
|
|
|
|
from .config import Config
|
|
from .dispatch import CommandDispatcher
|
|
from .layouts import PREDEFINED_LAYOUTS
|
|
from .overlays import Overlay
|
|
from .state import ControllerState
|
|
|
|
log = structlog.get_logger()
|
|
|
|
|
|
class LayoutSetReq(BaseModel):
|
|
layout: str
|
|
|
|
|
|
def create_app(
|
|
cfg: Config, state: ControllerState, dispatcher: CommandDispatcher
|
|
) -> FastAPI:
|
|
app = FastAPI(
|
|
title="cuda-grid-controller",
|
|
version="0.1.0",
|
|
description="Control plane для vf_cuda_grid FFmpeg filter",
|
|
)
|
|
|
|
def _check_instance(name: str):
|
|
inst = next((i for i in cfg.instances if i.name == name), None)
|
|
if inst is None:
|
|
raise HTTPException(404, f"unknown instance '{name}'")
|
|
return inst
|
|
|
|
@app.get("/health")
|
|
async def health() -> dict[str, Any]:
|
|
return {"status": "ok"}
|
|
|
|
@app.get("/layouts")
|
|
async def layouts() -> dict[str, Any]:
|
|
return {"predefined": PREDEFINED_LAYOUTS}
|
|
|
|
@app.get("/state")
|
|
async def get_state() -> dict[str, Any]:
|
|
out = {}
|
|
for inst in cfg.instances:
|
|
overlays = await state.get_overlays(inst.name)
|
|
out[inst.name] = {
|
|
"active_layout": await state.get_layout(inst.name),
|
|
"zmq_endpoint": inst.zmq_endpoint,
|
|
"overlays_count": len(overlays),
|
|
}
|
|
return {"instances": out}
|
|
|
|
@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:
|
|
raise HTTPException(
|
|
400, f"unknown layout '{req.layout}'. Доступны: {PREDEFINED_LAYOUTS}"
|
|
)
|
|
await dispatcher.handle(instance, "layout.set", req.layout)
|
|
return {"ok": True, "instance": instance, "layout": req.layout}
|
|
|
|
# ─── Overlays ──────────────────────────────────────────────────
|
|
|
|
@app.post("/overlay/{instance}/add")
|
|
async def overlay_add(
|
|
instance: str, overlay: Overlay = Body(...)
|
|
) -> dict[str, Any]:
|
|
_check_instance(instance)
|
|
await dispatcher.handle(
|
|
instance, "overlay.add", overlay.model_dump_json()
|
|
)
|
|
return {"ok": True, "id": overlay.id, "type": overlay.type}
|
|
|
|
@app.get("/overlay/{instance}")
|
|
async def overlay_list(instance: str) -> dict[str, Any]:
|
|
_check_instance(instance)
|
|
overlays = await state.get_overlays(instance)
|
|
return {
|
|
"instance": instance,
|
|
"count": len(overlays),
|
|
"overlays": [o.model_dump() for o in overlays],
|
|
}
|
|
|
|
@app.delete("/overlay/{instance}/{overlay_id}")
|
|
async def overlay_remove(instance: str, overlay_id: str) -> dict[str, Any]:
|
|
_check_instance(instance)
|
|
await dispatcher.handle(instance, "overlay.remove", overlay_id)
|
|
return {"ok": True}
|
|
|
|
@app.delete("/overlay/{instance}")
|
|
async def overlay_clear(instance: str) -> dict[str, Any]:
|
|
_check_instance(instance)
|
|
await dispatcher.handle(instance, "overlay.clear", "")
|
|
return {"ok": True}
|
|
|
|
@app.patch("/overlay/{instance}/{overlay_id}")
|
|
async def overlay_update(
|
|
instance: str, overlay_id: str, overlay: Overlay = Body(...)
|
|
) -> dict[str, Any]:
|
|
_check_instance(instance)
|
|
# Фиксируем id из URL — игнорируем body's id если отличается
|
|
overlay.id = overlay_id
|
|
await state.update_overlay(instance, overlay)
|
|
await dispatcher.handle(
|
|
instance, "overlay.add", overlay.model_dump_json()
|
|
)
|
|
return {"ok": True, "id": overlay_id}
|
|
|
|
return app
|