From a7b1d9b1d9858b4e0f6aa5623eb2e93215bc75d1 Mon Sep 17 00:00:00 2001 From: gx Date: Thu, 21 May 2026 06:31:27 +0100 Subject: [PATCH] controller: auto-layout selector (motion + priority) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- controller/cuda_grid_controller/__main__.py | 4 +- controller/cuda_grid_controller/config.py | 4 ++ controller/cuda_grid_controller/dispatch.py | 14 +++++++ .../cuda_grid_controller/frigate_bridge.py | 41 +++++++++++++++++++ controller/cuda_grid_controller/http_api.py | 23 +++++++++++ .../cuda_grid_controller/static/index.html | 15 ++++++- 6 files changed, 99 insertions(+), 2 deletions(-) diff --git a/controller/cuda_grid_controller/__main__.py b/controller/cuda_grid_controller/__main__.py index bbea77c..6045bc5 100644 --- a/controller/cuda_grid_controller/__main__.py +++ b/controller/cuda_grid_controller/__main__.py @@ -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, diff --git a/controller/cuda_grid_controller/config.py b/controller/cuda_grid_controller/config.py index 1a57637..a322c60 100644 --- a/controller/cuda_grid_controller/config.py +++ b/controller/cuda_grid_controller/config.py @@ -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)", diff --git a/controller/cuda_grid_controller/dispatch.py b/controller/cuda_grid_controller/dispatch.py index 5f52090..8d58890 100644 --- a/controller/cuda_grid_controller/dispatch.py +++ b/controller/cuda_grid_controller/dispatch.py @@ -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) diff --git a/controller/cuda_grid_controller/frigate_bridge.py b/controller/cuda_grid_controller/frigate_bridge.py index f05ffc1..bb035d7 100644 --- a/controller/cuda_grid_controller/frigate_bridge.py +++ b/controller/cuda_grid_controller/frigate_bridge.py @@ -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]] = {} # ":" → 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" diff --git a/controller/cuda_grid_controller/http_api.py b/controller/cuda_grid_controller/http_api.py index 8f6f897..887a4e3 100644 --- a/controller/cuda_grid_controller/http_api.py +++ b/controller/cuda_grid_controller/http_api.py @@ -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") diff --git a/controller/cuda_grid_controller/static/index.html b/controller/cuda_grid_controller/static/index.html index 826b3b9..6468b91 100644 --- a/controller/cuda_grid_controller/static/index.html +++ b/controller/cuda_grid_controller/static/index.html @@ -51,7 +51,7 @@ pre { background:#0f0f12; padding:8px; border-radius:4px; font-size:11px; overfl
-

Layout

+

Layout

@@ -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); }