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)",
|
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(),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user