From e2764160b6ae110e699f29d2fa9d7c760787ff27 Mon Sep 17 00:00:00 2001 From: gx Date: Thu, 21 May 2026 20:11:04 +0100 Subject: [PATCH] =?UTF-8?q?controller:=20Phase=207=20dispatch=20=E2=80=94?= =?UTF-8?q?=20set=5Flayout=20+=20cell=5Fmap=20(=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=20streamselect)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync с n7.1-vf-cuda-grid-phase7 filter rework (один cuda_grid + native runtime layout switching). Изменения: dispatch._set_layout: target: layout_filter_target (default cuda_grid@cg, был streamselect@layout) command: "set_layout " (был "map ") validation: PREDEFINED_LAYOUTS (был inst.layout_map) dispatch.set_main_cam: target: main_cam_filter_target (default cuda_grid@cg, был streamselect@main_cam) command: "cell_map " × max_cells cell 0 = main camera; cells 1..N-1 = identity rotation остальных pads (исключая main чтобы не дублировать в preview cells) dispatch.set_mpp_main: alias к set_main_cam — main_plus_preview тоже использует cell 0 как main. config.InstanceCfg: layout_filter_target / main_cam_filter_target / mpp_main_filter_target default = cuda_grid@cg (Phase 7 single filter) new max_cells: int = 4 (соответствует filter max_cells option) layout_map deprecated — оставлен для UI subset visibility (HTTP /layouts/{inst}) http_api.set_layout: validation против PREDEFINED_LAYOUTS вместо layout_map Frigate bridge не меняется — set_main_cam подпись та же (instance, cam_index). Compile-test OK. Pipeline image: gx/cuda-grid-pipeline:phase7. Controller image rebuild требуется для deploy. Co-Authored-By: Claude Opus 4.7 --- controller/cuda_grid_controller/config.py | 23 ++++++--- controller/cuda_grid_controller/dispatch.py | 54 +++++++++++++-------- controller/cuda_grid_controller/http_api.py | 12 +++-- 3 files changed, 57 insertions(+), 32 deletions(-) diff --git a/controller/cuda_grid_controller/config.py b/controller/cuda_grid_controller/config.py index c976909..219d81f 100644 --- a/controller/cuda_grid_controller/config.py +++ b/controller/cuda_grid_controller/config.py @@ -101,20 +101,29 @@ class InstanceCfg(BaseModel): description="Громкость music когда intercom активен (0.2 = -14 dB)", ) layout_filter_target: str = Field( - default="streamselect@layout", - description="ZMQ target streamselect filter для runtime layout switching", + default="cuda_grid@cg", + description="ZMQ target cuda_grid filter (Phase 7) для runtime layout switching " + "(set_layout ) и cell remapping (cell_map ).", ) main_cam_filter_target: str = Field( - default="streamselect@main_cam", - description="ZMQ target streamselect filter для dynamic main camera (single layout)", + default="cuda_grid@cg", + description="ZMQ target для cell_map command — single layout main camera. " + "Phase 7: тот же cuda_grid@cg (был streamselect@main_cam).", ) mpp_main_filter_target: str = Field( - default="streamselect@mpp_main", - description="ZMQ target streamselect filter для dynamic main в main_plus_preview layout", + default="cuda_grid@cg", + description="ZMQ target для cell_map command — main_plus_preview main camera. " + "Phase 7: тот же cuda_grid@cg (был streamselect@mpp_main).", + ) + max_cells: int = Field( + default=4, ge=1, le=16, + description="Кол-во input pads cuda_grid filter (Phase 7 max_cells option). " + "Должно совпадать с max_cells в pipeline filter_complex.", ) 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)", + description="Список доступных layouts (Phase 7: ключи используются для validation, " + "значения игнорируются — cuda_grid resolve'ит layout по имени).", ) snapshot_history: "SnapshotHistoryCfg" = Field( default_factory=lambda: SnapshotHistoryCfg(), diff --git a/controller/cuda_grid_controller/dispatch.py b/controller/cuda_grid_controller/dispatch.py index f52df83..0b99e4f 100644 --- a/controller/cuda_grid_controller/dispatch.py +++ b/controller/cuda_grid_controller/dispatch.py @@ -183,32 +183,40 @@ class CommandDispatcher: ) async def set_main_cam(self, instance: str, cam_index: int) -> None: - """Switch single layout main camera через streamselect@main_cam map.""" + """Phase 7: main camera = cell 0. Sends cuda_grid `cell_map ` + для всех cells: cell 0 = cam_index, остальные = identity rotation + (исключая cam_index чтобы не дублировать в preview cells). + + Применимо к любому layout — non-visible cells просто игнорируются + при composition (active layout's nb_cells определяет сколько cells + фактически отрисовываются).""" inst = self._find_instance(instance) if inst is None: return + if not (0 <= cam_index < inst.max_cells): + log.warning("dispatch.main_cam_out_of_range", + instance=instance, cam_index=cam_index, max_cells=inst.max_cells) + return + + # cell 0 = main; cells 1..max-1 = другие pads без main (стабильный порядок) + other_pads = [i for i in range(inst.max_cells) if i != cam_index] + cell_assignment = [cam_index] + other_pads + 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) + for cell_idx, pad_idx in enumerate(cell_assignment): + await client.send_command( + inst.main_cam_filter_target, "cell_map", f"{cell_idx} {pad_idx}" + ) + log.info("dispatch.main_cam_set", instance=instance, + cam_index=cam_index, assignment=cell_assignment) except (TimeoutError, Exception) as e: log.warning("dispatch.main_cam_fail", instance=instance, error=str(e)) async def set_mpp_main(self, instance: str, cam_index: int) -> None: - """Switch mpp main camera через streamselect@mpp_main map.""" - inst = self._find_instance(instance) - if inst is None: - return - client = self._client(inst) - try: - reply = await client.send_command( - inst.mpp_main_filter_target, "map", str(cam_index) - ) - log.info("dispatch.mpp_main_set", instance=instance, cam_index=cam_index, ffmpeg_reply=reply) - except (TimeoutError, Exception) as e: - log.warning("dispatch.mpp_main_fail", instance=instance, error=str(e)) + """Phase 7: alias к set_main_cam — main_plus_preview layout также + использует cell 0 как main slot. Семантика идентична: cell_map 0 = cam_index.""" + await self.set_main_cam(instance, cam_index) async def _reload_icon(self, instance: str, icon_name: str) -> None: """Invalidate cached icon atlas во всех cuda_grid instances — каждый @@ -244,26 +252,30 @@ class CommandDispatcher: # ─── Layout ──────────────────────────────────────────────────── async def _set_layout(self, inst: InstanceCfg, layout: str) -> None: - if layout not in inst.layout_map: + # Phase 7: validation против built-in layouts (mirror of C filter table). + # `inst.layout_map` keys могут быть subset (УI-доступные); фактически filter + # знает весь PREDEFINED_LAYOUTS и сам ответит ошибкой если name невалиден. + if layout not in PREDEFINED_LAYOUTS: log.warning( "dispatch.unknown_layout", instance=inst.name, layout=layout, - available=list(inst.layout_map.keys()), + available=PREDEFINED_LAYOUTS, ) return old = await self.state.get_layout(inst.name) client = self._client(inst) try: + # Phase 7: cuda_grid имеет native `set_layout ` command. + # Layout resolve'ится по имени из встроенного PREDEFINED_LAYOUTS таблицы. reply = await client.send_command( - inst.layout_filter_target, "map", str(inst.layout_map[layout]) + inst.layout_filter_target, "set_layout", layout ) log.info( "dispatch.layout_set", instance=inst.name, layout=layout, - index=inst.layout_map[layout], ffmpeg_reply=reply, ) except (TimeoutError, Exception) as e: diff --git a/controller/cuda_grid_controller/http_api.py b/controller/cuda_grid_controller/http_api.py index 887a4e3..2576688 100644 --- a/controller/cuda_grid_controller/http_api.py +++ b/controller/cuda_grid_controller/http_api.py @@ -82,10 +82,11 @@ def create_app( @app.post("/layout/{instance}/set") async def set_layout(instance: str, req: LayoutSetReq) -> dict[str, Any]: - inst = _check_instance(instance) - if req.layout not in inst.layout_map: + _check_instance(instance) + # Phase 7: validation против built-in PREDEFINED_LAYOUTS (filter ground truth). + if req.layout not in PREDEFINED_LAYOUTS: raise HTTPException( - 400, f"unknown layout '{req.layout}'. Доступны: {list(inst.layout_map.keys())}" + 400, f"unknown layout '{req.layout}'. Доступны: {PREDEFINED_LAYOUTS}" ) await dispatcher.handle(instance, "layout.set", req.layout) return {"ok": True, "instance": instance, "layout": req.layout} @@ -93,9 +94,12 @@ def create_app( @app.get("/layouts/{instance}") async def layouts_for_instance(instance: str) -> dict[str, Any]: inst = _check_instance(instance) + # UI shows только то что объявлено в config layout_map (subset). Если пуст — + # все доступные filter layouts. + ui_layouts = list(inst.layout_map.keys()) if inst.layout_map else PREDEFINED_LAYOUTS return { "instance": instance, - "layouts": list(inst.layout_map.keys()), + "layouts": ui_layouts, "current": await state.get_layout(instance), }