controller: auto-layout selector (motion + priority)

FrigateCameraMapping +priority +main_cam_index — для auto-layout decision.
FrigateBridgeCfg.auto_layout flag — toggle через REST.

Logic (FrigateBridge._update_auto_layout):
  0 active cameras → quad (default overview)
  1+ active → single, main_cam = highest priority active
  Equal priority → first active wins (deterministic)

Dispatcher.set_main_cam — ZMQ streamselect@main_cam map <index>
Config.main_cam_filter_target = "streamselect@main_cam"

REST:
  GET  /auto-layout/{instance}     — current toggle state
  POST /auto-layout/{instance}     — { enabled: bool }
                                      при включении сразу применяет

UI:
  + checkbox "auto" в Layout card — toggleAuto() hits POST /auto-layout

Live verified: enable → immediately picks layout=single, main=gate_lpr
(priority=10, highest active). Visual confirms gate_lpr full screen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gx
2026-05-21 06:31:27 +01:00
parent c9c5b93ef8
commit a7b1d9b1d9
6 changed files with 99 additions and 2 deletions
+3 -1
View File
@@ -86,7 +86,9 @@ async def _run(cfg: Config) -> None:
snapshot_hist = SnapshotHistory(cfg)
# HTTP REST
app = create_app(cfg, state, dispatcher, snapshot_history=snapshot_hist)
app = create_app(cfg, state, dispatcher,
snapshot_history=snapshot_hist,
frigate_bridge=frigate_bridge)
server = uvicorn.Server(
uvicorn.Config(
app,
@@ -97,6 +97,10 @@ class InstanceCfg(BaseModel):
default="streamselect@layout",
description="ZMQ target streamselect filter для runtime layout switching",
)
main_cam_filter_target: str = Field(
default="streamselect@main_cam",
description="ZMQ target streamselect filter для dynamic main camera (single layout)",
)
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)",
@@ -182,6 +182,20 @@ class CommandDispatcher:
inst.name, "audio_switched", {"to": source_name, "index": src.index}
)
async def set_main_cam(self, instance: str, cam_index: int) -> None:
"""Switch single layout main camera через streamselect@main_cam map."""
inst = self._find_instance(instance)
if inst is None:
return
client = self._client(inst)
try:
reply = await client.send_command(
inst.main_cam_filter_target, "map", str(cam_index)
)
log.info("dispatch.main_cam_set", instance=instance, cam_index=cam_index, ffmpeg_reply=reply)
except (TimeoutError, Exception) as e:
log.warning("dispatch.main_cam_fail", instance=instance, error=str(e))
async def _reload_icon(self, instance: str, icon_name: str) -> None:
"""Invalidate cached icon atlas в filter — used by DynamicRenderer."""
inst = self._find_instance(instance)
@@ -40,6 +40,9 @@ class FrigateCameraMapping(BaseModel):
camera_height: int = Field(default=1080, gt=0)
motion_indicator: bool = Field(default=True, description="Подсвечивать рамкой при motion ON")
bbox_overlay: bool = Field(default=True, description="Рисовать bbox для object detection events")
priority: int = Field(default=0, description="Higher priority → выводится в main cell при auto-layout. Equal priority → first-active wins")
main_cam_index: int = Field(default=0, ge=0, le=15,
description="Index в pipeline streamselect@main_cam (соответствует порядку -i в filter_complex)")
class BorderTheme(BaseModel):
@@ -72,6 +75,9 @@ class FrigateBridgeCfg(BaseModel):
description="Стиль cell borders — idle/motion цвета")
focus_theme: FocusTheme = Field(default_factory=FocusTheme,
description="Auto-focus: dim non-active cells когда motion на одной")
auto_layout: bool = Field(default=False,
description="Auto-switch layout based on active motion + priorities. "
"0 active → quad, 1+ active → single c main=highest priority active")
class FrigateBridge:
@@ -91,6 +97,9 @@ class FrigateBridge:
self._borders_initialized: dict[str, bool] = {} # target_instance → bool
self._cell_states: dict[str, set[str]] = {} # "<inst>:<cell>" → set of active cameras с motion
self._focus_dims: dict[str, set[int]] = {} # instance → set of cells which сейчас затемнены
self._auto_state: dict[str, tuple[str, int]] = {} # instance → (current_layout, current_main_cam_index)
# Active per camera (для auto-layout decision)
self._cam_active: dict[str, bool] = {m.frigate_camera: False for m in cfg.mappings}
def topics_to_subscribe(self) -> list[str]:
if not self.cfg.enabled:
@@ -190,6 +199,38 @@ class FrigateBridge:
if is_active != was_active:
await self._set_border_state(mapping.target_instance, mapping.cell, motion=is_active)
await self._update_focus(mapping.target_instance)
self._cam_active[cam] = is_active
await self._update_auto_layout(mapping.target_instance)
async def _update_auto_layout(self, instance: str) -> None:
"""Auto-select layout + main_cam based на active cameras + priority."""
if not self.cfg.auto_layout or not self.dispatcher:
return
# Active cameras для этого instance, отсортированы по priority desc
active = [
m for m in self.cfg.mappings
if m.target_instance == instance and self._cam_active.get(m.frigate_camera, False)
]
active.sort(key=lambda m: -m.priority)
if not active:
# Idle — quad
target_layout = "quad"
target_main_cam = 0
else:
target_layout = "single"
target_main_cam = active[0].main_cam_index
prev = self._auto_state.get(instance, (None, None))
if prev == (target_layout, target_main_cam):
return
log.info("auto_layout.change", instance=instance,
from_state=prev, to_layout=target_layout, to_main=target_main_cam,
active=[m.frigate_camera for m in active])
# Set main_cam first (so single layout shows correct cam at switch moment)
await self.dispatcher.set_main_cam(instance, target_main_cam)
await self.dispatcher.handle(instance, "layout.set", target_layout)
self._auto_state[instance] = (target_layout, target_main_cam)
def _cell_dim_id(self, cell: int) -> str:
return f"cell_{cell}_focus_dim"
@@ -30,9 +30,14 @@ class AudioSetReq(BaseModel):
source: str
class AutoLayoutReq(BaseModel):
enabled: bool
def create_app(
cfg: Config, state: ControllerState, dispatcher: CommandDispatcher,
snapshot_history: SnapshotHistory | None = None,
frigate_bridge=None,
) -> FastAPI:
app = FastAPI(
title="cuda-grid-controller",
@@ -94,6 +99,24 @@ def create_app(
"current": await state.get_layout(instance),
}
@app.get("/auto-layout/{instance}")
async def auto_layout_get(instance: str) -> dict[str, Any]:
_check_instance(instance)
enabled = bool(frigate_bridge and frigate_bridge.cfg.auto_layout)
return {"instance": instance, "enabled": enabled}
@app.post("/auto-layout/{instance}")
async def auto_layout_set(instance: str, req: AutoLayoutReq) -> dict[str, Any]:
_check_instance(instance)
if frigate_bridge is None:
raise HTTPException(404, "frigate_bridge не configured")
frigate_bridge.cfg.auto_layout = req.enabled
log.info("auto_layout.toggled", instance=instance, enabled=req.enabled)
# При включении — сразу применить (могут уже быть active cameras)
if req.enabled:
await frigate_bridge._update_auto_layout(instance)
return {"ok": True, "instance": instance, "enabled": req.enabled}
# ─── Overlays ──────────────────────────────────────────────────
@app.post("/overlay/{instance}/add")
@@ -51,7 +51,7 @@ pre { background:#0f0f12; padding:8px; border-radius:4px; font-size:11px; overfl
<div class="controls">
<!-- Layout -->
<div class="card">
<h2>Layout</h2>
<h2>Layout <span style="float:right; font-size:11px"><label><input id="auto-layout-tog" type="checkbox" onchange="toggleAuto()"> auto (по motion + priority)</label></span></h2>
<div class="row" id="layout-buttons"></div>
</div>
@@ -169,6 +169,18 @@ async function api(method, path, body=null, okMsg='ok') {
} catch (e) { toast('fail: '+e.message, false); }
}
// ── Auto-layout toggle ────────────────────────────────
async function loadAutoLayout() {
const r = await fetch(`/auto-layout/${INSTANCE}`);
if (!r.ok) return;
const d = await r.json();
document.getElementById('auto-layout-tog').checked = d.enabled;
}
async function toggleAuto() {
const enabled = document.getElementById('auto-layout-tog').checked;
await api('POST', `/auto-layout/${INSTANCE}`, {enabled}, 'auto ' + (enabled?'ON':'OFF'));
}
// ── Layout buttons ────────────────────────────────────
async function loadLayouts() {
const r = await fetch(`/layouts/${INSTANCE}`);
@@ -306,6 +318,7 @@ async function refreshState() {
// Defensive init — async (non-video) FIRST so кнопки работают независимо от HLS.
try { ovTypeChanged(); } catch(e) { console.error('ovTypeChanged', e); }
try { loadLayouts(); } catch(e) { console.error('loadLayouts', e); }
try { loadAutoLayout(); } catch(e) { console.error('loadAutoLayout', e); }
try { loadAudio(); } catch(e) { console.error('loadAudio', e); }
try { loadHistory(); } catch(e) { console.error('loadHistory', e); }
try { refreshState(); setInterval(refreshState, 2000); } catch(e) { console.error('refreshState', e); }