controller: Phase 7 dispatch — set_layout + cell_map (вместо streamselect)

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 <name>"  (был "map <index>")
    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 <cell> <pad>" × 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 <noreply@anthropic.com>
This commit is contained in:
gx
2026-05-21 20:11:04 +01:00
parent 155038aabb
commit e2764160b6
3 changed files with 57 additions and 32 deletions
+16 -7
View File
@@ -101,20 +101,29 @@ class InstanceCfg(BaseModel):
description="Громкость music когда intercom активен (0.2 = -14 dB)", description="Громкость music когда intercom активен (0.2 = -14 dB)",
) )
layout_filter_target: str = Field( layout_filter_target: str = Field(
default="streamselect@layout", default="cuda_grid@cg",
description="ZMQ target streamselect filter для runtime layout switching", description="ZMQ target cuda_grid filter (Phase 7) для runtime layout switching "
"(set_layout <name>) и cell remapping (cell_map <cell> <pad>).",
) )
main_cam_filter_target: str = Field( main_cam_filter_target: str = Field(
default="streamselect@main_cam", default="cuda_grid@cg",
description="ZMQ target streamselect filter для dynamic main camera (single layout)", description="ZMQ target для cell_map command — single layout main camera. "
"Phase 7: тот же cuda_grid@cg (был streamselect@main_cam).",
) )
mpp_main_filter_target: str = Field( mpp_main_filter_target: str = Field(
default="streamselect@mpp_main", default="cuda_grid@cg",
description="ZMQ target streamselect filter для dynamic main в main_plus_preview layout", 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( layout_map: dict[str, int] = Field(
default_factory=lambda: {"quad": 0, "single": 1, "main_plus_preview": 2}, 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( snapshot_history: "SnapshotHistoryCfg" = Field(
default_factory=lambda: SnapshotHistoryCfg(), default_factory=lambda: SnapshotHistoryCfg(),
+33 -21
View File
@@ -183,32 +183,40 @@ class CommandDispatcher:
) )
async def set_main_cam(self, instance: str, cam_index: int) -> None: 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 <cell> <pad>`
для всех 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) inst = self._find_instance(instance)
if inst is None: if inst is None:
return 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) client = self._client(inst)
try: try:
reply = await client.send_command( for cell_idx, pad_idx in enumerate(cell_assignment):
inst.main_cam_filter_target, "map", str(cam_index) 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, ffmpeg_reply=reply) )
log.info("dispatch.main_cam_set", instance=instance,
cam_index=cam_index, assignment=cell_assignment)
except (TimeoutError, Exception) as e: except (TimeoutError, Exception) as e:
log.warning("dispatch.main_cam_fail", instance=instance, error=str(e)) log.warning("dispatch.main_cam_fail", instance=instance, error=str(e))
async def set_mpp_main(self, instance: str, cam_index: int) -> None: async def set_mpp_main(self, instance: str, cam_index: int) -> None:
"""Switch mpp main camera через streamselect@mpp_main map.""" """Phase 7: alias к set_main_cam — main_plus_preview layout также
inst = self._find_instance(instance) использует cell 0 как main slot. Семантика идентична: cell_map 0 = cam_index."""
if inst is None: await self.set_main_cam(instance, cam_index)
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))
async def _reload_icon(self, instance: str, icon_name: str) -> None: async def _reload_icon(self, instance: str, icon_name: str) -> None:
"""Invalidate cached icon atlas во всех cuda_grid instances — каждый """Invalidate cached icon atlas во всех cuda_grid instances — каждый
@@ -244,26 +252,30 @@ class CommandDispatcher:
# ─── Layout ──────────────────────────────────────────────────── # ─── Layout ────────────────────────────────────────────────────
async def _set_layout(self, inst: InstanceCfg, layout: str) -> None: 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( log.warning(
"dispatch.unknown_layout", "dispatch.unknown_layout",
instance=inst.name, instance=inst.name,
layout=layout, layout=layout,
available=list(inst.layout_map.keys()), available=PREDEFINED_LAYOUTS,
) )
return return
old = await self.state.get_layout(inst.name) old = await self.state.get_layout(inst.name)
client = self._client(inst) client = self._client(inst)
try: try:
# Phase 7: cuda_grid имеет native `set_layout <name>` command.
# Layout resolve'ится по имени из встроенного PREDEFINED_LAYOUTS таблицы.
reply = await client.send_command( reply = await client.send_command(
inst.layout_filter_target, "map", str(inst.layout_map[layout]) inst.layout_filter_target, "set_layout", layout
) )
log.info( log.info(
"dispatch.layout_set", "dispatch.layout_set",
instance=inst.name, instance=inst.name,
layout=layout, layout=layout,
index=inst.layout_map[layout],
ffmpeg_reply=reply, ffmpeg_reply=reply,
) )
except (TimeoutError, Exception) as e: except (TimeoutError, Exception) as e:
+8 -4
View File
@@ -82,10 +82,11 @@ def create_app(
@app.post("/layout/{instance}/set") @app.post("/layout/{instance}/set")
async def set_layout(instance: str, req: LayoutSetReq) -> dict[str, Any]: async def set_layout(instance: str, req: LayoutSetReq) -> dict[str, Any]:
inst = _check_instance(instance) _check_instance(instance)
if req.layout not in inst.layout_map: # Phase 7: validation против built-in PREDEFINED_LAYOUTS (filter ground truth).
if req.layout not in PREDEFINED_LAYOUTS:
raise HTTPException( 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) await dispatcher.handle(instance, "layout.set", req.layout)
return {"ok": True, "instance": instance, "layout": req.layout} return {"ok": True, "instance": instance, "layout": req.layout}
@@ -93,9 +94,12 @@ def create_app(
@app.get("/layouts/{instance}") @app.get("/layouts/{instance}")
async def layouts_for_instance(instance: str) -> dict[str, Any]: async def layouts_for_instance(instance: str) -> dict[str, Any]:
inst = _check_instance(instance) 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 { return {
"instance": instance, "instance": instance,
"layouts": list(inst.layout_map.keys()), "layouts": ui_layouts,
"current": await state.get_layout(instance), "current": await state.get_layout(instance),
} }