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:
@@ -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 <name>) и cell remapping (cell_map <cell> <pad>).",
|
||||
)
|
||||
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(),
|
||||
|
||||
@@ -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 <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)
|
||||
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 <name>` 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:
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user