controller: auto-layout selector (motion + priority)
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 <index>
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]] = {} # "<inst>:<cell>" → 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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -51,7 +51,7 @@ pre { background:#0f0f12; padding:8px; border-radius:4px; font-size:11px; overfl
|
||||
<div class="controls">
|
||||
<!-- Layout -->
|
||||
<div class="card">
|
||||
<h2>Layout</h2>
|
||||
<h2>Layout <span style="float:right; font-size:11px"><label><input id="auto-layout-tog" type="checkbox" onchange="toggleAuto()"> auto (по motion + priority)</label></span></h2>
|
||||
<div class="row" id="layout-buttons"></div>
|
||||
</div>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user