Implement WGC multi-monitor simultaneous capture support
Some checks failed
Validate / validate (push) Failing after 9s

- Refactored WGC engine to maintain separate capture instances per monitor
- Each monitor gets dedicated instance, control, frame storage, and events
- Supports simultaneous capture from multiple monitors using same template
- Fixed template test endpoint to avoid redundant monitor 0 initialization
- Removed monitor_index from WGC template configuration (monitor-agnostic)

This enables using the same WGC template for multiple devices capturing
from different monitors without conflicts or unexpected borders.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 19:12:21 +03:00
parent c371e07e81
commit 5ce4dba925
2 changed files with 533 additions and 96 deletions

View File

@@ -1,10 +1,15 @@
"""API routes and endpoints."""
import base64
import io
import sys
import time
from datetime import datetime
from typing import List
from typing import List, Dict, Any
import httpx
import numpy as np
from PIL import Image
from fastapi import APIRouter, HTTPException, Depends
from wled_controller import __version__
@@ -24,6 +29,17 @@ from wled_controller.api.schemas import (
CalibrationTestModeResponse,
ProcessingState,
MetricsResponse,
TemplateCreate,
TemplateUpdate,
TemplateResponse,
TemplateListResponse,
EngineInfo,
EngineListResponse,
TemplateTestRequest,
TemplateTestResponse,
CaptureImage,
BorderExtraction,
PerformanceMetrics,
)
from wled_controller.config import get_config
from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings
@@ -32,6 +48,8 @@ from wled_controller.core.calibration import (
calibration_to_dict,
)
from wled_controller.storage import DeviceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.utils import get_logger
from wled_controller.core.screen_capture import get_available_displays
@@ -41,6 +59,7 @@ router = APIRouter()
# Global instances (initialized in main.py)
_device_store: DeviceStore | None = None
_template_store: TemplateStore | None = None
_processor_manager: ProcessorManager | None = None
@@ -51,6 +70,13 @@ def get_device_store() -> DeviceStore:
return _device_store
def get_template_store() -> TemplateStore:
"""Get template store dependency."""
if _template_store is None:
raise RuntimeError("Template store not initialized")
return _template_store
def get_processor_manager() -> ProcessorManager:
"""Get processor manager dependency."""
if _processor_manager is None:
@@ -58,10 +84,15 @@ def get_processor_manager() -> ProcessorManager:
return _processor_manager
def init_dependencies(device_store: DeviceStore, processor_manager: ProcessorManager):
def init_dependencies(
device_store: DeviceStore,
template_store: TemplateStore,
processor_manager: ProcessorManager,
):
"""Initialize global dependencies."""
global _device_store, _processor_manager
global _device_store, _template_store, _processor_manager
_device_store = device_store
_template_store = template_store
_processor_manager = processor_manager
@@ -214,6 +245,7 @@ async def create_device(
state_check_interval=device.settings.state_check_interval,
),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
capture_template_id=device.capture_template_id,
created_at=device.created_at,
updated_at=device.updated_at,
)
@@ -248,6 +280,7 @@ async def list_devices(
state_check_interval=device.settings.state_check_interval,
),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
capture_template_id=device.capture_template_id,
created_at=device.created_at,
updated_at=device.updated_at,
)
@@ -291,6 +324,7 @@ async def get_device(
state_check_interval=device.settings.state_check_interval,
),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
capture_template_id=device.capture_template_id,
created_at=device.created_at,
updated_at=device.updated_at,
)
@@ -302,16 +336,53 @@ async def update_device(
update_data: DeviceUpdate,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Update device information."""
try:
# Check if template changed and device is processing (for hot-swap)
old_device = store.get_device(device_id)
template_changed = (
update_data.capture_template_id is not None
and update_data.capture_template_id != old_device.capture_template_id
)
was_processing = manager.is_processing(device_id)
# Update device
device = store.update_device(
device_id=device_id,
name=update_data.name,
url=update_data.url,
enabled=update_data.enabled,
capture_template_id=update_data.capture_template_id,
)
# Hot-swap: If template changed and device was processing, restart it
if template_changed and was_processing:
logger.info(f"Hot-swapping template for device {device_id}")
try:
# Stop current processing
await manager.stop_processing(device_id)
# Update processor with new template
manager.remove_device(device_id)
manager.add_device(
device_id=device.id,
device_url=device.url,
led_count=device.led_count,
settings=device.settings,
calibration=device.calibration,
capture_template_id=device.capture_template_id,
)
# Restart processing
await manager.start_processing(device_id)
logger.info(f"Successfully hot-swapped template for device {device_id}")
except Exception as e:
logger.error(f"Error during template hot-swap: {e}")
# Device is stopped but updated - user can manually restart
return DeviceResponse(
id=device.id,
name=device.name,
@@ -327,6 +398,7 @@ async def update_device(
state_check_interval=device.settings.state_check_interval,
),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
capture_template_id=device.capture_template_id,
created_at=device.created_at,
updated_at=device.updated_at,
)
@@ -626,3 +698,322 @@ async def get_metrics(
except Exception as e:
logger.error(f"Failed to get metrics: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== CAPTURE TEMPLATE ENDPOINTS =====
@router.get("/api/v1/capture-templates", response_model=TemplateListResponse, tags=["Templates"])
async def list_templates(
_auth: AuthRequired,
template_store: TemplateStore = Depends(get_template_store),
):
"""List all capture templates."""
try:
templates = template_store.get_all_templates()
template_responses = [
TemplateResponse(
id=t.id,
name=t.name,
engine_type=t.engine_type,
engine_config=t.engine_config,
is_default=t.is_default,
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
)
for t in templates
]
return TemplateListResponse(
templates=template_responses,
count=len(template_responses),
)
except Exception as e:
logger.error(f"Failed to list templates: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/capture-templates", response_model=TemplateResponse, tags=["Templates"], status_code=201)
async def create_template(
template_data: TemplateCreate,
_auth: AuthRequired,
template_store: TemplateStore = Depends(get_template_store),
):
"""Create a new capture template."""
try:
template = template_store.create_template(
name=template_data.name,
engine_type=template_data.engine_type,
engine_config=template_data.engine_config,
description=template_data.description,
)
return TemplateResponse(
id=template.id,
name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
is_default=template.is_default,
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create template: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/capture-templates/{template_id}", response_model=TemplateResponse, tags=["Templates"])
async def get_template(
template_id: str,
_auth: AuthRequired,
template_store: TemplateStore = Depends(get_template_store),
):
"""Get template by ID."""
template = template_store.get_template(template_id)
if not template:
raise HTTPException(status_code=404, detail=f"Template {template_id} not found")
return TemplateResponse(
id=template.id,
name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
is_default=template.is_default,
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
)
@router.put("/api/v1/capture-templates/{template_id}", response_model=TemplateResponse, tags=["Templates"])
async def update_template(
template_id: str,
update_data: TemplateUpdate,
_auth: AuthRequired,
template_store: TemplateStore = Depends(get_template_store),
):
"""Update a template."""
try:
template = template_store.update_template(
template_id=template_id,
name=update_data.name,
engine_config=update_data.engine_config,
description=update_data.description,
)
return TemplateResponse(
id=template.id,
name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
is_default=template.is_default,
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to update template: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/v1/capture-templates/{template_id}", status_code=204, tags=["Templates"])
async def delete_template(
template_id: str,
_auth: AuthRequired,
template_store: TemplateStore = Depends(get_template_store),
device_store: DeviceStore = Depends(get_device_store),
):
"""Delete a template.
Validates that no devices are currently using this template before deletion.
"""
try:
# Check if any devices are using this template
devices_using_template = []
for device in device_store.get_all_devices():
if device.capture_template_id == template_id:
devices_using_template.append(device.name)
if devices_using_template:
device_list = ", ".join(devices_using_template)
raise HTTPException(
status_code=409,
detail=f"Cannot delete template: it is currently assigned to the following device(s): {device_list}. "
f"Please reassign these devices to a different template before deleting."
)
# Proceed with deletion
template_store.delete_template(template_id)
except HTTPException:
raise # Re-raise HTTP exceptions as-is
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete template: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/capture-engines", response_model=EngineListResponse, tags=["Templates"])
async def list_engines(_auth: AuthRequired):
"""List available capture engines on this system.
Returns all registered engines that are available on the current platform.
"""
try:
available_engine_types = EngineRegistry.get_available_engines()
engines = []
for engine_type in available_engine_types:
engine_class = EngineRegistry.get_engine(engine_type)
engines.append(
EngineInfo(
type=engine_type,
name=engine_type.upper(),
default_config=engine_class.get_default_config(),
available=True,
)
)
return EngineListResponse(engines=engines, count=len(engines))
except Exception as e:
logger.error(f"Failed to list engines: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/capture-templates/test", response_model=TemplateTestResponse, tags=["Templates"])
async def test_template(
test_request: TemplateTestRequest,
_auth: AuthRequired,
processor_manager: ProcessorManager = Depends(get_processor_manager),
device_store: DeviceStore = Depends(get_device_store),
):
"""Test a capture template configuration.
Temporarily instantiates an engine with the provided configuration,
captures frames for the specified duration, and returns actual FPS metrics.
"""
engine = None
try:
# Validate engine type
if test_request.engine_type not in EngineRegistry.get_available_engines():
raise HTTPException(
status_code=400,
detail=f"Engine '{test_request.engine_type}' is not available on this system"
)
# Check if display is already being captured
locked_device_id = processor_manager.get_display_lock_info(test_request.display_index)
if locked_device_id:
# Get device info for better error message
try:
device = device_store.get_device(locked_device_id)
device_name = device.name
except Exception:
device_name = locked_device_id
raise HTTPException(
status_code=409,
detail=(
f"Display {test_request.display_index} is currently being captured by device "
f"'{device_name}'. Please stop the device processing before testing this template."
)
)
# Create engine (initialization happens on first capture)
engine = EngineRegistry.create_engine(test_request.engine_type, test_request.engine_config)
# Run sustained capture test
logger.info(f"Starting {test_request.capture_duration}s capture test with {test_request.engine_type}")
frame_count = 0
total_capture_time = 0.0
last_frame = None
start_time = time.perf_counter()
end_time = start_time + test_request.capture_duration
while time.perf_counter() < end_time:
capture_start = time.perf_counter()
screen_capture = engine.capture_display(test_request.display_index)
capture_elapsed = time.perf_counter() - capture_start
total_capture_time += capture_elapsed
frame_count += 1
last_frame = screen_capture
actual_duration = time.perf_counter() - start_time
logger.info(f"Captured {frame_count} frames in {actual_duration:.2f}s")
# Use the last captured frame for preview
if last_frame is None:
raise RuntimeError("No frames captured during test")
# Convert numpy array to PIL Image
if isinstance(last_frame.image, np.ndarray):
pil_image = Image.fromarray(last_frame.image)
else:
raise ValueError("Unexpected image format from engine")
# Create thumbnail (640px wide, maintain aspect ratio)
thumbnail_width = 640
aspect_ratio = pil_image.height / pil_image.width
thumbnail_height = int(thumbnail_width * aspect_ratio)
thumbnail = pil_image.copy()
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
# Encode full capture thumbnail as JPEG
img_buffer = io.BytesIO()
thumbnail.save(img_buffer, format='JPEG', quality=85)
img_buffer.seek(0)
full_capture_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
full_capture_data_uri = f"data:image/jpeg;base64,{full_capture_b64}"
# Calculate metrics
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
avg_capture_time_ms = (total_capture_time / frame_count * 1000) if frame_count > 0 else 0
width, height = pil_image.size
return TemplateTestResponse(
full_capture=CaptureImage(
image=full_capture_data_uri,
width=width,
height=height,
thumbnail_width=thumbnail_width,
thumbnail_height=thumbnail_height,
),
border_extraction=None,
performance=PerformanceMetrics(
capture_duration_s=actual_duration,
frame_count=frame_count,
actual_fps=actual_fps,
avg_capture_time_ms=avg_capture_time_ms,
),
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=500, detail=f"Engine error: {str(e)}")
except Exception as e:
logger.error(f"Failed to test template: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
finally:
# Always cleanup engine
if engine:
try:
engine.cleanup()
except Exception as e:
logger.error(f"Error cleaning up test engine: {e}")