diff --git a/server/src/wled_controller/api/__init__.py b/server/src/wled_controller/api/__init__.py index 86766cc..4b5baa5 100644 --- a/server/src/wled_controller/api/__init__.py +++ b/server/src/wled_controller/api/__init__.py @@ -1,5 +1,18 @@ """API routes and schemas.""" -from .routes import router +from fastapi import APIRouter + +from .routes.system import router as system_router +from .routes.devices import router as devices_router +from .routes.templates import router as templates_router +from .routes.postprocessing import router as postprocessing_router +from .routes.picture_sources import router as picture_sources_router + +router = APIRouter() +router.include_router(system_router) +router.include_router(devices_router) +router.include_router(templates_router) +router.include_router(postprocessing_router) +router.include_router(picture_sources_router) __all__ = ["router"] diff --git a/server/src/wled_controller/api/dependencies.py b/server/src/wled_controller/api/dependencies.py new file mode 100644 index 0000000..6b5bccb --- /dev/null +++ b/server/src/wled_controller/api/dependencies.py @@ -0,0 +1,65 @@ +"""Dependency injection for API routes.""" + +from wled_controller.core.processor_manager import ProcessorManager +from wled_controller.storage import DeviceStore +from wled_controller.storage.template_store import TemplateStore +from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore +from wled_controller.storage.picture_source_store import PictureSourceStore + +# Global instances (initialized in main.py) +_device_store: DeviceStore | None = None +_template_store: TemplateStore | None = None +_pp_template_store: PostprocessingTemplateStore | None = None +_picture_source_store: PictureSourceStore | None = None +_processor_manager: ProcessorManager | None = None + + +def get_device_store() -> DeviceStore: + """Get device store dependency.""" + if _device_store is None: + raise RuntimeError("Device store not initialized") + 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_pp_template_store() -> PostprocessingTemplateStore: + """Get postprocessing template store dependency.""" + if _pp_template_store is None: + raise RuntimeError("Postprocessing template store not initialized") + return _pp_template_store + + +def get_picture_source_store() -> PictureSourceStore: + """Get picture source store dependency.""" + if _picture_source_store is None: + raise RuntimeError("Picture source store not initialized") + return _picture_source_store + + +def get_processor_manager() -> ProcessorManager: + """Get processor manager dependency.""" + if _processor_manager is None: + raise RuntimeError("Processor manager not initialized") + return _processor_manager + + +def init_dependencies( + device_store: DeviceStore, + template_store: TemplateStore, + processor_manager: ProcessorManager, + pp_template_store: PostprocessingTemplateStore | None = None, + picture_source_store: PictureSourceStore | None = None, +): + """Initialize global dependencies.""" + global _device_store, _template_store, _processor_manager, _pp_template_store, _picture_source_store + _device_store = device_store + _template_store = template_store + _processor_manager = processor_manager + _pp_template_store = pp_template_store + _picture_source_store = picture_source_store diff --git a/server/src/wled_controller/api/routes.py b/server/src/wled_controller/api/routes.py deleted file mode 100644 index 64de2ca..0000000 --- a/server/src/wled_controller/api/routes.py +++ /dev/null @@ -1,1958 +0,0 @@ -"""API routes and endpoints.""" - -import base64 -import io -import sys -import time -from datetime import datetime -from typing import List, Dict, Any - -import httpx -import numpy as np -from PIL import Image -from fastapi import APIRouter, HTTPException, Depends, Query -from fastapi.responses import Response - -from wled_controller import __version__ -from wled_controller.api.auth import AuthRequired -from wled_controller.api.schemas import ( - HealthResponse, - VersionResponse, - DisplayListResponse, - DisplayInfo, - DeviceCreate, - DeviceUpdate, - DeviceResponse, - DeviceListResponse, - ProcessingSettings as ProcessingSettingsSchema, - Calibration as CalibrationSchema, - CalibrationTestModeRequest, - CalibrationTestModeResponse, - ProcessingState, - MetricsResponse, - TemplateCreate, - TemplateUpdate, - TemplateResponse, - TemplateListResponse, - EngineInfo, - EngineListResponse, - TemplateTestRequest, - TemplateTestResponse, - CaptureImage, - BorderExtraction, - PerformanceMetrics, - FilterInstanceSchema, - FilterOptionDefSchema, - FilterTypeResponse, - FilterTypeListResponse, - PostprocessingTemplateCreate, - PostprocessingTemplateUpdate, - PostprocessingTemplateResponse, - PostprocessingTemplateListResponse, - PictureStreamCreate, - PictureStreamUpdate, - PictureStreamResponse, - PictureStreamListResponse, - PictureStreamTestRequest, - PPTemplateTestRequest, - ImageValidateRequest, - ImageValidateResponse, -) -from wled_controller.config import get_config -from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings -from wled_controller.core.calibration import ( - calibration_from_dict, - calibration_to_dict, -) -from wled_controller.storage import DeviceStore -from wled_controller.storage.template_store import TemplateStore -from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore -from wled_controller.storage.picture_stream_store import PictureStreamStore -from wled_controller.core.capture_engines import EngineRegistry -from wled_controller.core.filters import FilterRegistry, FilterInstance, ImagePool -from wled_controller.utils import get_logger -from wled_controller.core.screen_capture import get_available_displays - -logger = get_logger(__name__) - -router = APIRouter() - -# Global instances (initialized in main.py) -_device_store: DeviceStore | None = None -_template_store: TemplateStore | None = None -_pp_template_store: PostprocessingTemplateStore | None = None -_picture_stream_store: PictureStreamStore | None = None -_processor_manager: ProcessorManager | None = None - - -def get_device_store() -> DeviceStore: - """Get device store dependency.""" - if _device_store is None: - raise RuntimeError("Device store not initialized") - 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_pp_template_store() -> PostprocessingTemplateStore: - """Get postprocessing template store dependency.""" - if _pp_template_store is None: - raise RuntimeError("Postprocessing template store not initialized") - return _pp_template_store - - -def get_picture_stream_store() -> PictureStreamStore: - """Get picture stream store dependency.""" - if _picture_stream_store is None: - raise RuntimeError("Picture stream store not initialized") - return _picture_stream_store - - -def get_processor_manager() -> ProcessorManager: - """Get processor manager dependency.""" - if _processor_manager is None: - raise RuntimeError("Processor manager not initialized") - return _processor_manager - - -def init_dependencies( - device_store: DeviceStore, - template_store: TemplateStore, - processor_manager: ProcessorManager, - pp_template_store: PostprocessingTemplateStore | None = None, - picture_stream_store: PictureStreamStore | None = None, -): - """Initialize global dependencies.""" - global _device_store, _template_store, _processor_manager, _pp_template_store, _picture_stream_store - _device_store = device_store - _template_store = template_store - _processor_manager = processor_manager - _pp_template_store = pp_template_store - _picture_stream_store = picture_stream_store - - -@router.get("/health", response_model=HealthResponse, tags=["Health"]) -async def health_check(): - """Check service health status. - - Returns basic health information including status, version, and timestamp. - """ - logger.info("Health check requested") - - return HealthResponse( - status="healthy", - timestamp=datetime.utcnow(), - version=__version__, - ) - - -@router.get("/api/v1/version", response_model=VersionResponse, tags=["Info"]) -async def get_version(): - """Get version information. - - Returns application version, Python version, and API version. - """ - logger.info("Version info requested") - - return VersionResponse( - version=__version__, - python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", - api_version="v1", - ) - - -@router.get("/api/v1/config/displays", response_model=DisplayListResponse, tags=["Config"]) -async def get_displays(_: AuthRequired): - """Get list of available displays. - - Returns information about all available monitors/displays that can be captured. - """ - logger.info("Listing available displays") - - try: - # Get available displays with all metadata (name, refresh rate, etc.) - display_dataclasses = get_available_displays() - - # Convert dataclass DisplayInfo to Pydantic DisplayInfo - displays = [ - DisplayInfo( - index=d.index, - name=d.name, - width=d.width, - height=d.height, - x=d.x, - y=d.y, - is_primary=d.is_primary, - refresh_rate=d.refresh_rate, - ) - for d in display_dataclasses - ] - - logger.info(f"Found {len(displays)} displays") - - return DisplayListResponse( - displays=displays, - count=len(displays), - ) - - except Exception as e: - logger.error(f"Failed to get displays: {e}") - raise HTTPException( - status_code=500, - detail=f"Failed to retrieve display information: {str(e)}" - ) - - -# ===== DEVICE MANAGEMENT ENDPOINTS ===== - -@router.post("/api/v1/devices", response_model=DeviceResponse, tags=["Devices"], status_code=201) -async def create_device( - device_data: DeviceCreate, - _auth: AuthRequired, - store: DeviceStore = Depends(get_device_store), - manager: ProcessorManager = Depends(get_processor_manager), - template_store: TemplateStore = Depends(get_template_store), -): - """Create and attach a new WLED device.""" - try: - logger.info(f"Creating device: {device_data.name}") - - # Validate WLED device is reachable before adding - device_url = device_data.url.rstrip("/") - try: - async with httpx.AsyncClient(timeout=5) as client: - response = await client.get(f"{device_url}/json/info") - response.raise_for_status() - wled_info = response.json() - wled_led_count = wled_info.get("leds", {}).get("count") - if not wled_led_count or wled_led_count < 1: - raise HTTPException( - status_code=422, - detail=f"WLED device at {device_url} reported invalid LED count: {wled_led_count}" - ) - logger.info( - f"WLED device reachable: {wled_info.get('name', 'Unknown')} " - f"v{wled_info.get('ver', '?')} ({wled_led_count} LEDs)" - ) - except httpx.ConnectError: - raise HTTPException( - status_code=422, - detail=f"Cannot reach WLED device at {device_url}. Check the URL and ensure the device is powered on." - ) - except httpx.TimeoutException: - raise HTTPException( - status_code=422, - detail=f"Connection to {device_url} timed out. Check network connectivity." - ) - except Exception as e: - raise HTTPException( - status_code=422, - detail=f"Failed to connect to WLED device at {device_url}: {e}" - ) - - # Resolve capture template: use requested ID if valid, else first available - capture_template_id = None - if device_data.capture_template_id: - try: - template_store.get_template(device_data.capture_template_id) - capture_template_id = device_data.capture_template_id - except ValueError: - logger.warning( - f"Requested template '{device_data.capture_template_id}' not found, using first available" - ) - if not capture_template_id: - all_templates = template_store.get_all_templates() - if all_templates: - capture_template_id = all_templates[0].id - else: - raise HTTPException( - status_code=500, - detail="No capture templates available. Please create one first." - ) - - # Create device in storage (LED count auto-detected from WLED) - device = store.create_device( - name=device_data.name, - url=device_data.url, - led_count=wled_led_count, - capture_template_id=capture_template_id, - ) - - # Add to processor manager - manager.add_device( - device_id=device.id, - device_url=device.url, - led_count=device.led_count, - settings=device.settings, - calibration=device.calibration, - ) - - return DeviceResponse( - id=device.id, - name=device.name, - url=device.url, - led_count=device.led_count, - enabled=device.enabled, - status="disconnected", - settings=ProcessingSettingsSchema( - display_index=device.settings.display_index, - fps=device.settings.fps, - border_width=device.settings.border_width, - interpolation_mode=device.settings.interpolation_mode, - brightness=device.settings.brightness, - smoothing=device.settings.smoothing, - state_check_interval=device.settings.state_check_interval, - ), - calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), - capture_template_id=device.capture_template_id, - picture_stream_id=device.picture_stream_id, - created_at=device.created_at, - updated_at=device.updated_at, - ) - - except Exception as e: - logger.error(f"Failed to create device: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/api/v1/devices", response_model=DeviceListResponse, tags=["Devices"]) -async def list_devices( - _auth: AuthRequired, - store: DeviceStore = Depends(get_device_store), -): - """List all attached WLED devices.""" - try: - devices = store.get_all_devices() - - device_responses = [ - DeviceResponse( - id=device.id, - name=device.name, - url=device.url, - led_count=device.led_count, - enabled=device.enabled, - status="disconnected", - settings=ProcessingSettingsSchema( - display_index=device.settings.display_index, - fps=device.settings.fps, - border_width=device.settings.border_width, - interpolation_mode=device.settings.interpolation_mode, - brightness=device.settings.brightness, - smoothing=device.settings.smoothing, - state_check_interval=device.settings.state_check_interval, - ), - calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), - capture_template_id=device.capture_template_id, - picture_stream_id=device.picture_stream_id, - created_at=device.created_at, - updated_at=device.updated_at, - ) - for device in devices - ] - - return DeviceListResponse(devices=device_responses, count=len(device_responses)) - - except Exception as e: - logger.error(f"Failed to list devices: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"]) -async def get_device( - device_id: str, - _auth: AuthRequired, - store: DeviceStore = Depends(get_device_store), - manager: ProcessorManager = Depends(get_processor_manager), -): - """Get device details by ID.""" - device = store.get_device(device_id) - if not device: - raise HTTPException(status_code=404, detail=f"Device {device_id} not found") - - # Determine status - status = "connected" if manager.is_processing(device_id) else "disconnected" - - return DeviceResponse( - id=device.id, - name=device.name, - url=device.url, - led_count=device.led_count, - enabled=device.enabled, - status=status, - settings=ProcessingSettingsSchema( - display_index=device.settings.display_index, - fps=device.settings.fps, - border_width=device.settings.border_width, - interpolation_mode=device.settings.interpolation_mode, - brightness=device.settings.brightness, - smoothing=device.settings.smoothing, - state_check_interval=device.settings.state_check_interval, - ), - calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), - capture_template_id=device.capture_template_id, - picture_stream_id=device.picture_stream_id, - created_at=device.created_at, - updated_at=device.updated_at, - ) - - -@router.put("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"]) -async def update_device( - device_id: str, - update_data: DeviceUpdate, - _auth: AuthRequired, - store: DeviceStore = Depends(get_device_store), - manager: ProcessorManager = Depends(get_processor_manager), -): - """Update device information.""" - try: - # Check if stream or template changed and device is processing (for hot-swap) - old_device = store.get_device(device_id) - stream_changed = ( - update_data.picture_stream_id is not None - and update_data.picture_stream_id != old_device.picture_stream_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, - picture_stream_id=update_data.picture_stream_id, - ) - - # Sync processor state when stream/template changed - if stream_changed or template_changed: - if was_processing: - # Hot-swap: restart with new settings - logger.info(f"Hot-swapping stream/template for device {device_id}") - try: - await manager.stop_processing(device_id) - 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, - picture_stream_id=device.picture_stream_id, - ) - await manager.start_processing(device_id) - logger.info(f"Successfully hot-swapped stream/template for device {device_id}") - except Exception as e: - logger.error(f"Error during template hot-swap: {e}") - else: - # Not processing — update processor state so next start uses new values - 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, - picture_stream_id=device.picture_stream_id, - ) - - return DeviceResponse( - id=device.id, - name=device.name, - url=device.url, - led_count=device.led_count, - enabled=device.enabled, - status="disconnected", - settings=ProcessingSettingsSchema( - display_index=device.settings.display_index, - fps=device.settings.fps, - border_width=device.settings.border_width, - interpolation_mode=device.settings.interpolation_mode, - brightness=device.settings.brightness, - smoothing=device.settings.smoothing, - state_check_interval=device.settings.state_check_interval, - ), - calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), - capture_template_id=device.capture_template_id, - picture_stream_id=device.picture_stream_id, - created_at=device.created_at, - updated_at=device.updated_at, - ) - - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - logger.error(f"Failed to update device: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.delete("/api/v1/devices/{device_id}", status_code=204, tags=["Devices"]) -async def delete_device( - device_id: str, - _auth: AuthRequired, - store: DeviceStore = Depends(get_device_store), - manager: ProcessorManager = Depends(get_processor_manager), -): - """Delete/detach a device.""" - try: - # Stop processing if running - if manager.is_processing(device_id): - await manager.stop_processing(device_id) - - # Remove from manager - manager.remove_device(device_id) - - # Delete from storage - store.delete_device(device_id) - - logger.info(f"Deleted device {device_id}") - - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - logger.error(f"Failed to delete device: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -# ===== PROCESSING CONTROL ENDPOINTS ===== - -@router.post("/api/v1/devices/{device_id}/start", tags=["Processing"]) -async def start_processing( - device_id: str, - _auth: AuthRequired, - store: DeviceStore = Depends(get_device_store), - manager: ProcessorManager = Depends(get_processor_manager), -): - """Start screen processing for a device.""" - try: - # Verify device exists - device = store.get_device(device_id) - if not device: - raise HTTPException(status_code=404, detail=f"Device {device_id} not found") - - await manager.start_processing(device_id) - - logger.info(f"Started processing for device {device_id}") - return {"status": "started", "device_id": device_id} - - except RuntimeError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"Failed to start processing: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/api/v1/devices/{device_id}/stop", tags=["Processing"]) -async def stop_processing( - device_id: str, - _auth: AuthRequired, - manager: ProcessorManager = Depends(get_processor_manager), -): - """Stop screen processing for a device.""" - try: - await manager.stop_processing(device_id) - - logger.info(f"Stopped processing for device {device_id}") - return {"status": "stopped", "device_id": device_id} - - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - logger.error(f"Failed to stop processing: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/api/v1/devices/{device_id}/state", response_model=ProcessingState, tags=["Processing"]) -async def get_processing_state( - device_id: str, - _auth: AuthRequired, - manager: ProcessorManager = Depends(get_processor_manager), -): - """Get current processing state for a device.""" - try: - state = manager.get_state(device_id) - return ProcessingState(**state) - - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - logger.error(f"Failed to get state: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -# ===== SETTINGS ENDPOINTS ===== - -@router.get("/api/v1/devices/{device_id}/settings", response_model=ProcessingSettingsSchema, tags=["Settings"]) -async def get_settings( - device_id: str, - _auth: AuthRequired, - store: DeviceStore = Depends(get_device_store), -): - """Get processing settings for a device.""" - device = store.get_device(device_id) - if not device: - raise HTTPException(status_code=404, detail=f"Device {device_id} not found") - - return ProcessingSettingsSchema( - display_index=device.settings.display_index, - fps=device.settings.fps, - border_width=device.settings.border_width, - interpolation_mode=device.settings.interpolation_mode, - brightness=device.settings.brightness, - smoothing=device.settings.smoothing, - state_check_interval=device.settings.state_check_interval, - ) - - -@router.put("/api/v1/devices/{device_id}/settings", response_model=ProcessingSettingsSchema, tags=["Settings"]) -async def update_settings( - device_id: str, - settings: ProcessingSettingsSchema, - _auth: AuthRequired, - store: DeviceStore = Depends(get_device_store), - manager: ProcessorManager = Depends(get_processor_manager), -): - """Update processing settings for a device. - - Merges with existing settings so callers can send partial updates. - Only fields explicitly included in the request body are applied. - """ - try: - # Get existing device to merge settings - device = store.get_device(device_id) - if not device: - raise HTTPException(status_code=404, detail=f"Device {device_id} not found") - - existing = device.settings - sent = settings.model_fields_set # fields the client actually sent - - # Merge: only override fields the client explicitly provided - new_settings = ProcessingSettings( - display_index=settings.display_index if 'display_index' in sent else existing.display_index, - fps=settings.fps if 'fps' in sent else existing.fps, - border_width=settings.border_width if 'border_width' in sent else existing.border_width, - interpolation_mode=settings.interpolation_mode if 'interpolation_mode' in sent else existing.interpolation_mode, - brightness=settings.brightness if 'brightness' in sent else existing.brightness, - gamma=existing.gamma, - saturation=existing.saturation, - smoothing=settings.smoothing if 'smoothing' in sent else existing.smoothing, - state_check_interval=settings.state_check_interval if 'state_check_interval' in sent else existing.state_check_interval, - ) - - # Apply color_correction fields if explicitly sent - if 'color_correction' in sent and settings.color_correction: - cc_sent = settings.color_correction.model_fields_set - if 'brightness' in cc_sent: - new_settings.brightness = settings.color_correction.brightness - if 'gamma' in cc_sent: - new_settings.gamma = settings.color_correction.gamma - if 'saturation' in cc_sent: - new_settings.saturation = settings.color_correction.saturation - - # Update in storage - device = store.update_device(device_id, settings=new_settings) - - # Update in manager if device exists - try: - manager.update_settings(device_id, new_settings) - except ValueError: - # Device not in manager yet, that's ok - pass - - return ProcessingSettingsSchema( - display_index=device.settings.display_index, - fps=device.settings.fps, - border_width=device.settings.border_width, - interpolation_mode=device.settings.interpolation_mode, - brightness=device.settings.brightness, - smoothing=device.settings.smoothing, - state_check_interval=device.settings.state_check_interval, - ) - - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - logger.error(f"Failed to update settings: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -# ===== WLED BRIGHTNESS ENDPOINT ===== - -@router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"]) -async def get_device_brightness( - device_id: str, - _auth: AuthRequired, - store: DeviceStore = Depends(get_device_store), -): - """Get current brightness from the WLED device.""" - device = store.get_device(device_id) - if not device: - raise HTTPException(status_code=404, detail=f"Device {device_id} not found") - - try: - async with httpx.AsyncClient(timeout=5.0) as http_client: - resp = await http_client.get(f"{device.url}/json/state") - resp.raise_for_status() - state = resp.json() - bri = state.get("bri", 255) - return {"brightness": bri} - except Exception as e: - logger.error(f"Failed to get WLED brightness for {device_id}: {e}") - raise HTTPException(status_code=502, detail=f"Failed to reach WLED device: {e}") - - -@router.put("/api/v1/devices/{device_id}/brightness", tags=["Settings"]) -async def set_device_brightness( - device_id: str, - body: dict, - _auth: AuthRequired, - store: DeviceStore = Depends(get_device_store), -): - """Set brightness on the WLED device directly.""" - device = store.get_device(device_id) - if not device: - raise HTTPException(status_code=404, detail=f"Device {device_id} not found") - - bri = body.get("brightness") - if bri is None or not isinstance(bri, int) or not 0 <= bri <= 255: - raise HTTPException(status_code=400, detail="brightness must be an integer 0-255") - - try: - async with httpx.AsyncClient(timeout=5.0) as http_client: - resp = await http_client.post( - f"{device.url}/json/state", - json={"bri": bri}, - ) - resp.raise_for_status() - return {"brightness": bri} - except Exception as e: - logger.error(f"Failed to set WLED brightness for {device_id}: {e}") - raise HTTPException(status_code=502, detail=f"Failed to reach WLED device: {e}") - - -# ===== CALIBRATION ENDPOINTS ===== - -@router.get("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"]) -async def get_calibration( - device_id: str, - _auth: AuthRequired, - store: DeviceStore = Depends(get_device_store), -): - """Get calibration configuration for a device.""" - device = store.get_device(device_id) - if not device: - raise HTTPException(status_code=404, detail=f"Device {device_id} not found") - - return CalibrationSchema(**calibration_to_dict(device.calibration)) - - -@router.put("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"]) -async def update_calibration( - device_id: str, - calibration_data: CalibrationSchema, - _auth: AuthRequired, - store: DeviceStore = Depends(get_device_store), - manager: ProcessorManager = Depends(get_processor_manager), -): - """Update calibration configuration for a device.""" - try: - # Convert schema to CalibrationConfig - calibration_dict = calibration_data.model_dump() - calibration = calibration_from_dict(calibration_dict) - - # Update in storage - device = store.update_device(device_id, calibration=calibration) - - # Update in manager if device exists - try: - manager.update_calibration(device_id, calibration) - except ValueError: - # Device not in manager yet, that's ok - pass - - return CalibrationSchema(**calibration_to_dict(device.calibration)) - - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"Failed to update calibration: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.put( - "/api/v1/devices/{device_id}/calibration/test", - response_model=CalibrationTestModeResponse, - tags=["Calibration"], -) -async def set_calibration_test_mode( - device_id: str, - body: CalibrationTestModeRequest, - _auth: AuthRequired, - store: DeviceStore = Depends(get_device_store), - manager: ProcessorManager = Depends(get_processor_manager), -): - """Toggle calibration test mode for specific edges. - - Send edges with colors to light them up, or empty edges dict to exit test mode. - While test mode is active, screen capture processing is paused. - """ - try: - device = store.get_device(device_id) - if not device: - raise HTTPException(status_code=404, detail=f"Device {device_id} not found") - - # Validate edge names and colors - valid_edges = {"top", "right", "bottom", "left"} - for edge_name, color in body.edges.items(): - if edge_name not in valid_edges: - raise HTTPException( - status_code=400, - detail=f"Invalid edge '{edge_name}'. Must be one of: {', '.join(valid_edges)}" - ) - if len(color) != 3 or not all(0 <= c <= 255 for c in color): - raise HTTPException( - status_code=400, - detail=f"Invalid color for edge '{edge_name}'. Must be [R, G, B] with values 0-255." - ) - - await manager.set_test_mode(device_id, body.edges) - - active_edges = list(body.edges.keys()) - logger.info( - f"Test mode {'activated' if active_edges else 'deactivated'} " - f"for device {device_id}: {active_edges}" - ) - - return CalibrationTestModeResponse( - test_mode=len(active_edges) > 0, - active_edges=active_edges, - device_id=device_id, - ) - - except HTTPException: - raise - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - logger.error(f"Failed to set test mode: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -# ===== METRICS ENDPOINTS ===== - -@router.get("/api/v1/devices/{device_id}/metrics", response_model=MetricsResponse, tags=["Metrics"]) -async def get_metrics( - device_id: str, - _auth: AuthRequired, - manager: ProcessorManager = Depends(get_processor_manager), -): - """Get processing metrics for a device.""" - try: - metrics = manager.get_metrics(device_id) - return MetricsResponse(**metrics) - - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - 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, - - 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, - - 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.""" - try: - template = template_store.get_template(template_id) - except ValueError: - 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, - 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_type=update_data.engine_type, - 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, - - 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), - stream_store: PictureStreamStore = Depends(get_picture_stream_store), -): - """Delete a template. - - Validates that no streams are currently using this template before deletion. - """ - try: - # Check if any streams are using this template - streams_using_template = [] - for stream in stream_store.get_all_streams(): - if stream.capture_template_id == template_id: - streams_using_template.append(stream.name) - - if streams_using_template: - stream_list = ", ".join(streams_using_template) - raise HTTPException( - status_code=409, - detail=f"Cannot delete template: it is used by the following stream(s): {stream_list}. " - f"Please reassign these streams 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 (auto-initializes 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 - - # Skip if no new frame (screen unchanged) - if screen_capture is None: - continue - - 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 thumbnail as JPEG - img_buffer = io.BytesIO() - thumbnail.save(img_buffer, format='JPEG', quality=85) - img_buffer.seek(0) - thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8') - thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}" - - # Encode full-resolution image as JPEG - full_buffer = io.BytesIO() - pil_image.save(full_buffer, format='JPEG', quality=90) - full_buffer.seek(0) - full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8') - full_data_uri = f"data:image/jpeg;base64,{full_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=thumbnail_data_uri, - full_image=full_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}") - - -# ===== FILTER TYPE ENDPOINTS ===== - -@router.get("/api/v1/filters", response_model=FilterTypeListResponse, tags=["Filters"]) -async def list_filter_types(_auth: AuthRequired): - """List all available postprocessing filter types and their options schemas.""" - all_filters = FilterRegistry.get_all() - responses = [] - for filter_id, filter_cls in all_filters.items(): - schema = filter_cls.get_options_schema() - responses.append(FilterTypeResponse( - filter_id=filter_cls.filter_id, - filter_name=filter_cls.filter_name, - options_schema=[ - FilterOptionDefSchema( - key=opt.key, - label=opt.label, - type=opt.option_type, - default=opt.default, - min_value=opt.min_value, - max_value=opt.max_value, - step=opt.step, - ) - for opt in schema - ], - )) - return FilterTypeListResponse(filters=responses, count=len(responses)) - - -# ===== POSTPROCESSING TEMPLATE ENDPOINTS ===== - -def _pp_template_to_response(t) -> PostprocessingTemplateResponse: - """Convert a PostprocessingTemplate to its API response.""" - return PostprocessingTemplateResponse( - id=t.id, - name=t.name, - filters=[FilterInstanceSchema(filter_id=f.filter_id, options=f.options) for f in t.filters], - created_at=t.created_at, - updated_at=t.updated_at, - description=t.description, - ) - - -@router.get("/api/v1/postprocessing-templates", response_model=PostprocessingTemplateListResponse, tags=["Postprocessing Templates"]) -async def list_pp_templates( - _auth: AuthRequired, - store: PostprocessingTemplateStore = Depends(get_pp_template_store), -): - """List all postprocessing templates.""" - try: - templates = store.get_all_templates() - responses = [_pp_template_to_response(t) for t in templates] - return PostprocessingTemplateListResponse(templates=responses, count=len(responses)) - except Exception as e: - logger.error(f"Failed to list postprocessing templates: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/api/v1/postprocessing-templates", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"], status_code=201) -async def create_pp_template( - data: PostprocessingTemplateCreate, - _auth: AuthRequired, - store: PostprocessingTemplateStore = Depends(get_pp_template_store), -): - """Create a new postprocessing template.""" - try: - filters = [FilterInstance(f.filter_id, f.options) for f in data.filters] - template = store.create_template( - name=data.name, - filters=filters, - description=data.description, - ) - return _pp_template_to_response(template) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"Failed to create postprocessing template: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/api/v1/postprocessing-templates/{template_id}", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"]) -async def get_pp_template( - template_id: str, - _auth: AuthRequired, - store: PostprocessingTemplateStore = Depends(get_pp_template_store), -): - """Get postprocessing template by ID.""" - try: - template = store.get_template(template_id) - return _pp_template_to_response(template) - except ValueError: - raise HTTPException(status_code=404, detail=f"Postprocessing template {template_id} not found") - - -@router.put("/api/v1/postprocessing-templates/{template_id}", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"]) -async def update_pp_template( - template_id: str, - data: PostprocessingTemplateUpdate, - _auth: AuthRequired, - store: PostprocessingTemplateStore = Depends(get_pp_template_store), -): - """Update a postprocessing template.""" - try: - filters = [FilterInstance(f.filter_id, f.options) for f in data.filters] if data.filters is not None else None - template = store.update_template( - template_id=template_id, - name=data.name, - filters=filters, - description=data.description, - ) - return _pp_template_to_response(template) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"Failed to update postprocessing template: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.delete("/api/v1/postprocessing-templates/{template_id}", status_code=204, tags=["Postprocessing Templates"]) -async def delete_pp_template( - template_id: str, - _auth: AuthRequired, - store: PostprocessingTemplateStore = Depends(get_pp_template_store), - stream_store: PictureStreamStore = Depends(get_picture_stream_store), -): - """Delete a postprocessing template.""" - try: - # Check if any picture stream references this template - if store.is_referenced_by(template_id, stream_store): - raise HTTPException( - status_code=409, - detail="Cannot delete postprocessing template: it is referenced by one or more picture streams. " - "Please reassign those streams before deleting.", - ) - store.delete_template(template_id) - except HTTPException: - raise - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"Failed to delete postprocessing template: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/api/v1/postprocessing-templates/{template_id}/test", response_model=TemplateTestResponse, tags=["Postprocessing Templates"]) -async def test_pp_template( - template_id: str, - test_request: PPTemplateTestRequest, - _auth: AuthRequired, - pp_store: PostprocessingTemplateStore = Depends(get_pp_template_store), - stream_store: PictureStreamStore = Depends(get_picture_stream_store), - template_store: TemplateStore = Depends(get_template_store), - processor_manager: ProcessorManager = Depends(get_processor_manager), - device_store: DeviceStore = Depends(get_device_store), -): - """Test a postprocessing template by capturing from a source stream and applying filters.""" - engine = None - try: - # Get the PP template - try: - pp_template = pp_store.get_template(template_id) - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - # Resolve source stream chain to get the raw stream - try: - chain = stream_store.resolve_stream_chain(test_request.source_stream_id) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - raw_stream = chain["raw_stream"] - - if raw_stream.stream_type == "static_image": - # Static image: load directly - from pathlib import Path - - source = raw_stream.image_source - start_time = time.perf_counter() - - if source.startswith(("http://", "https://")): - async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: - resp = await client.get(source) - resp.raise_for_status() - pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB") - else: - path = Path(source) - if not path.exists(): - raise HTTPException(status_code=400, detail=f"Image file not found: {source}") - pil_image = Image.open(path).convert("RGB") - - actual_duration = time.perf_counter() - start_time - frame_count = 1 - total_capture_time = actual_duration - else: - # Raw capture stream: use engine - try: - capture_template = template_store.get_template(raw_stream.capture_template_id) - except ValueError: - raise HTTPException( - status_code=400, - detail=f"Capture template not found: {raw_stream.capture_template_id}", - ) - - display_index = raw_stream.display_index - - if capture_template.engine_type not in EngineRegistry.get_available_engines(): - raise HTTPException( - status_code=400, - detail=f"Engine '{capture_template.engine_type}' is not available on this system", - ) - - locked_device_id = processor_manager.get_display_lock_info(display_index) - if locked_device_id: - 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 {display_index} is currently being captured by device '{device_name}'. " - f"Please stop the device processing before testing.", - ) - - engine = EngineRegistry.create_engine(capture_template.engine_type, capture_template.engine_config) - - logger.info(f"Starting {test_request.capture_duration}s PP template test for {template_id} using stream {test_request.source_stream_id}") - - 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(display_index) - capture_elapsed = time.perf_counter() - capture_start - - if screen_capture is None: - continue - - total_capture_time += capture_elapsed - frame_count += 1 - last_frame = screen_capture - - actual_duration = time.perf_counter() - start_time - - if last_frame is None: - raise RuntimeError("No frames captured during test") - - if isinstance(last_frame.image, np.ndarray): - pil_image = Image.fromarray(last_frame.image) - else: - raise ValueError("Unexpected image format from engine") - - # Create thumbnail - 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) - - # Apply postprocessing filters - if pp_template.filters: - pool = ImagePool() - - def apply_filters(img): - arr = np.array(img) - for fi in pp_template.filters: - f = FilterRegistry.create_instance(fi.filter_id, fi.options) - result = f.process_image(arr, pool) - if result is not None: - arr = result - return Image.fromarray(arr) - - thumbnail = apply_filters(thumbnail) - pil_image = apply_filters(pil_image) - - # Encode thumbnail - img_buffer = io.BytesIO() - thumbnail.save(img_buffer, format='JPEG', quality=85) - img_buffer.seek(0) - thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8') - thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}" - - # Encode full-resolution image - full_buffer = io.BytesIO() - pil_image.save(full_buffer, format='JPEG', quality=90) - full_buffer.seek(0) - full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8') - full_data_uri = f"data:image/jpeg;base64,{full_b64}" - - 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=thumbnail_data_uri, - full_image=full_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 HTTPException: - raise - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"Postprocessing template test failed: {e}") - raise HTTPException(status_code=500, detail=str(e)) - finally: - if engine: - try: - engine.release() - except Exception: - pass - - -# ===== PICTURE STREAM ENDPOINTS ===== - -def _stream_to_response(s) -> PictureStreamResponse: - """Convert a PictureStream to its API response.""" - return PictureStreamResponse( - id=s.id, - name=s.name, - stream_type=s.stream_type, - display_index=s.display_index, - capture_template_id=s.capture_template_id, - target_fps=s.target_fps, - source_stream_id=s.source_stream_id, - postprocessing_template_id=s.postprocessing_template_id, - image_source=s.image_source, - created_at=s.created_at, - updated_at=s.updated_at, - description=s.description, - ) - - -@router.get("/api/v1/picture-streams", response_model=PictureStreamListResponse, tags=["Picture Streams"]) -async def list_picture_streams( - _auth: AuthRequired, - store: PictureStreamStore = Depends(get_picture_stream_store), -): - """List all picture streams.""" - try: - streams = store.get_all_streams() - responses = [_stream_to_response(s) for s in streams] - return PictureStreamListResponse(streams=responses, count=len(responses)) - except Exception as e: - logger.error(f"Failed to list picture streams: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/api/v1/picture-streams/validate-image", response_model=ImageValidateResponse, tags=["Picture Streams"]) -async def validate_image( - data: ImageValidateRequest, - _auth: AuthRequired, -): - """Validate an image source (URL or file path) and return a preview thumbnail.""" - try: - from pathlib import Path - - source = data.image_source.strip() - if not source: - return ImageValidateResponse(valid=False, error="Image source is empty") - - if source.startswith(("http://", "https://")): - async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: - response = await client.get(source) - response.raise_for_status() - pil_image = Image.open(io.BytesIO(response.content)) - else: - path = Path(source) - if not path.exists(): - return ImageValidateResponse(valid=False, error=f"File not found: {source}") - pil_image = Image.open(path) - - pil_image = pil_image.convert("RGB") - width, height = pil_image.size - - # Create thumbnail preview (max 320px wide) - thumb = pil_image.copy() - thumb.thumbnail((320, 320), Image.Resampling.LANCZOS) - buf = io.BytesIO() - thumb.save(buf, format="JPEG", quality=80) - buf.seek(0) - preview = f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}" - - return ImageValidateResponse( - valid=True, width=width, height=height, preview=preview - ) - - except httpx.HTTPStatusError as e: - return ImageValidateResponse(valid=False, error=f"HTTP {e.response.status_code}: {e.response.reason_phrase}") - except httpx.RequestError as e: - return ImageValidateResponse(valid=False, error=f"Request failed: {e}") - except Exception as e: - return ImageValidateResponse(valid=False, error=str(e)) - - -@router.get("/api/v1/picture-streams/full-image", tags=["Picture Streams"]) -async def get_full_image( - _auth: AuthRequired, - source: str = Query(..., description="Image URL or local file path"), -): - """Serve the full-resolution image for lightbox preview.""" - from pathlib import Path - - try: - if source.startswith(("http://", "https://")): - async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: - response = await client.get(source) - response.raise_for_status() - pil_image = Image.open(io.BytesIO(response.content)) - else: - path = Path(source) - if not path.exists(): - raise HTTPException(status_code=404, detail="File not found") - pil_image = Image.open(path) - - pil_image = pil_image.convert("RGB") - buf = io.BytesIO() - pil_image.save(buf, format="JPEG", quality=90) - buf.seek(0) - return Response(content=buf.getvalue(), media_type="image/jpeg") - - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@router.post("/api/v1/picture-streams", response_model=PictureStreamResponse, tags=["Picture Streams"], status_code=201) -async def create_picture_stream( - data: PictureStreamCreate, - _auth: AuthRequired, - store: PictureStreamStore = Depends(get_picture_stream_store), - template_store: TemplateStore = Depends(get_template_store), - pp_store: PostprocessingTemplateStore = Depends(get_pp_template_store), -): - """Create a new picture stream.""" - try: - # Validate referenced entities - if data.stream_type == "raw" and data.capture_template_id: - try: - template_store.get_template(data.capture_template_id) - except ValueError: - raise HTTPException( - status_code=400, - detail=f"Capture template not found: {data.capture_template_id}", - ) - - if data.stream_type == "processed" and data.postprocessing_template_id: - try: - pp_store.get_template(data.postprocessing_template_id) - except ValueError: - raise HTTPException( - status_code=400, - detail=f"Postprocessing template not found: {data.postprocessing_template_id}", - ) - - stream = store.create_stream( - name=data.name, - stream_type=data.stream_type, - display_index=data.display_index, - capture_template_id=data.capture_template_id, - target_fps=data.target_fps, - source_stream_id=data.source_stream_id, - postprocessing_template_id=data.postprocessing_template_id, - image_source=data.image_source, - description=data.description, - ) - return _stream_to_response(stream) - except HTTPException: - raise - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"Failed to create picture stream: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/api/v1/picture-streams/{stream_id}", response_model=PictureStreamResponse, tags=["Picture Streams"]) -async def get_picture_stream( - stream_id: str, - _auth: AuthRequired, - store: PictureStreamStore = Depends(get_picture_stream_store), -): - """Get picture stream by ID.""" - try: - stream = store.get_stream(stream_id) - return _stream_to_response(stream) - except ValueError: - raise HTTPException(status_code=404, detail=f"Picture stream {stream_id} not found") - - -@router.put("/api/v1/picture-streams/{stream_id}", response_model=PictureStreamResponse, tags=["Picture Streams"]) -async def update_picture_stream( - stream_id: str, - data: PictureStreamUpdate, - _auth: AuthRequired, - store: PictureStreamStore = Depends(get_picture_stream_store), -): - """Update a picture stream.""" - try: - stream = store.update_stream( - stream_id=stream_id, - name=data.name, - display_index=data.display_index, - capture_template_id=data.capture_template_id, - target_fps=data.target_fps, - source_stream_id=data.source_stream_id, - postprocessing_template_id=data.postprocessing_template_id, - image_source=data.image_source, - description=data.description, - ) - return _stream_to_response(stream) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"Failed to update picture stream: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.delete("/api/v1/picture-streams/{stream_id}", status_code=204, tags=["Picture Streams"]) -async def delete_picture_stream( - stream_id: str, - _auth: AuthRequired, - store: PictureStreamStore = Depends(get_picture_stream_store), - device_store: DeviceStore = Depends(get_device_store), -): - """Delete a picture stream.""" - try: - # Check if any device references this stream - if store.is_referenced_by_device(stream_id, device_store): - raise HTTPException( - status_code=409, - detail="Cannot delete picture stream: it is assigned to one or more devices. " - "Please reassign those devices before deleting.", - ) - store.delete_stream(stream_id) - except HTTPException: - raise - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"Failed to delete picture stream: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/api/v1/picture-streams/{stream_id}/test", response_model=TemplateTestResponse, tags=["Picture Streams"]) -async def test_picture_stream( - stream_id: str, - test_request: PictureStreamTestRequest, - _auth: AuthRequired, - store: PictureStreamStore = Depends(get_picture_stream_store), - template_store: TemplateStore = Depends(get_template_store), - processor_manager: ProcessorManager = Depends(get_processor_manager), - device_store: DeviceStore = Depends(get_device_store), - pp_store: PostprocessingTemplateStore = Depends(get_pp_template_store), -): - """Test a picture stream by resolving its chain and running a capture test. - - Resolves the stream chain to the raw stream, captures frames, - and returns preview image + performance metrics. - For processed streams, applies postprocessing (gamma, saturation, brightness) - to the preview image. - """ - engine = None - try: - # Resolve stream chain - try: - chain = store.resolve_stream_chain(stream_id) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - raw_stream = chain["raw_stream"] - - if raw_stream.stream_type == "static_image": - # Static image stream: load image directly, no engine needed - from pathlib import Path - - source = raw_stream.image_source - start_time = time.perf_counter() - - if source.startswith(("http://", "https://")): - async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: - resp = await client.get(source) - resp.raise_for_status() - pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB") - else: - path = Path(source) - if not path.exists(): - raise HTTPException(status_code=400, detail=f"Image file not found: {source}") - pil_image = Image.open(path).convert("RGB") - - actual_duration = time.perf_counter() - start_time - frame_count = 1 - total_capture_time = actual_duration - - else: - # Raw capture stream: use engine - try: - capture_template = template_store.get_template(raw_stream.capture_template_id) - except ValueError: - raise HTTPException( - status_code=400, - detail=f"Capture template not found: {raw_stream.capture_template_id}", - ) - - display_index = raw_stream.display_index - - if capture_template.engine_type not in EngineRegistry.get_available_engines(): - raise HTTPException( - status_code=400, - detail=f"Engine '{capture_template.engine_type}' is not available on this system", - ) - - locked_device_id = processor_manager.get_display_lock_info(display_index) - if locked_device_id: - 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 {display_index} is currently being captured by device '{device_name}'. " - f"Please stop the device processing before testing.", - ) - - engine = EngineRegistry.create_engine(capture_template.engine_type, capture_template.engine_config) - - logger.info(f"Starting {test_request.capture_duration}s stream test for {stream_id}") - - 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(display_index) - capture_elapsed = time.perf_counter() - capture_start - - if screen_capture is None: - continue - - total_capture_time += capture_elapsed - frame_count += 1 - last_frame = screen_capture - - actual_duration = time.perf_counter() - start_time - - if last_frame is None: - raise RuntimeError("No frames captured during test") - - if isinstance(last_frame.image, np.ndarray): - pil_image = Image.fromarray(last_frame.image) - else: - raise ValueError("Unexpected image format from engine") - - # Create thumbnail - 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) - - # Apply postprocessing filters if this is a processed stream - pp_template_ids = chain["postprocessing_template_ids"] - if pp_template_ids: - try: - pp_template = pp_store.get_template(pp_template_ids[0]) - pool = ImagePool() - - def apply_filters(img): - arr = np.array(img) - for fi in pp_template.filters: - f = FilterRegistry.create_instance(fi.filter_id, fi.options) - result = f.process_image(arr, pool) - if result is not None: - arr = result - return Image.fromarray(arr) - - thumbnail = apply_filters(thumbnail) - pil_image = apply_filters(pil_image) - except ValueError: - logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview") - - # Encode thumbnail - img_buffer = io.BytesIO() - thumbnail.save(img_buffer, format='JPEG', quality=85) - img_buffer.seek(0) - thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8') - thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}" - - # Encode full-resolution image - full_buffer = io.BytesIO() - pil_image.save(full_buffer, format='JPEG', quality=90) - full_buffer.seek(0) - full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8') - full_data_uri = f"data:image/jpeg;base64,{full_b64}" - - 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=thumbnail_data_uri, - full_image=full_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 HTTPException: - raise - 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 picture stream: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) - finally: - if engine: - try: - engine.cleanup() - except Exception as e: - logger.error(f"Error cleaning up test engine: {e}") diff --git a/server/src/wled_controller/api/routes/__init__.py b/server/src/wled_controller/api/routes/__init__.py new file mode 100644 index 0000000..fb0a2f8 --- /dev/null +++ b/server/src/wled_controller/api/routes/__init__.py @@ -0,0 +1 @@ +"""API route modules.""" diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py new file mode 100644 index 0000000..74db65f --- /dev/null +++ b/server/src/wled_controller/api/routes/devices.py @@ -0,0 +1,663 @@ +"""Device routes: CRUD, processing control, settings, brightness, calibration, metrics.""" + +from datetime import datetime + +import httpx +from fastapi import APIRouter, HTTPException, Depends + +from wled_controller.api.auth import AuthRequired +from wled_controller.api.dependencies import ( + get_device_store, + get_processor_manager, +) +from wled_controller.api.schemas.devices import ( + Calibration as CalibrationSchema, + CalibrationTestModeRequest, + CalibrationTestModeResponse, + DeviceCreate, + DeviceListResponse, + DeviceResponse, + DeviceUpdate, + MetricsResponse, + ProcessingSettings as ProcessingSettingsSchema, + ProcessingState, +) +from wled_controller.core.calibration import ( + calibration_from_dict, + calibration_to_dict, +) +from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings +from wled_controller.storage import DeviceStore +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +router = APIRouter() + + +# ===== DEVICE MANAGEMENT ENDPOINTS ===== + +@router.post("/api/v1/devices", response_model=DeviceResponse, tags=["Devices"], status_code=201) +async def create_device( + device_data: DeviceCreate, + _auth: AuthRequired, + store: DeviceStore = Depends(get_device_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Create and attach a new WLED device.""" + try: + logger.info(f"Creating device: {device_data.name}") + + # Validate WLED device is reachable before adding + device_url = device_data.url.rstrip("/") + try: + async with httpx.AsyncClient(timeout=5) as client: + response = await client.get(f"{device_url}/json/info") + response.raise_for_status() + wled_info = response.json() + wled_led_count = wled_info.get("leds", {}).get("count") + if not wled_led_count or wled_led_count < 1: + raise HTTPException( + status_code=422, + detail=f"WLED device at {device_url} reported invalid LED count: {wled_led_count}" + ) + logger.info( + f"WLED device reachable: {wled_info.get('name', 'Unknown')} " + f"v{wled_info.get('ver', '?')} ({wled_led_count} LEDs)" + ) + except httpx.ConnectError: + raise HTTPException( + status_code=422, + detail=f"Cannot reach WLED device at {device_url}. Check the URL and ensure the device is powered on." + ) + except httpx.TimeoutException: + raise HTTPException( + status_code=422, + detail=f"Connection to {device_url} timed out. Check network connectivity." + ) + except Exception as e: + raise HTTPException( + status_code=422, + detail=f"Failed to connect to WLED device at {device_url}: {e}" + ) + + # Create device in storage (LED count auto-detected from WLED) + device = store.create_device( + name=device_data.name, + url=device_data.url, + led_count=wled_led_count, + ) + + # Add to processor manager + manager.add_device( + device_id=device.id, + device_url=device.url, + led_count=device.led_count, + settings=device.settings, + calibration=device.calibration, + ) + + return DeviceResponse( + id=device.id, + name=device.name, + url=device.url, + led_count=device.led_count, + enabled=device.enabled, + status="disconnected", + settings=ProcessingSettingsSchema( + display_index=device.settings.display_index, + fps=device.settings.fps, + border_width=device.settings.border_width, + interpolation_mode=device.settings.interpolation_mode, + brightness=device.settings.brightness, + smoothing=device.settings.smoothing, + state_check_interval=device.settings.state_check_interval, + ), + calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), + picture_source_id=device.picture_source_id, + created_at=device.created_at, + updated_at=device.updated_at, + ) + + except Exception as e: + logger.error(f"Failed to create device: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/api/v1/devices", response_model=DeviceListResponse, tags=["Devices"]) +async def list_devices( + _auth: AuthRequired, + store: DeviceStore = Depends(get_device_store), +): + """List all attached WLED devices.""" + try: + devices = store.get_all_devices() + + device_responses = [ + DeviceResponse( + id=device.id, + name=device.name, + url=device.url, + led_count=device.led_count, + enabled=device.enabled, + status="disconnected", + settings=ProcessingSettingsSchema( + display_index=device.settings.display_index, + fps=device.settings.fps, + border_width=device.settings.border_width, + interpolation_mode=device.settings.interpolation_mode, + brightness=device.settings.brightness, + smoothing=device.settings.smoothing, + state_check_interval=device.settings.state_check_interval, + ), + calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), + picture_source_id=device.picture_source_id, + created_at=device.created_at, + updated_at=device.updated_at, + ) + for device in devices + ] + + return DeviceListResponse(devices=device_responses, count=len(device_responses)) + + except Exception as e: + logger.error(f"Failed to list devices: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"]) +async def get_device( + device_id: str, + _auth: AuthRequired, + store: DeviceStore = Depends(get_device_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Get device details by ID.""" + device = store.get_device(device_id) + if not device: + raise HTTPException(status_code=404, detail=f"Device {device_id} not found") + + # Determine status + status = "connected" if manager.is_processing(device_id) else "disconnected" + + return DeviceResponse( + id=device.id, + name=device.name, + url=device.url, + led_count=device.led_count, + enabled=device.enabled, + status=status, + settings=ProcessingSettingsSchema( + display_index=device.settings.display_index, + fps=device.settings.fps, + border_width=device.settings.border_width, + interpolation_mode=device.settings.interpolation_mode, + brightness=device.settings.brightness, + smoothing=device.settings.smoothing, + state_check_interval=device.settings.state_check_interval, + ), + calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), + picture_source_id=device.picture_source_id, + created_at=device.created_at, + updated_at=device.updated_at, + ) + + +@router.put("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"]) +async def update_device( + device_id: str, + update_data: DeviceUpdate, + _auth: AuthRequired, + store: DeviceStore = Depends(get_device_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Update device information.""" + try: + # Check if stream changed and device is processing (for hot-swap) + old_device = store.get_device(device_id) + stream_changed = ( + update_data.picture_source_id is not None + and update_data.picture_source_id != old_device.picture_source_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, + picture_source_id=update_data.picture_source_id, + ) + + # Sync processor state when stream changed + if stream_changed: + if was_processing: + # Hot-swap: restart with new settings + logger.info(f"Hot-swapping stream for device {device_id}") + try: + await manager.stop_processing(device_id) + 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, + picture_source_id=device.picture_source_id, + ) + await manager.start_processing(device_id) + logger.info(f"Successfully hot-swapped stream for device {device_id}") + except Exception as e: + logger.error(f"Error during stream hot-swap: {e}") + else: + # Not processing -- update processor state so next start uses new values + 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, + picture_source_id=device.picture_source_id, + ) + + return DeviceResponse( + id=device.id, + name=device.name, + url=device.url, + led_count=device.led_count, + enabled=device.enabled, + status="disconnected", + settings=ProcessingSettingsSchema( + display_index=device.settings.display_index, + fps=device.settings.fps, + border_width=device.settings.border_width, + interpolation_mode=device.settings.interpolation_mode, + brightness=device.settings.brightness, + smoothing=device.settings.smoothing, + state_check_interval=device.settings.state_check_interval, + ), + calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), + picture_source_id=device.picture_source_id, + created_at=device.created_at, + updated_at=device.updated_at, + ) + + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + logger.error(f"Failed to update device: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/api/v1/devices/{device_id}", status_code=204, tags=["Devices"]) +async def delete_device( + device_id: str, + _auth: AuthRequired, + store: DeviceStore = Depends(get_device_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Delete/detach a device.""" + try: + # Stop processing if running + if manager.is_processing(device_id): + await manager.stop_processing(device_id) + + # Remove from manager + manager.remove_device(device_id) + + # Delete from storage + store.delete_device(device_id) + + logger.info(f"Deleted device {device_id}") + + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + logger.error(f"Failed to delete device: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ===== PROCESSING CONTROL ENDPOINTS ===== + +@router.post("/api/v1/devices/{device_id}/start", tags=["Processing"]) +async def start_processing( + device_id: str, + _auth: AuthRequired, + store: DeviceStore = Depends(get_device_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Start screen processing for a device.""" + try: + # Verify device exists + device = store.get_device(device_id) + if not device: + raise HTTPException(status_code=404, detail=f"Device {device_id} not found") + + await manager.start_processing(device_id) + + logger.info(f"Started processing for device {device_id}") + return {"status": "started", "device_id": device_id} + + except RuntimeError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Failed to start processing: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/api/v1/devices/{device_id}/stop", tags=["Processing"]) +async def stop_processing( + device_id: str, + _auth: AuthRequired, + manager: ProcessorManager = Depends(get_processor_manager), +): + """Stop screen processing for a device.""" + try: + await manager.stop_processing(device_id) + + logger.info(f"Stopped processing for device {device_id}") + return {"status": "stopped", "device_id": device_id} + + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + logger.error(f"Failed to stop processing: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/api/v1/devices/{device_id}/state", response_model=ProcessingState, tags=["Processing"]) +async def get_processing_state( + device_id: str, + _auth: AuthRequired, + manager: ProcessorManager = Depends(get_processor_manager), +): + """Get current processing state for a device.""" + try: + state = manager.get_state(device_id) + return ProcessingState(**state) + + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + logger.error(f"Failed to get state: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ===== SETTINGS ENDPOINTS ===== + +@router.get("/api/v1/devices/{device_id}/settings", response_model=ProcessingSettingsSchema, tags=["Settings"]) +async def get_settings( + device_id: str, + _auth: AuthRequired, + store: DeviceStore = Depends(get_device_store), +): + """Get processing settings for a device.""" + device = store.get_device(device_id) + if not device: + raise HTTPException(status_code=404, detail=f"Device {device_id} not found") + + return ProcessingSettingsSchema( + display_index=device.settings.display_index, + fps=device.settings.fps, + border_width=device.settings.border_width, + interpolation_mode=device.settings.interpolation_mode, + brightness=device.settings.brightness, + smoothing=device.settings.smoothing, + state_check_interval=device.settings.state_check_interval, + ) + + +@router.put("/api/v1/devices/{device_id}/settings", response_model=ProcessingSettingsSchema, tags=["Settings"]) +async def update_settings( + device_id: str, + settings: ProcessingSettingsSchema, + _auth: AuthRequired, + store: DeviceStore = Depends(get_device_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Update processing settings for a device. + + Merges with existing settings so callers can send partial updates. + Only fields explicitly included in the request body are applied. + """ + try: + # Get existing device to merge settings + device = store.get_device(device_id) + if not device: + raise HTTPException(status_code=404, detail=f"Device {device_id} not found") + + existing = device.settings + sent = settings.model_fields_set # fields the client actually sent + + # Merge: only override fields the client explicitly provided + new_settings = ProcessingSettings( + display_index=settings.display_index if 'display_index' in sent else existing.display_index, + fps=settings.fps if 'fps' in sent else existing.fps, + border_width=settings.border_width if 'border_width' in sent else existing.border_width, + interpolation_mode=settings.interpolation_mode if 'interpolation_mode' in sent else existing.interpolation_mode, + brightness=settings.brightness if 'brightness' in sent else existing.brightness, + gamma=existing.gamma, + saturation=existing.saturation, + smoothing=settings.smoothing if 'smoothing' in sent else existing.smoothing, + state_check_interval=settings.state_check_interval if 'state_check_interval' in sent else existing.state_check_interval, + ) + + # Apply color_correction fields if explicitly sent + if 'color_correction' in sent and settings.color_correction: + cc_sent = settings.color_correction.model_fields_set + if 'brightness' in cc_sent: + new_settings.brightness = settings.color_correction.brightness + if 'gamma' in cc_sent: + new_settings.gamma = settings.color_correction.gamma + if 'saturation' in cc_sent: + new_settings.saturation = settings.color_correction.saturation + + # Update in storage + device = store.update_device(device_id, settings=new_settings) + + # Update in manager if device exists + try: + manager.update_settings(device_id, new_settings) + except ValueError: + # Device not in manager yet, that's ok + pass + + return ProcessingSettingsSchema( + display_index=device.settings.display_index, + fps=device.settings.fps, + border_width=device.settings.border_width, + interpolation_mode=device.settings.interpolation_mode, + brightness=device.settings.brightness, + smoothing=device.settings.smoothing, + state_check_interval=device.settings.state_check_interval, + ) + + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + logger.error(f"Failed to update settings: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ===== WLED BRIGHTNESS ENDPOINT ===== + +@router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"]) +async def get_device_brightness( + device_id: str, + _auth: AuthRequired, + store: DeviceStore = Depends(get_device_store), +): + """Get current brightness from the WLED device.""" + device = store.get_device(device_id) + if not device: + raise HTTPException(status_code=404, detail=f"Device {device_id} not found") + + try: + async with httpx.AsyncClient(timeout=5.0) as http_client: + resp = await http_client.get(f"{device.url}/json/state") + resp.raise_for_status() + state = resp.json() + bri = state.get("bri", 255) + return {"brightness": bri} + except Exception as e: + logger.error(f"Failed to get WLED brightness for {device_id}: {e}") + raise HTTPException(status_code=502, detail=f"Failed to reach WLED device: {e}") + + +@router.put("/api/v1/devices/{device_id}/brightness", tags=["Settings"]) +async def set_device_brightness( + device_id: str, + body: dict, + _auth: AuthRequired, + store: DeviceStore = Depends(get_device_store), +): + """Set brightness on the WLED device directly.""" + device = store.get_device(device_id) + if not device: + raise HTTPException(status_code=404, detail=f"Device {device_id} not found") + + bri = body.get("brightness") + if bri is None or not isinstance(bri, int) or not 0 <= bri <= 255: + raise HTTPException(status_code=400, detail="brightness must be an integer 0-255") + + try: + async with httpx.AsyncClient(timeout=5.0) as http_client: + resp = await http_client.post( + f"{device.url}/json/state", + json={"bri": bri}, + ) + resp.raise_for_status() + return {"brightness": bri} + except Exception as e: + logger.error(f"Failed to set WLED brightness for {device_id}: {e}") + raise HTTPException(status_code=502, detail=f"Failed to reach WLED device: {e}") + + +# ===== CALIBRATION ENDPOINTS ===== + +@router.get("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"]) +async def get_calibration( + device_id: str, + _auth: AuthRequired, + store: DeviceStore = Depends(get_device_store), +): + """Get calibration configuration for a device.""" + device = store.get_device(device_id) + if not device: + raise HTTPException(status_code=404, detail=f"Device {device_id} not found") + + return CalibrationSchema(**calibration_to_dict(device.calibration)) + + +@router.put("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"]) +async def update_calibration( + device_id: str, + calibration_data: CalibrationSchema, + _auth: AuthRequired, + store: DeviceStore = Depends(get_device_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Update calibration configuration for a device.""" + try: + # Convert schema to CalibrationConfig + calibration_dict = calibration_data.model_dump() + calibration = calibration_from_dict(calibration_dict) + + # Update in storage + device = store.update_device(device_id, calibration=calibration) + + # Update in manager if device exists + try: + manager.update_calibration(device_id, calibration) + except ValueError: + # Device not in manager yet, that's ok + pass + + return CalibrationSchema(**calibration_to_dict(device.calibration)) + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Failed to update calibration: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put( + "/api/v1/devices/{device_id}/calibration/test", + response_model=CalibrationTestModeResponse, + tags=["Calibration"], +) +async def set_calibration_test_mode( + device_id: str, + body: CalibrationTestModeRequest, + _auth: AuthRequired, + store: DeviceStore = Depends(get_device_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Toggle calibration test mode for specific edges. + + Send edges with colors to light them up, or empty edges dict to exit test mode. + While test mode is active, screen capture processing is paused. + """ + try: + device = store.get_device(device_id) + if not device: + raise HTTPException(status_code=404, detail=f"Device {device_id} not found") + + # Validate edge names and colors + valid_edges = {"top", "right", "bottom", "left"} + for edge_name, color in body.edges.items(): + if edge_name not in valid_edges: + raise HTTPException( + status_code=400, + detail=f"Invalid edge '{edge_name}'. Must be one of: {', '.join(valid_edges)}" + ) + if len(color) != 3 or not all(0 <= c <= 255 for c in color): + raise HTTPException( + status_code=400, + detail=f"Invalid color for edge '{edge_name}'. Must be [R, G, B] with values 0-255." + ) + + await manager.set_test_mode(device_id, body.edges) + + active_edges = list(body.edges.keys()) + logger.info( + f"Test mode {'activated' if active_edges else 'deactivated'} " + f"for device {device_id}: {active_edges}" + ) + + return CalibrationTestModeResponse( + test_mode=len(active_edges) > 0, + active_edges=active_edges, + device_id=device_id, + ) + + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + logger.error(f"Failed to set test mode: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ===== METRICS ENDPOINTS ===== + +@router.get("/api/v1/devices/{device_id}/metrics", response_model=MetricsResponse, tags=["Metrics"]) +async def get_metrics( + device_id: str, + _auth: AuthRequired, + manager: ProcessorManager = Depends(get_processor_manager), +): + """Get processing metrics for a device.""" + try: + metrics = manager.get_metrics(device_id) + return MetricsResponse(**metrics) + + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + logger.error(f"Failed to get metrics: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/server/src/wled_controller/api/routes/picture_sources.py b/server/src/wled_controller/api/routes/picture_sources.py new file mode 100644 index 0000000..231c91c --- /dev/null +++ b/server/src/wled_controller/api/routes/picture_sources.py @@ -0,0 +1,475 @@ +"""Picture source routes.""" + +import base64 +import io +import time + +import httpx +import numpy as np +from PIL import Image +from fastapi import APIRouter, HTTPException, Depends, Query +from fastapi.responses import Response + +from wled_controller.api.auth import AuthRequired +from wled_controller.api.dependencies import ( + get_device_store, + get_picture_source_store, + get_pp_template_store, + get_processor_manager, + get_template_store, +) +from wled_controller.api.schemas.common import ( + CaptureImage, + PerformanceMetrics, + TemplateTestResponse, +) +from wled_controller.api.schemas.picture_sources import ( + ImageValidateRequest, + ImageValidateResponse, + PictureSourceCreate, + PictureSourceListResponse, + PictureSourceResponse, + PictureSourceTestRequest, + PictureSourceUpdate, +) +from wled_controller.core.capture_engines import EngineRegistry +from wled_controller.core.filters import FilterRegistry, ImagePool +from wled_controller.core.processor_manager import ProcessorManager +from wled_controller.storage import DeviceStore +from wled_controller.storage.template_store import TemplateStore +from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore +from wled_controller.storage.picture_source_store import PictureSourceStore +from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +router = APIRouter() + + +def _stream_to_response(s) -> PictureSourceResponse: + """Convert a PictureSource to its API response.""" + return PictureSourceResponse( + id=s.id, + name=s.name, + stream_type=s.stream_type, + display_index=getattr(s, "display_index", None), + capture_template_id=getattr(s, "capture_template_id", None), + target_fps=getattr(s, "target_fps", None), + source_stream_id=getattr(s, "source_stream_id", None), + postprocessing_template_id=getattr(s, "postprocessing_template_id", None), + image_source=getattr(s, "image_source", None), + created_at=s.created_at, + updated_at=s.updated_at, + description=s.description, + ) + + +@router.get("/api/v1/picture-sources", response_model=PictureSourceListResponse, tags=["Picture Sources"]) +async def list_picture_sources( + _auth: AuthRequired, + store: PictureSourceStore = Depends(get_picture_source_store), +): + """List all picture sources.""" + try: + streams = store.get_all_streams() + responses = [_stream_to_response(s) for s in streams] + return PictureSourceListResponse(streams=responses, count=len(responses)) + except Exception as e: + logger.error(f"Failed to list picture sources: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/api/v1/picture-sources/validate-image", response_model=ImageValidateResponse, tags=["Picture Sources"]) +async def validate_image( + data: ImageValidateRequest, + _auth: AuthRequired, +): + """Validate an image source (URL or file path) and return a preview thumbnail.""" + try: + from pathlib import Path + + source = data.image_source.strip() + if not source: + return ImageValidateResponse(valid=False, error="Image source is empty") + + if source.startswith(("http://", "https://")): + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: + response = await client.get(source) + response.raise_for_status() + pil_image = Image.open(io.BytesIO(response.content)) + else: + path = Path(source) + if not path.exists(): + return ImageValidateResponse(valid=False, error=f"File not found: {source}") + pil_image = Image.open(path) + + pil_image = pil_image.convert("RGB") + width, height = pil_image.size + + # Create thumbnail preview (max 320px wide) + thumb = pil_image.copy() + thumb.thumbnail((320, 320), Image.Resampling.LANCZOS) + buf = io.BytesIO() + thumb.save(buf, format="JPEG", quality=80) + buf.seek(0) + preview = f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}" + + return ImageValidateResponse( + valid=True, width=width, height=height, preview=preview + ) + + except httpx.HTTPStatusError as e: + return ImageValidateResponse(valid=False, error=f"HTTP {e.response.status_code}: {e.response.reason_phrase}") + except httpx.RequestError as e: + return ImageValidateResponse(valid=False, error=f"Request failed: {e}") + except Exception as e: + return ImageValidateResponse(valid=False, error=str(e)) + + +@router.get("/api/v1/picture-sources/full-image", tags=["Picture Sources"]) +async def get_full_image( + _auth: AuthRequired, + source: str = Query(..., description="Image URL or local file path"), +): + """Serve the full-resolution image for lightbox preview.""" + from pathlib import Path + + try: + if source.startswith(("http://", "https://")): + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: + response = await client.get(source) + response.raise_for_status() + pil_image = Image.open(io.BytesIO(response.content)) + else: + path = Path(source) + if not path.exists(): + raise HTTPException(status_code=404, detail="File not found") + pil_image = Image.open(path) + + pil_image = pil_image.convert("RGB") + buf = io.BytesIO() + pil_image.save(buf, format="JPEG", quality=90) + buf.seek(0) + return Response(content=buf.getvalue(), media_type="image/jpeg") + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/api/v1/picture-sources", response_model=PictureSourceResponse, tags=["Picture Sources"], status_code=201) +async def create_picture_source( + data: PictureSourceCreate, + _auth: AuthRequired, + store: PictureSourceStore = Depends(get_picture_source_store), + template_store: TemplateStore = Depends(get_template_store), + pp_store: PostprocessingTemplateStore = Depends(get_pp_template_store), +): + """Create a new picture source.""" + try: + # Validate referenced entities + if data.stream_type == "raw" and data.capture_template_id: + try: + template_store.get_template(data.capture_template_id) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Capture template not found: {data.capture_template_id}", + ) + + if data.stream_type == "processed" and data.postprocessing_template_id: + try: + pp_store.get_template(data.postprocessing_template_id) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Postprocessing template not found: {data.postprocessing_template_id}", + ) + + stream = store.create_stream( + name=data.name, + stream_type=data.stream_type, + display_index=data.display_index, + capture_template_id=data.capture_template_id, + target_fps=data.target_fps, + source_stream_id=data.source_stream_id, + postprocessing_template_id=data.postprocessing_template_id, + image_source=data.image_source, + description=data.description, + ) + return _stream_to_response(stream) + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Failed to create picture source: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/api/v1/picture-sources/{stream_id}", response_model=PictureSourceResponse, tags=["Picture Sources"]) +async def get_picture_source( + stream_id: str, + _auth: AuthRequired, + store: PictureSourceStore = Depends(get_picture_source_store), +): + """Get picture source by ID.""" + try: + stream = store.get_stream(stream_id) + return _stream_to_response(stream) + except ValueError: + raise HTTPException(status_code=404, detail=f"Picture source {stream_id} not found") + + +@router.put("/api/v1/picture-sources/{stream_id}", response_model=PictureSourceResponse, tags=["Picture Sources"]) +async def update_picture_source( + stream_id: str, + data: PictureSourceUpdate, + _auth: AuthRequired, + store: PictureSourceStore = Depends(get_picture_source_store), +): + """Update a picture source.""" + try: + stream = store.update_stream( + stream_id=stream_id, + name=data.name, + display_index=data.display_index, + capture_template_id=data.capture_template_id, + target_fps=data.target_fps, + source_stream_id=data.source_stream_id, + postprocessing_template_id=data.postprocessing_template_id, + image_source=data.image_source, + description=data.description, + ) + return _stream_to_response(stream) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Failed to update picture source: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/api/v1/picture-sources/{stream_id}", status_code=204, tags=["Picture Sources"]) +async def delete_picture_source( + stream_id: str, + _auth: AuthRequired, + store: PictureSourceStore = Depends(get_picture_source_store), + device_store: DeviceStore = Depends(get_device_store), +): + """Delete a picture source.""" + try: + # Check if any device references this stream + if store.is_referenced_by_device(stream_id, device_store): + raise HTTPException( + status_code=409, + detail="Cannot delete picture source: it is assigned to one or more devices. " + "Please reassign those devices before deleting.", + ) + store.delete_stream(stream_id) + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Failed to delete picture source: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/api/v1/picture-sources/{stream_id}/test", response_model=TemplateTestResponse, tags=["Picture Sources"]) +async def test_picture_source( + stream_id: str, + test_request: PictureSourceTestRequest, + _auth: AuthRequired, + store: PictureSourceStore = Depends(get_picture_source_store), + template_store: TemplateStore = Depends(get_template_store), + processor_manager: ProcessorManager = Depends(get_processor_manager), + device_store: DeviceStore = Depends(get_device_store), + pp_store: PostprocessingTemplateStore = Depends(get_pp_template_store), +): + """Test a picture source by resolving its chain and running a capture test. + + Resolves the stream chain to the raw stream, captures frames, + and returns preview image + performance metrics. + For processed streams, applies postprocessing (gamma, saturation, brightness) + to the preview image. + """ + stream = None + try: + # Resolve stream chain + try: + chain = store.resolve_stream_chain(stream_id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + raw_stream = chain["raw_stream"] + + if isinstance(raw_stream, StaticImagePictureSource): + # Static image stream: load image directly, no engine needed + from pathlib import Path + + source = raw_stream.image_source + start_time = time.perf_counter() + + if source.startswith(("http://", "https://")): + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: + resp = await client.get(source) + resp.raise_for_status() + pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB") + else: + path = Path(source) + if not path.exists(): + raise HTTPException(status_code=400, detail=f"Image file not found: {source}") + pil_image = Image.open(path).convert("RGB") + + actual_duration = time.perf_counter() - start_time + frame_count = 1 + total_capture_time = actual_duration + + elif isinstance(raw_stream, ScreenCapturePictureSource): + # Screen capture stream: use engine + try: + capture_template = template_store.get_template(raw_stream.capture_template_id) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Capture template not found: {raw_stream.capture_template_id}", + ) + + display_index = raw_stream.display_index + + if capture_template.engine_type not in EngineRegistry.get_available_engines(): + raise HTTPException( + status_code=400, + detail=f"Engine '{capture_template.engine_type}' is not available on this system", + ) + + locked_device_id = processor_manager.get_display_lock_info(display_index) + if locked_device_id: + 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 {display_index} is currently being captured by device '{device_name}'. " + f"Please stop the device processing before testing.", + ) + + stream = EngineRegistry.create_stream( + capture_template.engine_type, display_index, capture_template.engine_config + ) + stream.initialize() + + logger.info(f"Starting {test_request.capture_duration}s stream test for {stream_id}") + + 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 = stream.capture_frame() + capture_elapsed = time.perf_counter() - capture_start + + if screen_capture is None: + continue + + total_capture_time += capture_elapsed + frame_count += 1 + last_frame = screen_capture + + actual_duration = time.perf_counter() - start_time + + if last_frame is None: + raise RuntimeError("No frames captured during test") + + if isinstance(last_frame.image, np.ndarray): + pil_image = Image.fromarray(last_frame.image) + else: + raise ValueError("Unexpected image format from engine") + + # Create thumbnail + 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) + + # Apply postprocessing filters if this is a processed stream + pp_template_ids = chain["postprocessing_template_ids"] + if pp_template_ids: + try: + pp_template = pp_store.get_template(pp_template_ids[0]) + pool = ImagePool() + + def apply_filters(img): + arr = np.array(img) + for fi in pp_template.filters: + f = FilterRegistry.create_instance(fi.filter_id, fi.options) + result = f.process_image(arr, pool) + if result is not None: + arr = result + return Image.fromarray(arr) + + thumbnail = apply_filters(thumbnail) + pil_image = apply_filters(pil_image) + except ValueError: + logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview") + + # Encode thumbnail + img_buffer = io.BytesIO() + thumbnail.save(img_buffer, format='JPEG', quality=85) + img_buffer.seek(0) + thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8') + thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}" + + # Encode full-resolution image + full_buffer = io.BytesIO() + pil_image.save(full_buffer, format='JPEG', quality=90) + full_buffer.seek(0) + full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8') + full_data_uri = f"data:image/jpeg;base64,{full_b64}" + + 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=thumbnail_data_uri, + full_image=full_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 HTTPException: + raise + 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 picture source: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + finally: + if stream: + try: + stream.cleanup() + except Exception as e: + logger.error(f"Error cleaning up test stream: {e}") diff --git a/server/src/wled_controller/api/routes/postprocessing.py b/server/src/wled_controller/api/routes/postprocessing.py new file mode 100644 index 0000000..e5c79a5 --- /dev/null +++ b/server/src/wled_controller/api/routes/postprocessing.py @@ -0,0 +1,348 @@ +"""Postprocessing template routes.""" + +import base64 +import io +import time + +import httpx +import numpy as np +from PIL import Image +from fastapi import APIRouter, HTTPException, Depends + +from wled_controller.api.auth import AuthRequired +from wled_controller.api.dependencies import ( + get_device_store, + get_picture_source_store, + get_pp_template_store, + get_processor_manager, + get_template_store, +) +from wled_controller.api.schemas.common import ( + CaptureImage, + PerformanceMetrics, + TemplateTestResponse, +) +from wled_controller.api.schemas.filters import FilterInstanceSchema +from wled_controller.api.schemas.postprocessing import ( + PostprocessingTemplateCreate, + PostprocessingTemplateListResponse, + PostprocessingTemplateResponse, + PostprocessingTemplateUpdate, + PPTemplateTestRequest, +) +from wled_controller.core.capture_engines import EngineRegistry +from wled_controller.core.filters import FilterRegistry, FilterInstance, ImagePool +from wled_controller.core.processor_manager import ProcessorManager +from wled_controller.storage import DeviceStore +from wled_controller.storage.template_store import TemplateStore +from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore +from wled_controller.storage.picture_source_store import PictureSourceStore +from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +router = APIRouter() + + +def _pp_template_to_response(t) -> PostprocessingTemplateResponse: + """Convert a PostprocessingTemplate to its API response.""" + return PostprocessingTemplateResponse( + id=t.id, + name=t.name, + filters=[FilterInstanceSchema(filter_id=f.filter_id, options=f.options) for f in t.filters], + created_at=t.created_at, + updated_at=t.updated_at, + description=t.description, + ) + + +@router.get("/api/v1/postprocessing-templates", response_model=PostprocessingTemplateListResponse, tags=["Postprocessing Templates"]) +async def list_pp_templates( + _auth: AuthRequired, + store: PostprocessingTemplateStore = Depends(get_pp_template_store), +): + """List all postprocessing templates.""" + try: + templates = store.get_all_templates() + responses = [_pp_template_to_response(t) for t in templates] + return PostprocessingTemplateListResponse(templates=responses, count=len(responses)) + except Exception as e: + logger.error(f"Failed to list postprocessing templates: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/api/v1/postprocessing-templates", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"], status_code=201) +async def create_pp_template( + data: PostprocessingTemplateCreate, + _auth: AuthRequired, + store: PostprocessingTemplateStore = Depends(get_pp_template_store), +): + """Create a new postprocessing template.""" + try: + filters = [FilterInstance(f.filter_id, f.options) for f in data.filters] + template = store.create_template( + name=data.name, + filters=filters, + description=data.description, + ) + return _pp_template_to_response(template) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Failed to create postprocessing template: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/api/v1/postprocessing-templates/{template_id}", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"]) +async def get_pp_template( + template_id: str, + _auth: AuthRequired, + store: PostprocessingTemplateStore = Depends(get_pp_template_store), +): + """Get postprocessing template by ID.""" + try: + template = store.get_template(template_id) + return _pp_template_to_response(template) + except ValueError: + raise HTTPException(status_code=404, detail=f"Postprocessing template {template_id} not found") + + +@router.put("/api/v1/postprocessing-templates/{template_id}", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"]) +async def update_pp_template( + template_id: str, + data: PostprocessingTemplateUpdate, + _auth: AuthRequired, + store: PostprocessingTemplateStore = Depends(get_pp_template_store), +): + """Update a postprocessing template.""" + try: + filters = [FilterInstance(f.filter_id, f.options) for f in data.filters] if data.filters is not None else None + template = store.update_template( + template_id=template_id, + name=data.name, + filters=filters, + description=data.description, + ) + return _pp_template_to_response(template) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Failed to update postprocessing template: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/api/v1/postprocessing-templates/{template_id}", status_code=204, tags=["Postprocessing Templates"]) +async def delete_pp_template( + template_id: str, + _auth: AuthRequired, + store: PostprocessingTemplateStore = Depends(get_pp_template_store), + stream_store: PictureSourceStore = Depends(get_picture_source_store), +): + """Delete a postprocessing template.""" + try: + # Check if any picture source references this template + if store.is_referenced_by(template_id, stream_store): + raise HTTPException( + status_code=409, + detail="Cannot delete postprocessing template: it is referenced by one or more picture sources. " + "Please reassign those streams before deleting.", + ) + store.delete_template(template_id) + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Failed to delete postprocessing template: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/api/v1/postprocessing-templates/{template_id}/test", response_model=TemplateTestResponse, tags=["Postprocessing Templates"]) +async def test_pp_template( + template_id: str, + test_request: PPTemplateTestRequest, + _auth: AuthRequired, + pp_store: PostprocessingTemplateStore = Depends(get_pp_template_store), + stream_store: PictureSourceStore = Depends(get_picture_source_store), + template_store: TemplateStore = Depends(get_template_store), + processor_manager: ProcessorManager = Depends(get_processor_manager), + device_store: DeviceStore = Depends(get_device_store), +): + """Test a postprocessing template by capturing from a source stream and applying filters.""" + stream = None + try: + # Get the PP template + try: + pp_template = pp_store.get_template(template_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + # Resolve source stream chain to get the raw stream + try: + chain = stream_store.resolve_stream_chain(test_request.source_stream_id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + raw_stream = chain["raw_stream"] + + if isinstance(raw_stream, StaticImagePictureSource): + # Static image: load directly + from pathlib import Path + + source = raw_stream.image_source + start_time = time.perf_counter() + + if source.startswith(("http://", "https://")): + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: + resp = await client.get(source) + resp.raise_for_status() + pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB") + else: + path = Path(source) + if not path.exists(): + raise HTTPException(status_code=400, detail=f"Image file not found: {source}") + pil_image = Image.open(path).convert("RGB") + + actual_duration = time.perf_counter() - start_time + frame_count = 1 + total_capture_time = actual_duration + elif isinstance(raw_stream, ScreenCapturePictureSource): + # Screen capture stream: use engine + try: + capture_template = template_store.get_template(raw_stream.capture_template_id) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Capture template not found: {raw_stream.capture_template_id}", + ) + + display_index = raw_stream.display_index + + if capture_template.engine_type not in EngineRegistry.get_available_engines(): + raise HTTPException( + status_code=400, + detail=f"Engine '{capture_template.engine_type}' is not available on this system", + ) + + locked_device_id = processor_manager.get_display_lock_info(display_index) + if locked_device_id: + 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 {display_index} is currently being captured by device '{device_name}'. " + f"Please stop the device processing before testing.", + ) + + stream = EngineRegistry.create_stream( + capture_template.engine_type, display_index, capture_template.engine_config + ) + stream.initialize() + + logger.info(f"Starting {test_request.capture_duration}s PP template test for {template_id} using stream {test_request.source_stream_id}") + + 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 = stream.capture_frame() + capture_elapsed = time.perf_counter() - capture_start + + if screen_capture is None: + continue + + total_capture_time += capture_elapsed + frame_count += 1 + last_frame = screen_capture + + actual_duration = time.perf_counter() - start_time + + if last_frame is None: + raise RuntimeError("No frames captured during test") + + if isinstance(last_frame.image, np.ndarray): + pil_image = Image.fromarray(last_frame.image) + else: + raise ValueError("Unexpected image format from engine") + + # Create thumbnail + 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) + + # Apply postprocessing filters + if pp_template.filters: + pool = ImagePool() + + def apply_filters(img): + arr = np.array(img) + for fi in pp_template.filters: + f = FilterRegistry.create_instance(fi.filter_id, fi.options) + result = f.process_image(arr, pool) + if result is not None: + arr = result + return Image.fromarray(arr) + + thumbnail = apply_filters(thumbnail) + pil_image = apply_filters(pil_image) + + # Encode thumbnail + img_buffer = io.BytesIO() + thumbnail.save(img_buffer, format='JPEG', quality=85) + img_buffer.seek(0) + thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8') + thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}" + + # Encode full-resolution image + full_buffer = io.BytesIO() + pil_image.save(full_buffer, format='JPEG', quality=90) + full_buffer.seek(0) + full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8') + full_data_uri = f"data:image/jpeg;base64,{full_b64}" + + 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=thumbnail_data_uri, + full_image=full_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 HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Postprocessing template test failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + finally: + if stream: + try: + stream.cleanup() + except Exception: + pass diff --git a/server/src/wled_controller/api/routes/system.py b/server/src/wled_controller/api/routes/system.py new file mode 100644 index 0000000..01a8a3e --- /dev/null +++ b/server/src/wled_controller/api/routes/system.py @@ -0,0 +1,93 @@ +"""System routes: health, version, displays.""" + +import sys +from datetime import datetime + +from fastapi import APIRouter, HTTPException + +from wled_controller import __version__ +from wled_controller.api.auth import AuthRequired +from wled_controller.api.schemas.system import ( + DisplayInfo, + DisplayListResponse, + HealthResponse, + VersionResponse, +) +from wled_controller.core.screen_capture import get_available_displays +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +router = APIRouter() + + +@router.get("/health", response_model=HealthResponse, tags=["Health"]) +async def health_check(): + """Check service health status. + + Returns basic health information including status, version, and timestamp. + """ + logger.info("Health check requested") + + return HealthResponse( + status="healthy", + timestamp=datetime.utcnow(), + version=__version__, + ) + + +@router.get("/api/v1/version", response_model=VersionResponse, tags=["Info"]) +async def get_version(): + """Get version information. + + Returns application version, Python version, and API version. + """ + logger.info("Version info requested") + + return VersionResponse( + version=__version__, + python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + api_version="v1", + ) + + +@router.get("/api/v1/config/displays", response_model=DisplayListResponse, tags=["Config"]) +async def get_displays(_: AuthRequired): + """Get list of available displays. + + Returns information about all available monitors/displays that can be captured. + """ + logger.info("Listing available displays") + + try: + # Get available displays with all metadata (name, refresh rate, etc.) + display_dataclasses = get_available_displays() + + # Convert dataclass DisplayInfo to Pydantic DisplayInfo + displays = [ + DisplayInfo( + index=d.index, + name=d.name, + width=d.width, + height=d.height, + x=d.x, + y=d.y, + is_primary=d.is_primary, + refresh_rate=d.refresh_rate, + ) + for d in display_dataclasses + ] + + logger.info(f"Found {len(displays)} displays") + + return DisplayListResponse( + displays=displays, + count=len(displays), + ) + + except Exception as e: + logger.error(f"Failed to get displays: {e}") + raise HTTPException( + status_code=500, + detail=f"Failed to retrieve display information: {str(e)}" + ) diff --git a/server/src/wled_controller/api/routes/templates.py b/server/src/wled_controller/api/routes/templates.py new file mode 100644 index 0000000..3bc3eba --- /dev/null +++ b/server/src/wled_controller/api/routes/templates.py @@ -0,0 +1,410 @@ +"""Capture template, engine, and filter routes.""" + +import base64 +import io +import time + +import numpy as np +from PIL import Image +from fastapi import APIRouter, HTTPException, Depends + +from wled_controller.api.auth import AuthRequired +from wled_controller.api.dependencies import ( + get_device_store, + get_picture_source_store, + get_processor_manager, + get_template_store, +) +from wled_controller.api.schemas.common import ( + CaptureImage, + PerformanceMetrics, + TemplateTestResponse, +) +from wled_controller.api.schemas.templates import ( + EngineInfo, + EngineListResponse, + TemplateCreate, + TemplateListResponse, + TemplateResponse, + TemplateTestRequest, + TemplateUpdate, +) +from wled_controller.api.schemas.filters import ( + FilterOptionDefSchema, + FilterTypeListResponse, + FilterTypeResponse, +) +from wled_controller.core.capture_engines import EngineRegistry +from wled_controller.core.filters import FilterRegistry +from wled_controller.core.processor_manager import ProcessorManager +from wled_controller.storage import DeviceStore +from wled_controller.storage.template_store import TemplateStore +from wled_controller.storage.picture_source_store import PictureSourceStore +from wled_controller.storage.picture_source import ScreenCapturePictureSource +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +router = APIRouter() + + +# ===== 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, + + 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, + + 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.""" + try: + template = template_store.get_template(template_id) + except ValueError: + 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, + 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_type=update_data.engine_type, + 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, + + 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), + stream_store: PictureSourceStore = Depends(get_picture_source_store), +): + """Delete a template. + + Validates that no streams are currently using this template before deletion. + """ + try: + # Check if any streams are using this template + streams_using_template = [] + for stream in stream_store.get_all_streams(): + if isinstance(stream, ScreenCapturePictureSource) and stream.capture_template_id == template_id: + streams_using_template.append(stream.name) + + if streams_using_template: + stream_list = ", ".join(streams_using_template) + raise HTTPException( + status_code=409, + detail=f"Cannot delete template: it is used by the following stream(s): {stream_list}. " + f"Please reassign these streams 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. + """ + stream = 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 and initialize capture stream + stream = EngineRegistry.create_stream( + test_request.engine_type, test_request.display_index, test_request.engine_config + ) + stream.initialize() + + # 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 = stream.capture_frame() + capture_elapsed = time.perf_counter() - capture_start + + # Skip if no new frame (screen unchanged) + if screen_capture is None: + continue + + 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 thumbnail as JPEG + img_buffer = io.BytesIO() + thumbnail.save(img_buffer, format='JPEG', quality=85) + img_buffer.seek(0) + thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8') + thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}" + + # Encode full-resolution image as JPEG + full_buffer = io.BytesIO() + pil_image.save(full_buffer, format='JPEG', quality=90) + full_buffer.seek(0) + full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8') + full_data_uri = f"data:image/jpeg;base64,{full_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=thumbnail_data_uri, + full_image=full_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: + if stream: + try: + stream.cleanup() + except Exception as e: + logger.error(f"Error cleaning up test stream: {e}") + + +# ===== FILTER TYPE ENDPOINTS ===== + +@router.get("/api/v1/filters", response_model=FilterTypeListResponse, tags=["Filters"]) +async def list_filter_types(_auth: AuthRequired): + """List all available postprocessing filter types and their options schemas.""" + all_filters = FilterRegistry.get_all() + responses = [] + for filter_id, filter_cls in all_filters.items(): + schema = filter_cls.get_options_schema() + responses.append(FilterTypeResponse( + filter_id=filter_cls.filter_id, + filter_name=filter_cls.filter_name, + options_schema=[ + FilterOptionDefSchema( + key=opt.key, + label=opt.label, + type=opt.option_type, + default=opt.default, + min_value=opt.min_value, + max_value=opt.max_value, + step=opt.step, + ) + for opt in schema + ], + )) + return FilterTypeListResponse(filters=responses, count=len(responses)) diff --git a/server/src/wled_controller/api/schemas.py b/server/src/wled_controller/api/schemas.py deleted file mode 100644 index b42d2b8..0000000 --- a/server/src/wled_controller/api/schemas.py +++ /dev/null @@ -1,482 +0,0 @@ -"""Pydantic schemas for API request and response models.""" - -from datetime import datetime -from typing import Any, Dict, List, Literal, Optional - -from pydantic import BaseModel, Field, HttpUrl - -from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL - - -# Health and Version Schemas - -class HealthResponse(BaseModel): - """Health check response.""" - - status: Literal["healthy", "unhealthy"] = Field(description="Service health status") - timestamp: datetime = Field(description="Current server time") - version: str = Field(description="Application version") - - -class VersionResponse(BaseModel): - """Version information response.""" - - version: str = Field(description="Application version") - python_version: str = Field(description="Python version") - api_version: str = Field(description="API version") - - -# Display Schemas - -class DisplayInfo(BaseModel): - """Display/monitor information.""" - - index: int = Field(description="Display index") - name: str = Field(description="Display name") - width: int = Field(description="Display width in pixels") - height: int = Field(description="Display height in pixels") - x: int = Field(description="Display X position") - y: int = Field(description="Display Y position") - is_primary: bool = Field(default=False, description="Whether this is the primary display") - refresh_rate: int = Field(description="Display refresh rate in Hz") - - -class DisplayListResponse(BaseModel): - """List of available displays.""" - - displays: List[DisplayInfo] = Field(description="Available displays") - count: int = Field(description="Number of displays") - - -# Device Schemas - -class DeviceCreate(BaseModel): - """Request to create/attach a WLED device.""" - - name: str = Field(description="Device name", min_length=1, max_length=100) - url: str = Field(description="WLED device URL (e.g., http://192.168.1.100)") - capture_template_id: Optional[str] = Field(None, description="Capture template ID (uses first available if not set or invalid)") - - -class DeviceUpdate(BaseModel): - """Request to update device information.""" - - name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100) - url: Optional[str] = Field(None, description="WLED device URL") - enabled: Optional[bool] = Field(None, description="Whether device is enabled") - capture_template_id: Optional[str] = Field(None, description="Capture template ID (legacy)") - picture_stream_id: Optional[str] = Field(None, description="Picture stream ID") - - -class ColorCorrection(BaseModel): - """Color correction settings.""" - - gamma: float = Field(default=2.2, description="Gamma correction", ge=0.1, le=5.0) - saturation: float = Field(default=1.0, description="Saturation multiplier", ge=0.0, le=2.0) - brightness: float = Field(default=1.0, description="Brightness multiplier", ge=0.0, le=1.0) - - -class ProcessingSettings(BaseModel): - """Processing settings for a device.""" - - display_index: int = Field(default=0, description="Display to capture", ge=0) - fps: int = Field(default=30, description="Target frames per second", ge=10, le=90) - border_width: int = Field(default=10, description="Border width in pixels", ge=1, le=100) - interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)") - brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0) - smoothing: float = Field(default=0.3, description="Temporal smoothing factor (0.0=none, 1.0=full)", ge=0.0, le=1.0) - state_check_interval: int = Field( - default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600, - description="Seconds between WLED health checks" - ) - color_correction: Optional[ColorCorrection] = Field( - default_factory=ColorCorrection, - description="Color correction settings" - ) - - -class Calibration(BaseModel): - """Calibration configuration for pixel-to-LED mapping.""" - - layout: Literal["clockwise", "counterclockwise"] = Field( - default="clockwise", - description="LED strip layout direction" - ) - start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"] = Field( - default="bottom_left", - description="Starting corner of the LED strip" - ) - offset: int = Field( - default=0, - ge=0, - description="Number of LEDs from physical LED 0 to start corner (along strip direction)" - ) - leds_top: int = Field(default=0, ge=0, description="Number of LEDs on the top edge") - leds_right: int = Field(default=0, ge=0, description="Number of LEDs on the right edge") - leds_bottom: int = Field(default=0, ge=0, description="Number of LEDs on the bottom edge") - leds_left: int = Field(default=0, ge=0, description="Number of LEDs on the left edge") - # Per-edge span: fraction of screen side covered by LEDs (0.0–1.0) - span_top_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start of top edge coverage") - span_top_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End of top edge coverage") - span_right_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start of right edge coverage") - span_right_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End of right edge coverage") - span_bottom_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start of bottom edge coverage") - span_bottom_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End of bottom edge coverage") - span_left_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start of left edge coverage") - span_left_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End of left edge coverage") - - -class CalibrationTestModeRequest(BaseModel): - """Request to set calibration test mode with multiple edges.""" - - edges: Dict[str, List[int]] = Field( - default_factory=dict, - description="Map of active edge names to RGB colors. " - "E.g. {'top': [255, 0, 0], 'left': [255, 255, 0]}. " - "Empty dict = exit test mode." - ) - - -class CalibrationTestModeResponse(BaseModel): - """Response for calibration test mode.""" - - test_mode: bool = Field(description="Whether test mode is active") - active_edges: List[str] = Field(default_factory=list, description="Currently lit edges") - device_id: str = Field(description="Device ID") - - -class DeviceResponse(BaseModel): - """Device information response.""" - - id: str = Field(description="Device ID") - name: str = Field(description="Device name") - url: str = Field(description="WLED device URL") - led_count: int = Field(description="Total number of LEDs") - enabled: bool = Field(description="Whether device is enabled") - status: Literal["connected", "disconnected", "error"] = Field( - description="Connection status" - ) - settings: ProcessingSettings = Field(description="Processing settings") - calibration: Optional[Calibration] = Field(None, description="Calibration configuration") - capture_template_id: str = Field(description="ID of assigned capture template (legacy)") - picture_stream_id: str = Field(default="", description="ID of assigned picture stream") - created_at: datetime = Field(description="Creation timestamp") - updated_at: datetime = Field(description="Last update timestamp") - - -class DeviceListResponse(BaseModel): - """List of devices response.""" - - devices: List[DeviceResponse] = Field(description="List of devices") - count: int = Field(description="Number of devices") - - -# Processing State Schemas - -class ProcessingState(BaseModel): - """Processing state for a device.""" - - device_id: str = Field(description="Device ID") - processing: bool = Field(description="Whether processing is active") - fps_actual: Optional[float] = Field(None, description="Actual FPS achieved") - fps_target: int = Field(description="Target FPS") - display_index: int = Field(description="Current display index") - last_update: Optional[datetime] = Field(None, description="Last successful update") - errors: List[str] = Field(default_factory=list, description="Recent errors") - wled_online: bool = Field(default=False, description="Whether WLED device is reachable") - wled_latency_ms: Optional[float] = Field(None, description="WLED health check latency in ms") - wled_name: Optional[str] = Field(None, description="WLED device name") - wled_version: Optional[str] = Field(None, description="WLED firmware version") - wled_led_count: Optional[int] = Field(None, description="LED count reported by WLED device") - wled_rgbw: Optional[bool] = Field(None, description="Whether WLED device uses RGBW LEDs") - wled_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)") - wled_last_checked: Optional[datetime] = Field(None, description="Last health check time") - wled_error: Optional[str] = Field(None, description="Last health check error") - - -class MetricsResponse(BaseModel): - """Device metrics response.""" - - device_id: str = Field(description="Device ID") - processing: bool = Field(description="Whether processing is active") - fps_actual: Optional[float] = Field(None, description="Actual FPS") - fps_target: int = Field(description="Target FPS") - uptime_seconds: float = Field(description="Processing uptime in seconds") - frames_processed: int = Field(description="Total frames processed") - errors_count: int = Field(description="Total error count") - last_error: Optional[str] = Field(None, description="Last error message") - last_update: Optional[datetime] = Field(None, description="Last update timestamp") - - -# Error Schemas - -class ErrorResponse(BaseModel): - """Error response.""" - - error: str = Field(description="Error type") - message: str = Field(description="Error message") - detail: Optional[Dict] = Field(None, description="Additional error details") - timestamp: datetime = Field(default_factory=datetime.utcnow, description="Error timestamp") - - -# Capture Template Schemas - -class TemplateCreate(BaseModel): - """Request to create a capture template.""" - - name: str = Field(description="Template name", min_length=1, max_length=100) - engine_type: str = Field(description="Engine type (e.g., 'mss', 'dxcam', 'wgc')", min_length=1) - engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration") - description: Optional[str] = Field(None, description="Template description", max_length=500) - - -class TemplateUpdate(BaseModel): - """Request to update a template.""" - - name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100) - engine_type: Optional[str] = Field(None, description="Capture engine type (mss, dxcam, wgc)") - engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration") - description: Optional[str] = Field(None, description="Template description", max_length=500) - - -class TemplateResponse(BaseModel): - """Template information response.""" - - id: str = Field(description="Template ID") - name: str = Field(description="Template name") - engine_type: str = Field(description="Engine type identifier") - engine_config: Dict = Field(description="Engine-specific configuration") - created_at: datetime = Field(description="Creation timestamp") - updated_at: datetime = Field(description="Last update timestamp") - description: Optional[str] = Field(None, description="Template description") - - -class TemplateListResponse(BaseModel): - """List of templates response.""" - - templates: List[TemplateResponse] = Field(description="List of templates") - count: int = Field(description="Number of templates") - - -class EngineInfo(BaseModel): - """Capture engine information.""" - - type: str = Field(description="Engine type identifier (e.g., 'mss', 'dxcam')") - name: str = Field(description="Human-readable engine name") - default_config: Dict = Field(description="Default configuration for this engine") - available: bool = Field(description="Whether engine is available on this system") - - -class EngineListResponse(BaseModel): - """List of available engines response.""" - - engines: List[EngineInfo] = Field(description="Available capture engines") - count: int = Field(description="Number of engines") - - -class TemplateAssignment(BaseModel): - """Request to assign template to device.""" - - template_id: str = Field(description="Template ID to assign") - - -class TemplateTestRequest(BaseModel): - """Request to test a capture template.""" - - engine_type: str = Field(description="Capture engine type to test") - engine_config: Dict = Field(default={}, description="Engine configuration") - display_index: int = Field(description="Display index to capture") - border_width: int = Field(default=10, ge=1, le=100, description="Border width in pixels") - capture_duration: float = Field(default=5.0, ge=1.0, le=30.0, description="Duration to capture in seconds") - - -class CaptureImage(BaseModel): - """Captured image with metadata.""" - - image: str = Field(description="Base64-encoded thumbnail image data") - full_image: Optional[str] = Field(None, description="Base64-encoded full-resolution image data") - width: int = Field(description="Original image width in pixels") - height: int = Field(description="Original image height in pixels") - thumbnail_width: Optional[int] = Field(None, description="Thumbnail width (if resized)") - thumbnail_height: Optional[int] = Field(None, description="Thumbnail height (if resized)") - - -class BorderExtraction(BaseModel): - """Extracted border images.""" - - top: str = Field(description="Base64-encoded top border image") - right: str = Field(description="Base64-encoded right border image") - bottom: str = Field(description="Base64-encoded bottom border image") - left: str = Field(description="Base64-encoded left border image") - - -class PerformanceMetrics(BaseModel): - """Performance metrics for template test.""" - - capture_duration_s: float = Field(description="Total capture duration in seconds") - frame_count: int = Field(description="Number of frames captured") - actual_fps: float = Field(description="Actual FPS (frame_count / duration)") - avg_capture_time_ms: float = Field(description="Average time per frame capture in milliseconds") - - -class TemplateTestResponse(BaseModel): - """Response from template test.""" - - full_capture: CaptureImage = Field(description="Full screen capture with thumbnail") - border_extraction: Optional[BorderExtraction] = Field(None, description="Extracted border images (deprecated)") - performance: PerformanceMetrics = Field(description="Performance metrics") - - -# Filter Schemas - -class FilterInstanceSchema(BaseModel): - """A single filter instance with its configuration.""" - - filter_id: str = Field(description="Filter type identifier") - options: Dict[str, Any] = Field(default_factory=dict, description="Filter-specific options") - - -class FilterOptionDefSchema(BaseModel): - """Describes a configurable option for a filter type.""" - - key: str = Field(description="Option key") - label: str = Field(description="Display label") - type: str = Field(description="Option type (float or int)") - default: Any = Field(description="Default value") - min_value: Any = Field(description="Minimum value") - max_value: Any = Field(description="Maximum value") - step: Any = Field(description="Step increment") - - -class FilterTypeResponse(BaseModel): - """Available filter type with its options schema.""" - - filter_id: str = Field(description="Filter type identifier") - filter_name: str = Field(description="Display name") - options_schema: List[FilterOptionDefSchema] = Field(description="Configurable options") - - -class FilterTypeListResponse(BaseModel): - """List of available filter types.""" - - filters: List[FilterTypeResponse] = Field(description="Available filter types") - count: int = Field(description="Number of filter types") - - -# Postprocessing Template Schemas - -class PostprocessingTemplateCreate(BaseModel): - """Request to create a postprocessing template.""" - - name: str = Field(description="Template name", min_length=1, max_length=100) - filters: List[FilterInstanceSchema] = Field(default_factory=list, description="Ordered list of filter instances") - description: Optional[str] = Field(None, description="Template description", max_length=500) - - -class PostprocessingTemplateUpdate(BaseModel): - """Request to update a postprocessing template.""" - - name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100) - filters: Optional[List[FilterInstanceSchema]] = Field(None, description="Ordered list of filter instances") - description: Optional[str] = Field(None, description="Template description", max_length=500) - - -class PostprocessingTemplateResponse(BaseModel): - """Postprocessing template information response.""" - - id: str = Field(description="Template ID") - name: str = Field(description="Template name") - filters: List[FilterInstanceSchema] = Field(description="Ordered list of filter instances") - created_at: datetime = Field(description="Creation timestamp") - updated_at: datetime = Field(description="Last update timestamp") - description: Optional[str] = Field(None, description="Template description") - - -class PostprocessingTemplateListResponse(BaseModel): - """List of postprocessing templates response.""" - - templates: List[PostprocessingTemplateResponse] = Field(description="List of postprocessing templates") - count: int = Field(description="Number of templates") - - -# Picture Stream Schemas - -class PictureStreamCreate(BaseModel): - """Request to create a picture stream.""" - - name: str = Field(description="Stream name", min_length=1, max_length=100) - stream_type: Literal["raw", "processed", "static_image"] = Field(description="Stream type") - display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0) - capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)") - target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=10, le=90) - source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)") - postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)") - image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)") - description: Optional[str] = Field(None, description="Stream description", max_length=500) - - -class PictureStreamUpdate(BaseModel): - """Request to update a picture stream.""" - - name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100) - display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0) - capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)") - target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=10, le=90) - source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)") - postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)") - image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)") - description: Optional[str] = Field(None, description="Stream description", max_length=500) - - -class PictureStreamResponse(BaseModel): - """Picture stream information response.""" - - id: str = Field(description="Stream ID") - name: str = Field(description="Stream name") - stream_type: str = Field(description="Stream type (raw, processed, or static_image)") - display_index: Optional[int] = Field(None, description="Display index") - capture_template_id: Optional[str] = Field(None, description="Capture template ID") - target_fps: Optional[int] = Field(None, description="Target FPS") - source_stream_id: Optional[str] = Field(None, description="Source stream ID") - postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID") - image_source: Optional[str] = Field(None, description="Image URL or file path") - created_at: datetime = Field(description="Creation timestamp") - updated_at: datetime = Field(description="Last update timestamp") - description: Optional[str] = Field(None, description="Stream description") - - -class PictureStreamListResponse(BaseModel): - """List of picture streams response.""" - - streams: List[PictureStreamResponse] = Field(description="List of picture streams") - count: int = Field(description="Number of streams") - - -class PictureStreamTestRequest(BaseModel): - """Request to test a picture stream.""" - - capture_duration: float = Field(default=5.0, ge=1.0, le=30.0, description="Duration to capture in seconds") - border_width: int = Field(default=10, ge=1, le=100, description="Border width in pixels for preview") - - -class PPTemplateTestRequest(BaseModel): - """Request to test a postprocessing template against a source stream.""" - - source_stream_id: str = Field(description="ID of the source picture stream to capture from") - capture_duration: float = Field(default=5.0, ge=1.0, le=30.0, description="Duration to capture in seconds") - - -class ImageValidateRequest(BaseModel): - """Request to validate an image source (URL or file path).""" - - image_source: str = Field(description="Image URL or local file path") - - -class ImageValidateResponse(BaseModel): - """Response from image validation.""" - - valid: bool = Field(description="Whether the image source is accessible and valid") - width: Optional[int] = Field(None, description="Image width in pixels") - height: Optional[int] = Field(None, description="Image height in pixels") - preview: Optional[str] = Field(None, description="Base64-encoded JPEG thumbnail") - error: Optional[str] = Field(None, description="Error message if invalid") diff --git a/server/src/wled_controller/api/schemas/__init__.py b/server/src/wled_controller/api/schemas/__init__.py new file mode 100644 index 0000000..b5089dd --- /dev/null +++ b/server/src/wled_controller/api/schemas/__init__.py @@ -0,0 +1,107 @@ +"""Pydantic schemas for API request and response models.""" + +from .common import ( + CaptureImage, + BorderExtraction, + ErrorResponse, + PerformanceMetrics, + TemplateTestResponse, +) +from .system import ( + DisplayInfo, + DisplayListResponse, + HealthResponse, + VersionResponse, +) +from .devices import ( + Calibration, + CalibrationTestModeRequest, + CalibrationTestModeResponse, + ColorCorrection, + DeviceCreate, + DeviceListResponse, + DeviceResponse, + DeviceUpdate, + MetricsResponse, + ProcessingSettings, + ProcessingState, +) +from .templates import ( + EngineInfo, + EngineListResponse, + TemplateAssignment, + TemplateCreate, + TemplateListResponse, + TemplateResponse, + TemplateTestRequest, + TemplateUpdate, +) +from .filters import ( + FilterInstanceSchema, + FilterOptionDefSchema, + FilterTypeListResponse, + FilterTypeResponse, +) +from .postprocessing import ( + PostprocessingTemplateCreate, + PostprocessingTemplateListResponse, + PostprocessingTemplateResponse, + PostprocessingTemplateUpdate, + PPTemplateTestRequest, +) +from .picture_sources import ( + ImageValidateRequest, + ImageValidateResponse, + PictureSourceCreate, + PictureSourceListResponse, + PictureSourceResponse, + PictureSourceTestRequest, + PictureSourceUpdate, +) + +__all__ = [ + "CaptureImage", + "BorderExtraction", + "ErrorResponse", + "PerformanceMetrics", + "TemplateTestResponse", + "DisplayInfo", + "DisplayListResponse", + "HealthResponse", + "VersionResponse", + "Calibration", + "CalibrationTestModeRequest", + "CalibrationTestModeResponse", + "ColorCorrection", + "DeviceCreate", + "DeviceListResponse", + "DeviceResponse", + "DeviceUpdate", + "MetricsResponse", + "ProcessingSettings", + "ProcessingState", + "EngineInfo", + "EngineListResponse", + "TemplateAssignment", + "TemplateCreate", + "TemplateListResponse", + "TemplateResponse", + "TemplateTestRequest", + "TemplateUpdate", + "FilterInstanceSchema", + "FilterOptionDefSchema", + "FilterTypeListResponse", + "FilterTypeResponse", + "PostprocessingTemplateCreate", + "PostprocessingTemplateListResponse", + "PostprocessingTemplateResponse", + "PostprocessingTemplateUpdate", + "PPTemplateTestRequest", + "ImageValidateRequest", + "ImageValidateResponse", + "PictureSourceCreate", + "PictureSourceListResponse", + "PictureSourceResponse", + "PictureSourceTestRequest", + "PictureSourceUpdate", +] diff --git a/server/src/wled_controller/api/schemas/common.py b/server/src/wled_controller/api/schemas/common.py new file mode 100644 index 0000000..265b95d --- /dev/null +++ b/server/src/wled_controller/api/schemas/common.py @@ -0,0 +1,52 @@ +"""Shared schemas used across multiple route modules.""" + +from datetime import datetime +from typing import Dict, List, Optional + +from pydantic import BaseModel, Field + + +class ErrorResponse(BaseModel): + """Error response.""" + + error: str = Field(description="Error type") + message: str = Field(description="Error message") + detail: Optional[Dict] = Field(None, description="Additional error details") + timestamp: datetime = Field(default_factory=datetime.utcnow, description="Error timestamp") + + +class CaptureImage(BaseModel): + """Captured image with metadata.""" + + image: str = Field(description="Base64-encoded thumbnail image data") + full_image: Optional[str] = Field(None, description="Base64-encoded full-resolution image data") + width: int = Field(description="Original image width in pixels") + height: int = Field(description="Original image height in pixels") + thumbnail_width: Optional[int] = Field(None, description="Thumbnail width (if resized)") + thumbnail_height: Optional[int] = Field(None, description="Thumbnail height (if resized)") + + +class BorderExtraction(BaseModel): + """Extracted border images.""" + + top: str = Field(description="Base64-encoded top border image") + right: str = Field(description="Base64-encoded right border image") + bottom: str = Field(description="Base64-encoded bottom border image") + left: str = Field(description="Base64-encoded left border image") + + +class PerformanceMetrics(BaseModel): + """Performance metrics for template test.""" + + capture_duration_s: float = Field(description="Total capture duration in seconds") + frame_count: int = Field(description="Number of frames captured") + actual_fps: float = Field(description="Actual FPS (frame_count / duration)") + avg_capture_time_ms: float = Field(description="Average time per frame capture in milliseconds") + + +class TemplateTestResponse(BaseModel): + """Response from template test.""" + + full_capture: CaptureImage = Field(description="Full screen capture with thumbnail") + border_extraction: Optional[BorderExtraction] = Field(None, description="Extracted border images (deprecated)") + performance: PerformanceMetrics = Field(description="Performance metrics") diff --git a/server/src/wled_controller/api/schemas/devices.py b/server/src/wled_controller/api/schemas/devices.py new file mode 100644 index 0000000..01a15a4 --- /dev/null +++ b/server/src/wled_controller/api/schemas/devices.py @@ -0,0 +1,161 @@ +"""Device-related schemas (CRUD, settings, calibration, processing state, metrics).""" + +from datetime import datetime +from typing import Dict, List, Literal, Optional + +from pydantic import BaseModel, Field + +from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL + + +class DeviceCreate(BaseModel): + """Request to create/attach a WLED device.""" + + name: str = Field(description="Device name", min_length=1, max_length=100) + url: str = Field(description="WLED device URL (e.g., http://192.168.1.100)") + + +class DeviceUpdate(BaseModel): + """Request to update device information.""" + + name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100) + url: Optional[str] = Field(None, description="WLED device URL") + enabled: Optional[bool] = Field(None, description="Whether device is enabled") + picture_source_id: Optional[str] = Field(None, description="Picture source ID") + + +class ColorCorrection(BaseModel): + """Color correction settings.""" + + gamma: float = Field(default=2.2, description="Gamma correction", ge=0.1, le=5.0) + saturation: float = Field(default=1.0, description="Saturation multiplier", ge=0.0, le=2.0) + brightness: float = Field(default=1.0, description="Brightness multiplier", ge=0.0, le=1.0) + + +class ProcessingSettings(BaseModel): + """Processing settings for a device.""" + + display_index: int = Field(default=0, description="Display to capture", ge=0) + fps: int = Field(default=30, description="Target frames per second", ge=10, le=90) + border_width: int = Field(default=10, description="Border width in pixels", ge=1, le=100) + interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)") + brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0) + smoothing: float = Field(default=0.3, description="Temporal smoothing factor (0.0=none, 1.0=full)", ge=0.0, le=1.0) + state_check_interval: int = Field( + default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600, + description="Seconds between WLED health checks" + ) + color_correction: Optional[ColorCorrection] = Field( + default_factory=ColorCorrection, + description="Color correction settings" + ) + + +class Calibration(BaseModel): + """Calibration configuration for pixel-to-LED mapping.""" + + layout: Literal["clockwise", "counterclockwise"] = Field( + default="clockwise", + description="LED strip layout direction" + ) + start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"] = Field( + default="bottom_left", + description="Starting corner of the LED strip" + ) + offset: int = Field( + default=0, + ge=0, + description="Number of LEDs from physical LED 0 to start corner (along strip direction)" + ) + leds_top: int = Field(default=0, ge=0, description="Number of LEDs on the top edge") + leds_right: int = Field(default=0, ge=0, description="Number of LEDs on the right edge") + leds_bottom: int = Field(default=0, ge=0, description="Number of LEDs on the bottom edge") + leds_left: int = Field(default=0, ge=0, description="Number of LEDs on the left edge") + # Per-edge span: fraction of screen side covered by LEDs (0.0-1.0) + span_top_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start of top edge coverage") + span_top_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End of top edge coverage") + span_right_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start of right edge coverage") + span_right_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End of right edge coverage") + span_bottom_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start of bottom edge coverage") + span_bottom_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End of bottom edge coverage") + span_left_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start of left edge coverage") + span_left_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End of left edge coverage") + + +class CalibrationTestModeRequest(BaseModel): + """Request to set calibration test mode with multiple edges.""" + + edges: Dict[str, List[int]] = Field( + default_factory=dict, + description="Map of active edge names to RGB colors. " + "E.g. {'top': [255, 0, 0], 'left': [255, 255, 0]}. " + "Empty dict = exit test mode." + ) + + +class CalibrationTestModeResponse(BaseModel): + """Response for calibration test mode.""" + + test_mode: bool = Field(description="Whether test mode is active") + active_edges: List[str] = Field(default_factory=list, description="Currently lit edges") + device_id: str = Field(description="Device ID") + + +class DeviceResponse(BaseModel): + """Device information response.""" + + id: str = Field(description="Device ID") + name: str = Field(description="Device name") + url: str = Field(description="WLED device URL") + led_count: int = Field(description="Total number of LEDs") + enabled: bool = Field(description="Whether device is enabled") + status: Literal["connected", "disconnected", "error"] = Field( + description="Connection status" + ) + settings: ProcessingSettings = Field(description="Processing settings") + calibration: Optional[Calibration] = Field(None, description="Calibration configuration") + picture_source_id: str = Field(default="", description="ID of assigned picture source") + created_at: datetime = Field(description="Creation timestamp") + updated_at: datetime = Field(description="Last update timestamp") + + +class DeviceListResponse(BaseModel): + """List of devices response.""" + + devices: List[DeviceResponse] = Field(description="List of devices") + count: int = Field(description="Number of devices") + + +class ProcessingState(BaseModel): + """Processing state for a device.""" + + device_id: str = Field(description="Device ID") + processing: bool = Field(description="Whether processing is active") + fps_actual: Optional[float] = Field(None, description="Actual FPS achieved") + fps_target: int = Field(description="Target FPS") + display_index: int = Field(description="Current display index") + last_update: Optional[datetime] = Field(None, description="Last successful update") + errors: List[str] = Field(default_factory=list, description="Recent errors") + wled_online: bool = Field(default=False, description="Whether WLED device is reachable") + wled_latency_ms: Optional[float] = Field(None, description="WLED health check latency in ms") + wled_name: Optional[str] = Field(None, description="WLED device name") + wled_version: Optional[str] = Field(None, description="WLED firmware version") + wled_led_count: Optional[int] = Field(None, description="LED count reported by WLED device") + wled_rgbw: Optional[bool] = Field(None, description="Whether WLED device uses RGBW LEDs") + wled_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)") + wled_last_checked: Optional[datetime] = Field(None, description="Last health check time") + wled_error: Optional[str] = Field(None, description="Last health check error") + + +class MetricsResponse(BaseModel): + """Device metrics response.""" + + device_id: str = Field(description="Device ID") + processing: bool = Field(description="Whether processing is active") + fps_actual: Optional[float] = Field(None, description="Actual FPS") + fps_target: int = Field(description="Target FPS") + uptime_seconds: float = Field(description="Processing uptime in seconds") + frames_processed: int = Field(description="Total frames processed") + errors_count: int = Field(description="Total error count") + last_error: Optional[str] = Field(None, description="Last error message") + last_update: Optional[datetime] = Field(None, description="Last update timestamp") diff --git a/server/src/wled_controller/api/schemas/filters.py b/server/src/wled_controller/api/schemas/filters.py new file mode 100644 index 0000000..507b0d7 --- /dev/null +++ b/server/src/wled_controller/api/schemas/filters.py @@ -0,0 +1,39 @@ +"""Filter-related schemas.""" + +from typing import Any, Dict, List + +from pydantic import BaseModel, Field + + +class FilterInstanceSchema(BaseModel): + """A single filter instance with its configuration.""" + + filter_id: str = Field(description="Filter type identifier") + options: Dict[str, Any] = Field(default_factory=dict, description="Filter-specific options") + + +class FilterOptionDefSchema(BaseModel): + """Describes a configurable option for a filter type.""" + + key: str = Field(description="Option key") + label: str = Field(description="Display label") + type: str = Field(description="Option type (float or int)") + default: Any = Field(description="Default value") + min_value: Any = Field(description="Minimum value") + max_value: Any = Field(description="Maximum value") + step: Any = Field(description="Step increment") + + +class FilterTypeResponse(BaseModel): + """Available filter type with its options schema.""" + + filter_id: str = Field(description="Filter type identifier") + filter_name: str = Field(description="Display name") + options_schema: List[FilterOptionDefSchema] = Field(description="Configurable options") + + +class FilterTypeListResponse(BaseModel): + """List of available filter types.""" + + filters: List[FilterTypeResponse] = Field(description="Available filter types") + count: int = Field(description="Number of filter types") diff --git a/server/src/wled_controller/api/schemas/picture_sources.py b/server/src/wled_controller/api/schemas/picture_sources.py new file mode 100644 index 0000000..fa0f099 --- /dev/null +++ b/server/src/wled_controller/api/schemas/picture_sources.py @@ -0,0 +1,80 @@ +"""Picture source schemas.""" + +from datetime import datetime +from typing import List, Literal, Optional + +from pydantic import BaseModel, Field + + +class PictureSourceCreate(BaseModel): + """Request to create a picture source.""" + + name: str = Field(description="Stream name", min_length=1, max_length=100) + stream_type: Literal["raw", "processed", "static_image"] = Field(description="Stream type") + display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0) + capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)") + target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=10, le=90) + source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)") + postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)") + image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)") + description: Optional[str] = Field(None, description="Stream description", max_length=500) + + +class PictureSourceUpdate(BaseModel): + """Request to update a picture source.""" + + name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100) + display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0) + capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)") + target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=10, le=90) + source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)") + postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)") + image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)") + description: Optional[str] = Field(None, description="Stream description", max_length=500) + + +class PictureSourceResponse(BaseModel): + """Picture source information response.""" + + id: str = Field(description="Stream ID") + name: str = Field(description="Stream name") + stream_type: str = Field(description="Stream type (raw, processed, or static_image)") + display_index: Optional[int] = Field(None, description="Display index") + capture_template_id: Optional[str] = Field(None, description="Capture template ID") + target_fps: Optional[int] = Field(None, description="Target FPS") + source_stream_id: Optional[str] = Field(None, description="Source stream ID") + postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID") + image_source: Optional[str] = Field(None, description="Image URL or file path") + created_at: datetime = Field(description="Creation timestamp") + updated_at: datetime = Field(description="Last update timestamp") + description: Optional[str] = Field(None, description="Stream description") + + +class PictureSourceListResponse(BaseModel): + """List of picture sources response.""" + + streams: List[PictureSourceResponse] = Field(description="List of picture sources") + count: int = Field(description="Number of streams") + + +class PictureSourceTestRequest(BaseModel): + """Request to test a picture source.""" + + capture_duration: float = Field(default=5.0, ge=1.0, le=30.0, description="Duration to capture in seconds") + border_width: int = Field(default=10, ge=1, le=100, description="Border width in pixels for preview") + + +class ImageValidateRequest(BaseModel): + """Request to validate an image source (URL or file path).""" + + image_source: str = Field(description="Image URL or local file path") + + +class ImageValidateResponse(BaseModel): + """Response from image validation.""" + + valid: bool = Field(description="Whether the image source is accessible and valid") + width: Optional[int] = Field(None, description="Image width in pixels") + height: Optional[int] = Field(None, description="Image height in pixels") + preview: Optional[str] = Field(None, description="Base64-encoded JPEG thumbnail") + error: Optional[str] = Field(None, description="Error message if invalid") diff --git a/server/src/wled_controller/api/schemas/postprocessing.py b/server/src/wled_controller/api/schemas/postprocessing.py new file mode 100644 index 0000000..18a3a21 --- /dev/null +++ b/server/src/wled_controller/api/schemas/postprocessing.py @@ -0,0 +1,49 @@ +"""Postprocessing template schemas.""" + +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field + +from .filters import FilterInstanceSchema + + +class PostprocessingTemplateCreate(BaseModel): + """Request to create a postprocessing template.""" + + name: str = Field(description="Template name", min_length=1, max_length=100) + filters: List[FilterInstanceSchema] = Field(default_factory=list, description="Ordered list of filter instances") + description: Optional[str] = Field(None, description="Template description", max_length=500) + + +class PostprocessingTemplateUpdate(BaseModel): + """Request to update a postprocessing template.""" + + name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100) + filters: Optional[List[FilterInstanceSchema]] = Field(None, description="Ordered list of filter instances") + description: Optional[str] = Field(None, description="Template description", max_length=500) + + +class PostprocessingTemplateResponse(BaseModel): + """Postprocessing template information response.""" + + id: str = Field(description="Template ID") + name: str = Field(description="Template name") + filters: List[FilterInstanceSchema] = Field(description="Ordered list of filter instances") + created_at: datetime = Field(description="Creation timestamp") + updated_at: datetime = Field(description="Last update timestamp") + description: Optional[str] = Field(None, description="Template description") + + +class PostprocessingTemplateListResponse(BaseModel): + """List of postprocessing templates response.""" + + templates: List[PostprocessingTemplateResponse] = Field(description="List of postprocessing templates") + count: int = Field(description="Number of templates") + + +class PPTemplateTestRequest(BaseModel): + """Request to test a postprocessing template against a source stream.""" + + source_stream_id: str = Field(description="ID of the source picture source to capture from") + capture_duration: float = Field(default=5.0, ge=1.0, le=30.0, description="Duration to capture in seconds") diff --git a/server/src/wled_controller/api/schemas/system.py b/server/src/wled_controller/api/schemas/system.py new file mode 100644 index 0000000..a6a10dc --- /dev/null +++ b/server/src/wled_controller/api/schemas/system.py @@ -0,0 +1,42 @@ +"""System-related schemas (health, version, displays).""" + +from datetime import datetime +from typing import List, Literal + +from pydantic import BaseModel, Field + + +class HealthResponse(BaseModel): + """Health check response.""" + + status: Literal["healthy", "unhealthy"] = Field(description="Service health status") + timestamp: datetime = Field(description="Current server time") + version: str = Field(description="Application version") + + +class VersionResponse(BaseModel): + """Version information response.""" + + version: str = Field(description="Application version") + python_version: str = Field(description="Python version") + api_version: str = Field(description="API version") + + +class DisplayInfo(BaseModel): + """Display/monitor information.""" + + index: int = Field(description="Display index") + name: str = Field(description="Display name") + width: int = Field(description="Display width in pixels") + height: int = Field(description="Display height in pixels") + x: int = Field(description="Display X position") + y: int = Field(description="Display Y position") + is_primary: bool = Field(default=False, description="Whether this is the primary display") + refresh_rate: int = Field(description="Display refresh rate in Hz") + + +class DisplayListResponse(BaseModel): + """List of available displays.""" + + displays: List[DisplayInfo] = Field(description="Available displays") + count: int = Field(description="Number of displays") diff --git a/server/src/wled_controller/api/schemas/templates.py b/server/src/wled_controller/api/schemas/templates.py new file mode 100644 index 0000000..2777598 --- /dev/null +++ b/server/src/wled_controller/api/schemas/templates.py @@ -0,0 +1,75 @@ +"""Capture template and engine schemas.""" + +from datetime import datetime +from typing import Dict, List, Optional + +from pydantic import BaseModel, Field + + +class TemplateCreate(BaseModel): + """Request to create a capture template.""" + + name: str = Field(description="Template name", min_length=1, max_length=100) + engine_type: str = Field(description="Engine type (e.g., 'mss', 'dxcam', 'wgc')", min_length=1) + engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration") + description: Optional[str] = Field(None, description="Template description", max_length=500) + + +class TemplateUpdate(BaseModel): + """Request to update a template.""" + + name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100) + engine_type: Optional[str] = Field(None, description="Capture engine type (mss, dxcam, wgc)") + engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration") + description: Optional[str] = Field(None, description="Template description", max_length=500) + + +class TemplateResponse(BaseModel): + """Template information response.""" + + id: str = Field(description="Template ID") + name: str = Field(description="Template name") + engine_type: str = Field(description="Engine type identifier") + engine_config: Dict = Field(description="Engine-specific configuration") + created_at: datetime = Field(description="Creation timestamp") + updated_at: datetime = Field(description="Last update timestamp") + description: Optional[str] = Field(None, description="Template description") + + +class TemplateListResponse(BaseModel): + """List of templates response.""" + + templates: List[TemplateResponse] = Field(description="List of templates") + count: int = Field(description="Number of templates") + + +class EngineInfo(BaseModel): + """Capture engine information.""" + + type: str = Field(description="Engine type identifier (e.g., 'mss', 'dxcam')") + name: str = Field(description="Human-readable engine name") + default_config: Dict = Field(description="Default configuration for this engine") + available: bool = Field(description="Whether engine is available on this system") + + +class EngineListResponse(BaseModel): + """List of available engines response.""" + + engines: List[EngineInfo] = Field(description="Available capture engines") + count: int = Field(description="Number of engines") + + +class TemplateAssignment(BaseModel): + """Request to assign template to device.""" + + template_id: str = Field(description="Template ID to assign") + + +class TemplateTestRequest(BaseModel): + """Request to test a capture template.""" + + engine_type: str = Field(description="Capture engine type to test") + engine_config: Dict = Field(default={}, description="Engine configuration") + display_index: int = Field(description="Display index to capture") + border_width: int = Field(default=10, ge=1, le=100, description="Border width in pixels") + capture_duration: float = Field(default=5.0, ge=1.0, le=30.0, description="Duration to capture in seconds") diff --git a/server/src/wled_controller/config.py b/server/src/wled_controller/config.py index b938e6d..ffacf28 100644 --- a/server/src/wled_controller/config.py +++ b/server/src/wled_controller/config.py @@ -56,7 +56,7 @@ class StorageConfig(BaseSettings): devices_file: str = "data/devices.json" templates_file: str = "data/capture_templates.json" postprocessing_templates_file: str = "data/postprocessing_templates.json" - picture_streams_file: str = "data/picture_streams.json" + picture_sources_file: str = "data/picture_sources.json" class LoggingConfig(BaseSettings): diff --git a/server/src/wled_controller/core/calibration.py b/server/src/wled_controller/core/calibration.py index 812b467..5019f77 100644 --- a/server/src/wled_controller/core/calibration.py +++ b/server/src/wled_controller/core/calibration.py @@ -349,9 +349,6 @@ def create_default_calibration(led_count: int) -> CalibrationConfig: def calibration_from_dict(data: dict) -> CalibrationConfig: """Create calibration configuration from dictionary. - Supports both new format (leds_top/right/bottom/left) and legacy format - (segments list) for backward compatibility. - Args: data: Dictionary with calibration data diff --git a/server/src/wled_controller/core/capture_engines/__init__.py b/server/src/wled_controller/core/capture_engines/__init__.py index fb84edf..29e0a99 100644 --- a/server/src/wled_controller/core/capture_engines/__init__.py +++ b/server/src/wled_controller/core/capture_engines/__init__.py @@ -2,14 +2,15 @@ from wled_controller.core.capture_engines.base import ( CaptureEngine, + CaptureStream, DisplayInfo, ScreenCapture, ) from wled_controller.core.capture_engines.factory import EngineRegistry -from wled_controller.core.capture_engines.mss_engine import MSSEngine -from wled_controller.core.capture_engines.dxcam_engine import DXcamEngine -from wled_controller.core.capture_engines.bettercam_engine import BetterCamEngine -from wled_controller.core.capture_engines.wgc_engine import WGCEngine +from wled_controller.core.capture_engines.mss_engine import MSSEngine, MSSCaptureStream +from wled_controller.core.capture_engines.dxcam_engine import DXcamEngine, DXcamCaptureStream +from wled_controller.core.capture_engines.bettercam_engine import BetterCamEngine, BetterCamCaptureStream +from wled_controller.core.capture_engines.wgc_engine import WGCEngine, WGCCaptureStream # Auto-register available engines EngineRegistry.register(MSSEngine) @@ -19,11 +20,16 @@ EngineRegistry.register(WGCEngine) __all__ = [ "CaptureEngine", + "CaptureStream", "DisplayInfo", "ScreenCapture", "EngineRegistry", "MSSEngine", + "MSSCaptureStream", "DXcamEngine", + "DXcamCaptureStream", "BetterCamEngine", + "BetterCamCaptureStream", "WGCEngine", + "WGCCaptureStream", ] diff --git a/server/src/wled_controller/core/capture_engines/base.py b/server/src/wled_controller/core/capture_engines/base.py index 5cdae99..7a2934d 100644 --- a/server/src/wled_controller/core/capture_engines/base.py +++ b/server/src/wled_controller/core/capture_engines/base.py @@ -31,31 +31,33 @@ class ScreenCapture: display_index: int -class CaptureEngine(ABC): - """Abstract base class for screen capture engines. +class CaptureStream(ABC): + """Abstract base class for a display capture session. - All screen capture engines must implement this interface to be - compatible with the WLED Grab system. + A CaptureStream is a stateful session bound to a specific display. + It holds all display-specific resources and provides frame capture. + + Created by CaptureEngine.create_stream(). + + Lifecycle: + stream = engine.create_stream(display_index, config) + stream.initialize() + frame = stream.capture_frame() + stream.cleanup() + + Or via context manager: + with engine.create_stream(display_index, config) as stream: + frame = stream.capture_frame() """ - ENGINE_TYPE: str = "base" # Override in subclasses - ENGINE_PRIORITY: int = 0 # Higher = preferred. Override in subclasses. - - def __init__(self, config: Dict[str, Any]): - """Initialize engine with configuration. - - Args: - config: Engine-specific configuration dict - """ + def __init__(self, display_index: int, config: Dict[str, Any]): + self.display_index = display_index self.config = config self._initialized = False @abstractmethod def initialize(self) -> None: - """Initialize the capture engine. - - This method should prepare any resources needed for screen capture - (e.g., creating capture objects, allocating buffers). + """Initialize capture resources for this display. Raises: RuntimeError: If initialization fails @@ -64,17 +66,64 @@ class CaptureEngine(ABC): @abstractmethod def cleanup(self) -> None: - """Cleanup engine resources. - - This method should release any resources allocated during - initialization or capture operations. - """ + """Release all capture resources for this display.""" pass @abstractmethod - def get_available_displays(self) -> List[DisplayInfo]: + def capture_frame(self) -> Optional[ScreenCapture]: + """Capture one frame from the bound display. + + Returns: + ScreenCapture with image data (RGB), or None if screen unchanged. + + Raises: + RuntimeError: If capture fails + """ + pass + + def __enter__(self): + """Context manager entry - initialize stream.""" + self.initialize() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit - cleanup stream.""" + self.cleanup() + + +class CaptureEngine(ABC): + """Abstract base class for screen capture engines. + + A CaptureEngine is a stateless factory that knows about a capture + technology. It can enumerate displays, check availability, and create + CaptureStream instances for specific displays. + + All methods are classmethods — no instance creation needed. + """ + + ENGINE_TYPE: str = "base" + ENGINE_PRIORITY: int = 0 + + @classmethod + @abstractmethod + def is_available(cls) -> bool: + """Check if this engine is available on current system.""" + pass + + @classmethod + @abstractmethod + def get_default_config(cls) -> Dict[str, Any]: + """Get default configuration for this engine.""" + pass + + @classmethod + @abstractmethod + def get_available_displays(cls) -> List[DisplayInfo]: """Get list of available displays. + This method works without prior initialization — implementations + create temporary resources as needed. + Returns: List of DisplayInfo objects describing available displays @@ -83,61 +132,17 @@ class CaptureEngine(ABC): """ pass + @classmethod @abstractmethod - def capture_display(self, display_index: int) -> Optional[ScreenCapture]: - """Capture the specified display. + def create_stream(cls, display_index: int, config: Dict[str, Any]) -> CaptureStream: + """Create a capture stream for the specified display. Args: display_index: Index of display to capture (0-based) + config: Engine-specific configuration dict Returns: - ScreenCapture object with image data as numpy array (RGB format), - or None if no new frame is available (screen unchanged). - - Raises: - ValueError: If display_index is invalid - RuntimeError: If capture fails + Uninitialized CaptureStream. Caller must call initialize() + or use as context manager. """ pass - - @classmethod - @abstractmethod - def is_available(cls) -> bool: - """Check if this engine is available on current system. - - Returns: - True if engine can be used on this platform - - Examples: - >>> MSSEngine.is_available() - True # MSS is available on all platforms - >>> DXcamEngine.is_available() - True # On Windows 8.1+ - False # On Linux/macOS - """ - pass - - @classmethod - @abstractmethod - def get_default_config(cls) -> Dict[str, Any]: - """Get default configuration for this engine. - - Returns: - Default config dict with engine-specific options - - Examples: - >>> MSSEngine.get_default_config() - {} - >>> DXcamEngine.get_default_config() - {'device_idx': 0, 'output_color': 'RGB', 'max_buffer_len': 64} - """ - pass - - def __enter__(self): - """Context manager entry - initialize engine.""" - self.initialize() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - """Context manager exit - cleanup engine.""" - self.cleanup() diff --git a/server/src/wled_controller/core/capture_engines/bettercam_engine.py b/server/src/wled_controller/core/capture_engines/bettercam_engine.py index 5a4f11e..3d0a684 100644 --- a/server/src/wled_controller/core/capture_engines/bettercam_engine.py +++ b/server/src/wled_controller/core/capture_engines/bettercam_engine.py @@ -7,6 +7,7 @@ import numpy as np from wled_controller.core.capture_engines.base import ( CaptureEngine, + CaptureStream, DisplayInfo, ScreenCapture, ) @@ -15,32 +16,15 @@ from wled_controller.utils import get_logger logger = get_logger(__name__) -class BetterCamEngine(CaptureEngine): - """BetterCam-based screen capture engine. +class BetterCamCaptureStream(CaptureStream): + """BetterCam capture stream for a specific display.""" - Uses the bettercam library (a high-performance fork of DXCam) which leverages - DXGI Desktop Duplication API for ultra-fast screen capture on Windows. - Offers better performance than DXCam with multi-GPU support. - - Requires: Windows 8.1+ - """ - - ENGINE_TYPE = "bettercam" - ENGINE_PRIORITY = 4 - - def __init__(self, config: Dict[str, Any]): - """Initialize BetterCam engine.""" - super().__init__(config) + def __init__(self, display_index: int, config: Dict[str, Any]): + super().__init__(display_index, config) self._camera = None self._bettercam = None - self._current_output = None def initialize(self) -> None: - """Initialize BetterCam capture. - - Raises: - RuntimeError: If bettercam not installed or initialization fails - """ try: import bettercam self._bettercam = bettercam @@ -49,49 +33,24 @@ class BetterCamEngine(CaptureEngine): "BetterCam not installed. Install with: pip install bettercam" ) - self._initialized = True - logger.info("BetterCam engine initialized") - - def _ensure_camera(self, display_index: int) -> None: - """Ensure camera is created for the requested display. - - Creates or recreates the BetterCam camera if needed. - """ - if self._camera and self._current_output == display_index: - return - - # Stop and release existing camera - if self._camera: - try: - if self._camera.is_capturing: - self._camera.stop() - except Exception: - pass - try: - self._camera.release() - except Exception: - pass - self._camera = None - - # Clear global camera cache to avoid stale DXGI state + # Clear global camera cache for fresh DXGI state try: self._bettercam.__factory.clean_up() except Exception: pass self._camera = self._bettercam.create( - output_idx=display_index, + output_idx=self.display_index, output_color="RGB", ) if not self._camera: - raise RuntimeError(f"Failed to create BetterCam camera for display {display_index}") + raise RuntimeError(f"Failed to create BetterCam camera for display {self.display_index}") - self._current_output = display_index - logger.info(f"BetterCam camera created (output={display_index})") + self._initialized = True + logger.info(f"BetterCam capture stream initialized (display={self.display_index})") def cleanup(self) -> None: - """Cleanup BetterCam resources.""" if self._camera: try: if self._camera.is_capturing: @@ -104,94 +63,27 @@ class BetterCamEngine(CaptureEngine): logger.error(f"Error releasing BetterCam camera: {e}") self._camera = None - # Clear global cache so next create() gets fresh DXGI state if self._bettercam: try: self._bettercam.__factory.clean_up() except Exception: pass - self._current_output = None self._initialized = False - logger.info("BetterCam engine cleaned up") + logger.info(f"BetterCam capture stream cleaned up (display={self.display_index})") - def get_available_displays(self) -> List[DisplayInfo]: - """Get list of available displays using BetterCam. - - Returns: - List of DisplayInfo objects - - Raises: - RuntimeError: If not initialized or detection fails - """ - if not self._initialized: - raise RuntimeError("Engine not initialized") - - try: - displays = [] - output_idx = self._current_output or 0 - - if self._camera and hasattr(self._camera, "width") and hasattr(self._camera, "height"): - display_info = DisplayInfo( - index=output_idx, - name=f"BetterCam Display {output_idx}", - width=self._camera.width, - height=self._camera.height, - x=0, - y=0, - is_primary=(output_idx == 0), - refresh_rate=60, - ) - displays.append(display_info) - else: - display_info = DisplayInfo( - index=output_idx, - name=f"BetterCam Display {output_idx}", - width=1920, - height=1080, - x=0, - y=0, - is_primary=(output_idx == 0), - refresh_rate=60, - ) - displays.append(display_info) - - logger.debug(f"BetterCam detected {len(displays)} display(s)") - return displays - - except Exception as e: - logger.error(f"Failed to detect displays with BetterCam: {e}") - raise RuntimeError(f"Failed to detect displays: {e}") - - def capture_display(self, display_index: int) -> Optional[ScreenCapture]: - """Capture display using BetterCam. - - Args: - display_index: Index of display to capture (0-based). - - Returns: - ScreenCapture object with image data, or None if screen unchanged. - - Raises: - RuntimeError: If capture fails - """ - # Auto-initialize if not already initialized + def capture_frame(self) -> Optional[ScreenCapture]: if not self._initialized: self.initialize() - # Ensure camera is ready for the requested display - self._ensure_camera(display_index) - try: - # grab() uses AcquireNextFrame with timeout=0 (non-blocking). - # Returns None if screen content hasn't changed since last grab. frame = self._camera.grab() if frame is None: return None logger.debug( - f"BetterCam captured display {display_index}: " + f"BetterCam captured display {self.display_index}: " f"{frame.shape[1]}x{frame.shape[0]}" ) @@ -199,27 +91,32 @@ class BetterCamEngine(CaptureEngine): image=frame, width=frame.shape[1], height=frame.shape[0], - display_index=display_index, + display_index=self.display_index, ) except ValueError: raise except Exception as e: - logger.error(f"Failed to capture display {display_index} with BetterCam: {e}") + logger.error(f"Failed to capture display {self.display_index} with BetterCam: {e}") raise RuntimeError(f"Screen capture failed: {e}") + +class BetterCamEngine(CaptureEngine): + """BetterCam-based screen capture engine. + + Uses the bettercam library (a high-performance fork of DXCam) which leverages + DXGI Desktop Duplication API for ultra-fast screen capture on Windows. + + Requires: Windows 8.1+ + """ + + ENGINE_TYPE = "bettercam" + ENGINE_PRIORITY = 4 + @classmethod def is_available(cls) -> bool: - """Check if BetterCam is available. - - BetterCam requires Windows 8.1+ and the bettercam package. - - Returns: - True if bettercam is available on this system - """ if sys.platform != "win32": return False - try: import bettercam return True @@ -228,9 +125,34 @@ class BetterCamEngine(CaptureEngine): @classmethod def get_default_config(cls) -> Dict[str, Any]: - """Get default BetterCam configuration. - - Returns: - Default config dict with BetterCam options - """ return {} + + @classmethod + def get_available_displays(cls) -> List[DisplayInfo]: + try: + import mss + + displays = [] + with mss.mss() as sct: + for i, monitor in enumerate(sct.monitors[1:], start=0): + displays.append(DisplayInfo( + index=i, + name=f"Display {i}", + width=monitor["width"], + height=monitor["height"], + x=monitor["left"], + y=monitor["top"], + is_primary=(i == 0), + refresh_rate=60, + )) + + logger.debug(f"BetterCam detected {len(displays)} display(s)") + return displays + + except Exception as e: + logger.error(f"Failed to detect displays with BetterCam: {e}") + raise RuntimeError(f"Failed to detect displays: {e}") + + @classmethod + def create_stream(cls, display_index: int, config: Dict[str, Any]) -> BetterCamCaptureStream: + return BetterCamCaptureStream(display_index, config) diff --git a/server/src/wled_controller/core/capture_engines/dxcam_engine.py b/server/src/wled_controller/core/capture_engines/dxcam_engine.py index 514949d..288862c 100644 --- a/server/src/wled_controller/core/capture_engines/dxcam_engine.py +++ b/server/src/wled_controller/core/capture_engines/dxcam_engine.py @@ -7,6 +7,7 @@ import numpy as np from wled_controller.core.capture_engines.base import ( CaptureEngine, + CaptureStream, DisplayInfo, ScreenCapture, ) @@ -15,32 +16,15 @@ from wled_controller.utils import get_logger logger = get_logger(__name__) -class DXcamEngine(CaptureEngine): - """DXcam-based screen capture engine. +class DXcamCaptureStream(CaptureStream): + """DXcam capture stream for a specific display.""" - Uses the dxcam library which leverages DXGI Desktop Duplication API for - ultra-fast screen capture on Windows. Offers significantly better performance - than MSS and eliminates cursor flickering. - - Requires: Windows 8.1+ - """ - - ENGINE_TYPE = "dxcam" - ENGINE_PRIORITY = 3 - - def __init__(self, config: Dict[str, Any]): - """Initialize DXcam engine.""" - super().__init__(config) + def __init__(self, display_index: int, config: Dict[str, Any]): + super().__init__(display_index, config) self._camera = None self._dxcam = None - self._current_output = None def initialize(self) -> None: - """Initialize DXcam capture. - - Raises: - RuntimeError: If DXcam not installed or initialization fails - """ try: import dxcam self._dxcam = dxcam @@ -49,51 +33,24 @@ class DXcamEngine(CaptureEngine): "DXcam not installed. Install with: pip install dxcam" ) - self._initialized = True - logger.info("DXcam engine initialized") - - def _ensure_camera(self, display_index: int) -> None: - """Ensure camera is created for the requested display. - - Creates or recreates the DXcam camera if needed. - DXcam caches cameras globally per (device, output). We clear the - cache before creating to avoid stale DXGI state from prior requests. - """ - if self._camera and self._current_output == display_index: - return - - # Stop and release existing camera - if self._camera: - try: - if self._camera.is_capturing: - self._camera.stop() - except Exception: - pass - try: - self._camera.release() - except Exception: - pass - self._camera = None - - # Clear dxcam's global camera cache to avoid stale DXGI state + # Clear global camera cache for fresh DXGI state try: self._dxcam.__factory.clean_up() except Exception: pass self._camera = self._dxcam.create( - output_idx=display_index, + output_idx=self.display_index, output_color="RGB", ) if not self._camera: - raise RuntimeError(f"Failed to create DXcam camera for display {display_index}") + raise RuntimeError(f"Failed to create DXcam camera for display {self.display_index}") - self._current_output = display_index - logger.info(f"DXcam camera created (output={display_index})") + self._initialized = True + logger.info(f"DXcam capture stream initialized (display={self.display_index})") def cleanup(self) -> None: - """Cleanup DXcam resources.""" if self._camera: try: if self._camera.is_capturing: @@ -106,104 +63,27 @@ class DXcamEngine(CaptureEngine): logger.error(f"Error releasing DXcam camera: {e}") self._camera = None - # Clear dxcam's global cache so next create() gets fresh DXGI state if self._dxcam: try: self._dxcam.__factory.clean_up() except Exception: pass - self._current_output = None self._initialized = False - logger.info("DXcam engine cleaned up") + logger.info(f"DXcam capture stream cleaned up (display={self.display_index})") - def get_available_displays(self) -> List[DisplayInfo]: - """Get list of available displays using DXcam. - - Note: DXcam provides limited display enumeration. This method - returns basic information for the configured output. - - Returns: - List of DisplayInfo objects - - Raises: - RuntimeError: If not initialized or detection fails - """ - if not self._initialized: - raise RuntimeError("Engine not initialized") - - try: - displays = [] - - # Get output information from DXcam - # Note: DXcam doesn't provide comprehensive display enumeration - # We report the single configured output - output_idx = self._current_output or 0 - - # DXcam camera has basic output info - if self._camera and hasattr(self._camera, "width") and hasattr(self._camera, "height"): - display_info = DisplayInfo( - index=output_idx, - name=f"DXcam Display {output_idx}", - width=self._camera.width, - height=self._camera.height, - x=0, # DXcam doesn't provide position info - y=0, - is_primary=(output_idx == 0), - refresh_rate=60, # DXcam doesn't report refresh rate - ) - displays.append(display_info) - else: - # Fallback if camera doesn't have dimensions - display_info = DisplayInfo( - index=output_idx, - name=f"DXcam Display {output_idx}", - width=1920, # Reasonable default - height=1080, - x=0, - y=0, - is_primary=(output_idx == 0), - refresh_rate=60, - ) - displays.append(display_info) - - logger.debug(f"DXcam detected {len(displays)} display(s)") - return displays - - except Exception as e: - logger.error(f"Failed to detect displays with DXcam: {e}") - raise RuntimeError(f"Failed to detect displays: {e}") - - def capture_display(self, display_index: int) -> Optional[ScreenCapture]: - """Capture display using DXcam. - - Args: - display_index: Index of display to capture (0-based). - - Returns: - ScreenCapture object with image data, or None if screen unchanged. - - Raises: - RuntimeError: If capture fails - """ - # Auto-initialize if not already initialized + def capture_frame(self) -> Optional[ScreenCapture]: if not self._initialized: self.initialize() - # Ensure camera is ready for the requested display - self._ensure_camera(display_index) - try: - # grab() uses AcquireNextFrame with timeout=0 (non-blocking). - # Returns None if screen content hasn't changed since last grab. frame = self._camera.grab() if frame is None: return None - # DXcam returns numpy array directly in configured color format logger.debug( - f"DXcam captured display {display_index}: " + f"DXcam captured display {self.display_index}: " f"{frame.shape[1]}x{frame.shape[0]}" ) @@ -211,29 +91,32 @@ class DXcamEngine(CaptureEngine): image=frame, width=frame.shape[1], height=frame.shape[0], - display_index=display_index, + display_index=self.display_index, ) except ValueError: raise except Exception as e: - logger.error(f"Failed to capture display {display_index} with DXcam: {e}") + logger.error(f"Failed to capture display {self.display_index} with DXcam: {e}") raise RuntimeError(f"Screen capture failed: {e}") + +class DXcamEngine(CaptureEngine): + """DXcam-based screen capture engine. + + Uses the dxcam library which leverages DXGI Desktop Duplication API for + ultra-fast screen capture on Windows. + + Requires: Windows 8.1+ + """ + + ENGINE_TYPE = "dxcam" + ENGINE_PRIORITY = 3 + @classmethod def is_available(cls) -> bool: - """Check if DXcam is available. - - DXcam requires Windows 8.1+ and the dxcam package. - - Returns: - True if dxcam is available on this system - """ - # Check platform if sys.platform != "win32": return False - - # Check if dxcam is installed try: import dxcam return True @@ -242,9 +125,34 @@ class DXcamEngine(CaptureEngine): @classmethod def get_default_config(cls) -> Dict[str, Any]: - """Get default DXcam configuration. - - Returns: - Default config dict with DXcam options - """ return {} + + @classmethod + def get_available_displays(cls) -> List[DisplayInfo]: + try: + import mss + + displays = [] + with mss.mss() as sct: + for i, monitor in enumerate(sct.monitors[1:], start=0): + displays.append(DisplayInfo( + index=i, + name=f"Display {i}", + width=monitor["width"], + height=monitor["height"], + x=monitor["left"], + y=monitor["top"], + is_primary=(i == 0), + refresh_rate=60, + )) + + logger.debug(f"DXcam detected {len(displays)} display(s)") + return displays + + except Exception as e: + logger.error(f"Failed to detect displays with DXcam: {e}") + raise RuntimeError(f"Failed to detect displays: {e}") + + @classmethod + def create_stream(cls, display_index: int, config: Dict[str, Any]) -> DXcamCaptureStream: + return DXcamCaptureStream(display_index, config) diff --git a/server/src/wled_controller/core/capture_engines/factory.py b/server/src/wled_controller/core/capture_engines/factory.py index cf7d048..b0b160a 100644 --- a/server/src/wled_controller/core/capture_engines/factory.py +++ b/server/src/wled_controller/core/capture_engines/factory.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List, Optional, Type -from wled_controller.core.capture_engines.base import CaptureEngine +from wled_controller.core.capture_engines.base import CaptureEngine, CaptureStream from wled_controller.utils import get_logger logger = get_logger(__name__) @@ -11,8 +11,8 @@ logger = get_logger(__name__) class EngineRegistry: """Registry for available capture engines. - This class maintains a registry of all capture engine implementations - and provides factory methods for creating engine instances. + Maintains a registry of all capture engine implementations + and provides factory methods for creating capture streams. """ _engines: Dict[str, Type[CaptureEngine]] = {} @@ -26,7 +26,6 @@ class EngineRegistry: Raises: ValueError: If engine_class is not a subclass of CaptureEngine - ValueError: If an engine with the same ENGINE_TYPE is already registered """ if not issubclass(engine_class, CaptureEngine): raise ValueError(f"{engine_class} must be a subclass of CaptureEngine") @@ -66,12 +65,7 @@ class EngineRegistry: """Get list of available engine types on this system. Returns: - List of engine type identifiers that are available on the current platform - - Examples: - >>> EngineRegistry.get_available_engines() - ['mss'] # On Linux - ['mss', 'dxcam', 'wgc'] # On Windows 10+ + List of engine type identifiers that are available """ available = [] for engine_type, engine_class in cls._engines.items(): @@ -115,19 +109,27 @@ class EngineRegistry: return cls._engines.copy() @classmethod - def create_engine(cls, engine_type: str, config: Dict[str, Any]) -> CaptureEngine: - """Create engine instance with configuration. + def create_stream( + cls, + engine_type: str, + display_index: int, + config: Dict[str, Any], + ) -> CaptureStream: + """Create a CaptureStream for the specified engine and display. + + Looks up the engine class, validates availability, and creates + an uninitialized CaptureStream for the specified display. Args: engine_type: Engine type identifier + display_index: Display index for the stream config: Engine-specific configuration Returns: - Initialized engine instance + Uninitialized CaptureStream instance Raises: ValueError: If engine type not found or not available - RuntimeError: If engine initialization fails """ engine_class = cls.get_engine(engine_type) @@ -137,18 +139,15 @@ class EngineRegistry: ) try: - engine = engine_class(config) - logger.debug(f"Created engine instance: {engine_type}") - return engine + stream = engine_class.create_stream(display_index, config) + logger.debug(f"Created capture stream: {engine_type} (display={display_index})") + return stream except Exception as e: - logger.error(f"Failed to create engine '{engine_type}': {e}") - raise RuntimeError(f"Failed to create engine '{engine_type}': {e}") + logger.error(f"Failed to create stream for engine '{engine_type}': {e}") + raise RuntimeError(f"Failed to create stream for engine '{engine_type}': {e}") @classmethod def clear_registry(cls): - """Clear all registered engines. - - This is primarily useful for testing. - """ + """Clear all registered engines (for testing).""" cls._engines.clear() logger.debug("Cleared engine registry") diff --git a/server/src/wled_controller/core/capture_engines/mss_engine.py b/server/src/wled_controller/core/capture_engines/mss_engine.py index 30bff53..8b94416 100644 --- a/server/src/wled_controller/core/capture_engines/mss_engine.py +++ b/server/src/wled_controller/core/capture_engines/mss_engine.py @@ -1,6 +1,6 @@ """MSS-based screen capture engine (cross-platform).""" -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional import mss import numpy as np @@ -8,6 +8,7 @@ from PIL import Image from wled_controller.core.capture_engines.base import ( CaptureEngine, + CaptureStream, DisplayInfo, ScreenCapture, ) @@ -16,126 +17,43 @@ from wled_controller.utils import get_logger, get_monitor_names, get_monitor_ref logger = get_logger(__name__) -class MSSEngine(CaptureEngine): - """MSS-based screen capture engine. +class MSSCaptureStream(CaptureStream): + """MSS capture stream for a specific display.""" - Uses the mss library for cross-platform screen capture support. - Works on Windows, macOS, and Linux. - - Note: May experience cursor flickering on some systems. - """ - - ENGINE_TYPE = "mss" - ENGINE_PRIORITY = 1 - - def __init__(self, config: Dict[str, Any]): - """Initialize MSS engine. - - Args: - config: Engine configuration (currently unused for MSS) - """ - super().__init__(config) + def __init__(self, display_index: int, config: Dict[str, Any]): + super().__init__(display_index, config) self._sct = None def initialize(self) -> None: - """Initialize MSS capture context. - - Raises: - RuntimeError: If MSS initialization fails - """ try: self._sct = mss.mss() self._initialized = True - logger.info("MSS engine initialized") + logger.info(f"MSS capture stream initialized (display={self.display_index})") except Exception as e: raise RuntimeError(f"Failed to initialize MSS: {e}") def cleanup(self) -> None: - """Cleanup MSS resources.""" if self._sct: self._sct.close() self._sct = None self._initialized = False - logger.info("MSS engine cleaned up") + logger.info(f"MSS capture stream cleaned up (display={self.display_index})") - def get_available_displays(self) -> List[DisplayInfo]: - """Get list of available displays using MSS. - - Returns: - List of DisplayInfo objects for each available monitor - - Raises: - RuntimeError: If not initialized or display detection fails - """ - if not self._initialized: - raise RuntimeError("Engine not initialized") - - try: - # Get friendly monitor names (Windows only, falls back to generic names) - monitor_names = get_monitor_names() - - # Get monitor refresh rates (Windows only, falls back to 60Hz) - refresh_rates = get_monitor_refresh_rates() - - displays = [] - - # Skip the first monitor (combined virtual screen on multi-monitor setups) - for idx, monitor in enumerate(self._sct.monitors[1:], start=0): - # Use friendly name from WMI if available, otherwise generic name - friendly_name = monitor_names.get(idx, f"Display {idx}") - - # Use detected refresh rate or default to 60Hz - refresh_rate = refresh_rates.get(idx, 60) - - display_info = DisplayInfo( - index=idx, - name=friendly_name, - width=monitor["width"], - height=monitor["height"], - x=monitor["left"], - y=monitor["top"], - is_primary=(idx == 0), - refresh_rate=refresh_rate, - ) - displays.append(display_info) - - logger.debug(f"MSS detected {len(displays)} display(s)") - return displays - - except Exception as e: - logger.error(f"Failed to detect displays with MSS: {e}") - raise RuntimeError(f"Failed to detect displays: {e}") - - def capture_display(self, display_index: int) -> ScreenCapture: - """Capture display using MSS. - - Args: - display_index: Index of display to capture (0-based) - - Returns: - ScreenCapture object with image data - - Raises: - ValueError: If display_index is invalid - RuntimeError: If capture fails - """ - # Auto-initialize if not already initialized + def capture_frame(self) -> Optional[ScreenCapture]: if not self._initialized: self.initialize() try: # mss monitors[0] is the combined screen, monitors[1+] are individual displays - monitor_index = display_index + 1 + monitor_index = self.display_index + 1 if monitor_index >= len(self._sct.monitors): raise ValueError( - f"Invalid display index {display_index}. " + f"Invalid display index {self.display_index}. " f"Available displays: 0-{len(self._sct.monitors) - 2}" ) monitor = self._sct.monitors[monitor_index] - - # Capture screenshot screenshot = self._sct.grab(monitor) # Convert to numpy array (RGB) @@ -143,31 +61,35 @@ class MSSEngine(CaptureEngine): img_array = np.array(img) logger.debug( - f"MSS captured display {display_index}: {monitor['width']}x{monitor['height']}" + f"MSS captured display {self.display_index}: {monitor['width']}x{monitor['height']}" ) return ScreenCapture( image=img_array, width=monitor["width"], height=monitor["height"], - display_index=display_index, + display_index=self.display_index, ) except ValueError: raise except Exception as e: - logger.error(f"Failed to capture display {display_index} with MSS: {e}") + logger.error(f"Failed to capture display {self.display_index} with MSS: {e}") raise RuntimeError(f"Screen capture failed: {e}") + +class MSSEngine(CaptureEngine): + """MSS-based screen capture engine. + + Uses the mss library for cross-platform screen capture support. + Works on Windows, macOS, and Linux. + """ + + ENGINE_TYPE = "mss" + ENGINE_PRIORITY = 1 + @classmethod def is_available(cls) -> bool: - """Check if MSS is available. - - MSS is cross-platform and should always be available. - - Returns: - True if mss library is available - """ try: import mss return True @@ -176,11 +98,38 @@ class MSSEngine(CaptureEngine): @classmethod def get_default_config(cls) -> Dict[str, Any]: - """Get default MSS configuration. - - MSS has no configurable options. - - Returns: - Empty dict (MSS has no configuration) - """ return {} + + @classmethod + def get_available_displays(cls) -> List[DisplayInfo]: + try: + monitor_names = get_monitor_names() + refresh_rates = get_monitor_refresh_rates() + + displays = [] + with mss.mss() as sct: + for idx, monitor in enumerate(sct.monitors[1:], start=0): + friendly_name = monitor_names.get(idx, f"Display {idx}") + refresh_rate = refresh_rates.get(idx, 60) + + displays.append(DisplayInfo( + index=idx, + name=friendly_name, + width=monitor["width"], + height=monitor["height"], + x=monitor["left"], + y=monitor["top"], + is_primary=(idx == 0), + refresh_rate=refresh_rate, + )) + + logger.debug(f"MSS detected {len(displays)} display(s)") + return displays + + except Exception as e: + logger.error(f"Failed to detect displays with MSS: {e}") + raise RuntimeError(f"Failed to detect displays: {e}") + + @classmethod + def create_stream(cls, display_index: int, config: Dict[str, Any]) -> MSSCaptureStream: + return MSSCaptureStream(display_index, config) diff --git a/server/src/wled_controller/core/capture_engines/wgc_engine.py b/server/src/wled_controller/core/capture_engines/wgc_engine.py index 35ccbc3..f4045f8 100644 --- a/server/src/wled_controller/core/capture_engines/wgc_engine.py +++ b/server/src/wled_controller/core/capture_engines/wgc_engine.py @@ -2,14 +2,14 @@ import gc import sys -import time import threading -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional import numpy as np from wled_controller.core.capture_engines.base import ( CaptureEngine, + CaptureStream, DisplayInfo, ScreenCapture, ) @@ -18,54 +18,20 @@ from wled_controller.utils import get_logger logger = get_logger(__name__) -class WGCEngine(CaptureEngine): - """Windows Graphics Capture engine. +class WGCCaptureStream(CaptureStream): + """WGC capture stream for a specific display.""" - Uses the windows-capture library which leverages Windows.Graphics.Capture API. - This is Microsoft's recommended modern screen capture API for Windows 10+. - - Features: - - Cross-GPU support (works regardless of GPU routing) - - Hardware cursor exclusion (no cursor flickering) - - GPU-accelerated with direct texture sharing - - Modern, future-proof API - - Requires: Windows 10 1803+ - """ - - ENGINE_TYPE = "wgc" - ENGINE_PRIORITY = 2 - - def __init__(self, config: Dict[str, Any]): - """Initialize WGC engine. - - Args: - config: Engine configuration - - capture_cursor (bool): Include cursor in capture (default: False) - - draw_border (bool): Draw border around capture (default: False) - - Note: monitor_index is NOT in config - WGC maintains separate instances per monitor - to support simultaneous capture from multiple monitors. - """ - super().__init__(config) + def __init__(self, display_index: int, config: Dict[str, Any]): + super().__init__(display_index, config) self._wgc = None - # Per-monitor capture instances: {monitor_index: (instance, control, frame, frame_event)} - self._monitor_captures = {} + self._capture_instance = None + self._capture_control = None + self._latest_frame = None + self._frame_event = threading.Event() + self._closed_event = threading.Event() self._frame_lock = threading.Lock() - def initialize(self, monitor_index: int = 0) -> None: - """Initialize WGC capture for a specific monitor. - - Maintains separate capture instances per monitor to support simultaneous - capture from multiple monitors. - - Args: - monitor_index: Monitor index to capture (0-based) - - Raises: - RuntimeError: If windows-capture not installed or initialization fails - """ - # Import windows_capture if not already imported + def initialize(self) -> None: if self._wgc is None: try: import windows_capture @@ -75,249 +41,110 @@ class WGCEngine(CaptureEngine): "windows-capture not installed. Install with: pip install windows-capture" ) - # Skip if already initialized for this monitor - if monitor_index in self._monitor_captures: - logger.debug(f"WGC already initialized for monitor {monitor_index}") - return - try: capture_cursor = self.config.get("capture_cursor", False) - # Note: draw_border is not supported by WGC API on most platforms - # WGC uses 1-based monitor indexing (1, 2, 3...) while we use 0-based (0, 1, 2...) - wgc_monitor_index = monitor_index + 1 + # WGC uses 1-based monitor indexing + wgc_monitor_index = self.display_index + 1 - # Create per-monitor events and storage - frame_event = threading.Event() - closed_event = threading.Event() - latest_frame = None - - # Create capture instance - # Note: draw_border parameter not supported on all platforms - capture_instance = self._wgc.WindowsCapture( + self._capture_instance = self._wgc.WindowsCapture( cursor_capture=capture_cursor, monitor_index=wgc_monitor_index, ) - # Define event handlers as local functions that capture monitor_index def on_frame_arrived(frame, capture_control): - """Called when a new frame is captured.""" - nonlocal latest_frame try: - logger.debug(f"WGC frame callback triggered for monitor {monitor_index}") - - # Get frame buffer as numpy array frame_buffer = frame.frame_buffer width = frame.width height = frame.height - # Reshape to image dimensions (height, width, channels) - # WGC provides BGRA format + # WGC provides BGRA format, convert to RGB frame_array = frame_buffer.reshape((height, width, 4)) + frame_rgb = frame_array[:, :, [2, 1, 0]] - # Convert BGRA to RGB - frame_rgb = frame_array[:, :, [2, 1, 0]] # Take BGR channels - - # Store the latest frame for this monitor with self._frame_lock: - if monitor_index in self._monitor_captures: - self._monitor_captures[monitor_index]['latest_frame'] = frame_rgb.copy() - self._monitor_captures[monitor_index]['frame_event'].set() + self._latest_frame = frame_rgb.copy() + self._frame_event.set() except Exception as e: - logger.error(f"Error processing WGC frame for monitor {monitor_index}: {e}", exc_info=True) + logger.error(f"Error processing WGC frame: {e}", exc_info=True) def on_closed(): - """Called when capture session is closed.""" - logger.debug(f"WGC capture session closed for monitor {monitor_index}") - # Signal that the capture session has fully closed and resources are released - with self._frame_lock: - if monitor_index in self._monitor_captures: - self._monitor_captures[monitor_index]['closed_event'].set() + logger.debug(f"WGC capture session closed for display {self.display_index}") + self._closed_event.set() - # Set handlers directly as attributes - capture_instance.frame_handler = on_frame_arrived - capture_instance.closed_handler = on_closed + self._capture_instance.frame_handler = on_frame_arrived + self._capture_instance.closed_handler = on_closed - # Start capture using free-threaded mode (non-blocking) - # IMPORTANT: start_free_threaded() returns a CaptureControl object for cleanup - logger.debug(f"Starting WGC capture for monitor {monitor_index} (free-threaded mode)...") - capture_control = capture_instance.start_free_threaded() + logger.debug(f"Starting WGC capture for display {self.display_index} (free-threaded mode)...") + self._capture_control = self._capture_instance.start_free_threaded() - # Store all per-monitor data - self._monitor_captures[monitor_index] = { - 'instance': capture_instance, - 'control': capture_control, - 'latest_frame': None, - 'frame_event': frame_event, - 'closed_event': closed_event, - } + # Wait for first frame + logger.debug(f"Waiting for first WGC frame from display {self.display_index}...") + frame_received = self._frame_event.wait(timeout=5.0) - # Wait for first frame to arrive (with timeout) - logger.debug(f"Waiting for first WGC frame from monitor {monitor_index}...") - frame_received = frame_event.wait(timeout=5.0) - - if not frame_received or self._monitor_captures[monitor_index]['latest_frame'] is None: - # Cleanup on failure - with self._frame_lock: - if monitor_index in self._monitor_captures: - del self._monitor_captures[monitor_index] + if not frame_received or self._latest_frame is None: + self._cleanup_internal() raise RuntimeError( - f"WGC capture started for monitor {monitor_index} but no frames received within 5 seconds. " - "This may indicate the capture session failed to start or " - "the display is not actively updating." + f"WGC capture started for display {self.display_index} but no frames received within 5 seconds." ) + self._initialized = True logger.info( - f"WGC engine initialized (monitor={monitor_index}, " + f"WGC capture stream initialized (display={self.display_index}, " f"cursor={capture_cursor})" ) + except RuntimeError: + raise except Exception as e: - logger.error(f"Failed to initialize WGC for monitor {monitor_index}: {e}", exc_info=True) - raise RuntimeError(f"Failed to initialize WGC for monitor {monitor_index}: {e}") + logger.error(f"Failed to initialize WGC for display {self.display_index}: {e}", exc_info=True) + raise RuntimeError(f"Failed to initialize WGC for display {self.display_index}: {e}") + + def _cleanup_internal(self) -> None: + """Internal cleanup helper.""" + if self._capture_control: + try: + logger.debug(f"Stopping WGC capture thread for display {self.display_index}...") + self._capture_control.stop() + self._capture_control.wait() + logger.debug(f"WGC capture thread finished for display {self.display_index}") + except Exception as e: + logger.error(f"Error during WGC capture control cleanup: {e}", exc_info=True) + self._capture_control = None + + if self._capture_instance: + try: + del self._capture_instance + except Exception: + pass + self._capture_instance = None + + self._frame_event.clear() + self._closed_event.clear() + self._latest_frame = None def cleanup(self) -> None: - """Cleanup WGC resources for all monitors.""" - # Proper cleanup for free-threaded captures: - # 1. Stop capture via CaptureControl.stop() (signals thread to stop) - # 2. Wait for thread to finish using CaptureControl.wait() (blocks until done) - # 3. Delete capture instance (releases COM objects) - # 4. Force garbage collection (ensures COM cleanup) - - with self._frame_lock: - monitors_to_cleanup = list(self._monitor_captures.keys()) - - for monitor_index in monitors_to_cleanup: - logger.debug(f"Cleaning up WGC resources for monitor {monitor_index}...") - - with self._frame_lock: - if monitor_index not in self._monitor_captures: - continue - monitor_data = self._monitor_captures[monitor_index] - - # Stop and wait for capture thread - capture_control = monitor_data.get('control') - if capture_control: - try: - logger.debug(f"Stopping WGC capture thread for monitor {monitor_index}...") - capture_control.stop() - - logger.debug(f"Waiting for WGC capture thread to finish (monitor {monitor_index})...") - # This will block until the capture thread actually finishes - capture_control.wait() - logger.debug(f"WGC capture thread finished successfully for monitor {monitor_index}") - except Exception as e: - logger.error(f"Error during WGC capture control cleanup for monitor {monitor_index}: {e}", exc_info=True) - - # Delete capture instance - capture_instance = monitor_data.get('instance') - if capture_instance: - try: - logger.debug(f"Deleting WGC capture instance for monitor {monitor_index}...") - del capture_instance - logger.debug(f"WGC capture instance deleted for monitor {monitor_index}") - except Exception as e: - logger.error(f"Error deleting WGC capture instance for monitor {monitor_index}: {e}", exc_info=True) - - # Clear events - frame_event = monitor_data.get('frame_event') - if frame_event: - frame_event.clear() - - closed_event = monitor_data.get('closed_event') - if closed_event: - closed_event.clear() - - # Remove from dictionary - with self._frame_lock: - if monitor_index in self._monitor_captures: - del self._monitor_captures[monitor_index] - - logger.info(f"WGC engine cleaned up for monitor {monitor_index}") + self._cleanup_internal() + self._initialized = False # Force garbage collection to release COM objects - logger.debug("Running garbage collection for COM cleanup...") gc.collect() - logger.debug("Garbage collection completed") + logger.info(f"WGC capture stream cleaned up (display={self.display_index})") - def get_available_displays(self) -> List[DisplayInfo]: - """Get list of available displays using MSS. - - Note: WGC doesn't provide a direct API for enumerating monitors, - so we use MSS for display detection. - - Returns: - List of DisplayInfo objects - - Raises: - RuntimeError: If detection fails - """ - try: - import mss - - with mss.mss() as sct: - displays = [] - # Skip monitor 0 (all monitors combined) - for i, monitor in enumerate(sct.monitors[1:], start=0): - displays.append( - DisplayInfo( - index=i, - name=f"Monitor {i+1}", - width=monitor["width"], - height=monitor["height"], - x=monitor["left"], - y=monitor["top"], - is_primary=(i == 0), - refresh_rate=60, - ) - ) - - logger.debug(f"WGC detected {len(displays)} display(s)") - return displays - - except Exception as e: - logger.error(f"Failed to detect displays: {e}") - raise RuntimeError(f"Failed to detect displays: {e}") - - def capture_display(self, display_index: int) -> ScreenCapture: - """Capture display using WGC. - - WGC dynamically initializes for the requested display if needed. - Supports simultaneous capture from multiple monitors. - - Args: - display_index: Index of display to capture (0-based) - - Returns: - ScreenCapture object with image data - - Raises: - RuntimeError: If initialization or capture fails - """ - # Initialize for this monitor if not already initialized - self.initialize(display_index) + def capture_frame(self) -> Optional[ScreenCapture]: + if not self._initialized: + self.initialize() try: - # Get the latest frame for this monitor with self._frame_lock: - if display_index not in self._monitor_captures: + if self._latest_frame is None: raise RuntimeError( - f"Monitor {display_index} not initialized. This should not happen." + f"No frame available yet for display {self.display_index}." ) - - monitor_data = self._monitor_captures[display_index] - latest_frame = monitor_data.get('latest_frame') - - if latest_frame is None: - raise RuntimeError( - f"No frame available yet for monitor {display_index}. " - "The capture may not have started or the screen hasn't updated. " - "Wait a moment and try again." - ) - frame = latest_frame.copy() + frame = self._latest_frame.copy() logger.debug( - f"WGC captured display {display_index}: " + f"WGC captured display {self.display_index}: " f"{frame.shape[1]}x{frame.shape[0]}" ) @@ -325,46 +152,51 @@ class WGCEngine(CaptureEngine): image=frame, width=frame.shape[1], height=frame.shape[0], - display_index=display_index, + display_index=self.display_index, ) - except ValueError: + except RuntimeError: raise except Exception as e: - logger.error(f"Failed to capture display {display_index} with WGC: {e}") + logger.error(f"Failed to capture display {self.display_index} with WGC: {e}") raise RuntimeError(f"Screen capture failed: {e}") + +class WGCEngine(CaptureEngine): + """Windows Graphics Capture engine. + + Uses the windows-capture library which leverages Windows.Graphics.Capture API. + This is Microsoft's recommended modern screen capture API for Windows 10+. + + Features: + - Cross-GPU support + - Hardware cursor exclusion + - GPU-accelerated with direct texture sharing + + Requires: Windows 10 1803+ + """ + + ENGINE_TYPE = "wgc" + ENGINE_PRIORITY = 2 + @classmethod def is_available(cls) -> bool: - """Check if WGC is available. - - WGC requires Windows 10 1803+ and the windows-capture package. - - Returns: - True if windows-capture is available on this system - """ - # Check platform if sys.platform != "win32": return False - # Check Windows version (Windows 10 1803 = version 10.0.17134) try: import platform version = platform.version() - # Parse version string like "10.0.19045" parts = version.split(".") if len(parts) >= 3: major = int(parts[0]) minor = int(parts[1]) build = int(parts[2]) - # Check for Windows 10 1803+ (build 17134+) if major < 10 or (major == 10 and minor == 0 and build < 17134): return False except Exception: - # If we can't parse version, assume it might work pass - # Check if windows-capture is installed try: import windows_capture return True @@ -373,15 +205,37 @@ class WGCEngine(CaptureEngine): @classmethod def get_default_config(cls) -> Dict[str, Any]: - """Get default WGC configuration. - - Note: monitor_index is NOT in config - WGC dynamically initializes - for the requested monitor at capture time. - - Returns: - Default config dict with WGC options - """ return { - "capture_cursor": False, # Exclude cursor (hardware exclusion) - "draw_border": False, # Don't draw border around capture + "capture_cursor": False, + "draw_border": False, } + + @classmethod + def get_available_displays(cls) -> List[DisplayInfo]: + try: + import mss + + with mss.mss() as sct: + displays = [] + for i, monitor in enumerate(sct.monitors[1:], start=0): + displays.append(DisplayInfo( + index=i, + name=f"Monitor {i+1}", + width=monitor["width"], + height=monitor["height"], + x=monitor["left"], + y=monitor["top"], + is_primary=(i == 0), + refresh_rate=60, + )) + + logger.debug(f"WGC detected {len(displays)} display(s)") + return displays + + except Exception as e: + logger.error(f"Failed to detect displays: {e}") + raise RuntimeError(f"Failed to detect displays: {e}") + + @classmethod + def create_stream(cls, display_index: int, config: Dict[str, Any]) -> WGCCaptureStream: + return WGCCaptureStream(display_index, config) diff --git a/server/src/wled_controller/core/live_stream.py b/server/src/wled_controller/core/live_stream.py new file mode 100644 index 0000000..faf597f --- /dev/null +++ b/server/src/wled_controller/core/live_stream.py @@ -0,0 +1,240 @@ +"""Runtime live stream abstractions for frame production. + +LiveStream is the runtime counterpart of PictureSource (config/storage layer). +Each PictureSource type maps to a LiveStream implementation: + + ScreenCapturePictureSource → ScreenCaptureLiveStream + ProcessedPictureSource → ProcessedLiveStream + StaticImagePictureSource → StaticImageLiveStream + +LiveStreams are managed by LiveStreamManager which handles sharing and +reference counting — multiple devices using the same PictureSource +share a single LiveStream instance. +""" + +import threading +import time +from abc import ABC, abstractmethod +from typing import List, Optional + +import numpy as np + +from wled_controller.core.capture_engines.base import CaptureStream, ScreenCapture +from wled_controller.core.filters import ImagePool, PostprocessingFilter +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +class LiveStream(ABC): + """Abstract base for a runtime frame source. + + A LiveStream produces frames at some frequency. Consumers call + get_latest_frame() to read the most recent frame (non-blocking). + """ + + @property + @abstractmethod + def target_fps(self) -> int: + """Frame rate this stream targets.""" + + @property + @abstractmethod + def display_index(self) -> Optional[int]: + """Display index being captured, or None for non-capture streams.""" + + @abstractmethod + def start(self) -> None: + """Start producing frames. Called once when the stream is first needed.""" + + @abstractmethod + def stop(self) -> None: + """Stop producing frames and release resources.""" + + @abstractmethod + def get_latest_frame(self) -> Optional[ScreenCapture]: + """Get the most recent frame. + + Returns: + ScreenCapture with image data (RGB), or None if no frame available yet. + """ + + +class ScreenCaptureLiveStream(LiveStream): + """Live stream backed by a CaptureStream with a dedicated capture thread. + + Runs a background thread that captures frames at the target FPS and stores + the latest frame under a lock. Consumers read the cached frame via + get_latest_frame() (non-blocking). + + The dedicated thread naturally satisfies thread affinity requirements + for capture libraries (DXGI, GDI, WGC). + """ + + def __init__(self, capture_stream: CaptureStream, fps: int): + self._capture_stream = capture_stream + self._fps = fps + self._latest_frame: Optional[ScreenCapture] = None + self._frame_lock = threading.Lock() + self._running = False + self._thread: Optional[threading.Thread] = None + + @property + def target_fps(self) -> int: + return self._fps + + @property + def display_index(self) -> Optional[int]: + return self._capture_stream.display_index + + def start(self) -> None: + if self._running: + return + + self._capture_stream.initialize() + self._running = True + self._thread = threading.Thread( + target=self._capture_loop, + name=f"live-capture-{self._capture_stream.display_index}", + daemon=True, + ) + self._thread.start() + logger.info( + f"ScreenCaptureLiveStream started " + f"(display={self._capture_stream.display_index}, fps={self._fps})" + ) + + def stop(self) -> None: + if not self._running: + return + + self._running = False + if self._thread: + self._thread.join(timeout=5.0) + if self._thread.is_alive(): + logger.warning("Capture thread did not terminate within 5s") + self._thread = None + + self._capture_stream.cleanup() + self._latest_frame = None + logger.info( + f"ScreenCaptureLiveStream stopped " + f"(display={self._capture_stream.display_index})" + ) + + def get_latest_frame(self) -> Optional[ScreenCapture]: + with self._frame_lock: + return self._latest_frame + + def _capture_loop(self) -> None: + frame_time = 1.0 / self._fps if self._fps > 0 else 1.0 + while self._running: + loop_start = time.time() + try: + frame = self._capture_stream.capture_frame() + if frame is not None: + with self._frame_lock: + self._latest_frame = frame + except Exception as e: + logger.error(f"Capture error (display={self._capture_stream.display_index}): {e}") + + elapsed = time.time() - loop_start + sleep_time = max(0, frame_time - elapsed) + if sleep_time > 0: + time.sleep(sleep_time) + + +class ProcessedLiveStream(LiveStream): + """Live stream that applies postprocessing filters to a source stream. + + Reads frames from a source LiveStream and applies a chain of filters. + Uses identity caching — if the source frame hasn't changed, returns + the previously processed result without recomputing. + + Thread-safe: a lock protects the filter application so concurrent + consumers don't duplicate work. + """ + + def __init__( + self, + source: LiveStream, + filters: List[PostprocessingFilter], + ): + self._source = source + self._filters = filters + self._image_pool = ImagePool() + self._process_lock = threading.Lock() + self._cached_source_frame: Optional[ScreenCapture] = None + self._cached_result: Optional[ScreenCapture] = None + + @property + def target_fps(self) -> int: + return self._source.target_fps + + @property + def display_index(self) -> Optional[int]: + return self._source.display_index + + def start(self) -> None: + # Source lifecycle managed by LiveStreamManager + pass + + def stop(self) -> None: + # Source lifecycle managed by LiveStreamManager + self._cached_source_frame = None + self._cached_result = None + + def get_latest_frame(self) -> Optional[ScreenCapture]: + source_frame = self._source.get_latest_frame() + if source_frame is None: + return None + + with self._process_lock: + # Identity cache: if source frame object hasn't changed, reuse result + if source_frame is self._cached_source_frame and self._cached_result is not None: + return self._cached_result + + # Apply filters to a copy of the source image + image = source_frame.image.copy() + for f in self._filters: + result = f.process_image(image, self._image_pool) + if result is not None: + image = result + + processed = ScreenCapture( + image=image, + width=source_frame.width, + height=source_frame.height, + display_index=source_frame.display_index, + ) + self._cached_source_frame = source_frame + self._cached_result = processed + return processed + + +class StaticImageLiveStream(LiveStream): + """Live stream that always returns the same static image.""" + + def __init__(self, image: np.ndarray): + self._image = image + h, w = image.shape[:2] + self._frame = ScreenCapture( + image=image, width=w, height=h, display_index=-1 + ) + + @property + def target_fps(self) -> int: + return 1 + + @property + def display_index(self) -> Optional[int]: + return None + + def start(self) -> None: + pass + + def stop(self) -> None: + pass + + def get_latest_frame(self) -> Optional[ScreenCapture]: + return self._frame diff --git a/server/src/wled_controller/core/live_stream_manager.py b/server/src/wled_controller/core/live_stream_manager.py new file mode 100644 index 0000000..7c2f176 --- /dev/null +++ b/server/src/wled_controller/core/live_stream_manager.py @@ -0,0 +1,272 @@ +"""Shared live stream management with reference counting. + +LiveStreamManager creates LiveStream instances from PictureSource configs +and shares them across multiple consumers (devices). When multiple devices +reference the same PictureSource, they share a single LiveStream instance. + +Reference counting ensures streams are cleaned up when the last consumer +releases them. +""" + +from dataclasses import dataclass +from typing import Dict, Optional + +import httpx +import numpy as np + +from wled_controller.core.capture_engines import EngineRegistry +from wled_controller.core.filters import FilterRegistry, PostprocessingFilter +from wled_controller.core.live_stream import ( + LiveStream, + ProcessedLiveStream, + ScreenCaptureLiveStream, + StaticImageLiveStream, +) +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +@dataclass +class _LiveStreamEntry: + """Internal tracking entry for a managed live stream.""" + + live_stream: LiveStream + ref_count: int + # For ProcessedLiveStream: the source stream ID whose live stream we depend on. + # Used to recursively release the source when this stream's ref count hits 0. + source_stream_id: Optional[str] = None + + +class LiveStreamManager: + """Manages shared LiveStream instances with reference counting. + + Multiple devices using the same PictureSource share a single LiveStream. + Streams are created on first acquire and cleaned up when the last + consumer releases. + + For ProcessedPictureSources, the source stream is recursively acquired, + enabling sharing at every level of the stream chain. + """ + + def __init__(self, picture_source_store, capture_template_store=None, pp_template_store=None): + """Initialize the live stream manager. + + Args: + picture_source_store: PictureSourceStore for resolving stream configs + capture_template_store: TemplateStore for resolving capture engine settings + pp_template_store: PostprocessingTemplateStore for resolving filter chains + """ + self._picture_source_store = picture_source_store + self._capture_template_store = capture_template_store + self._pp_template_store = pp_template_store + self._streams: Dict[str, _LiveStreamEntry] = {} + + def acquire(self, picture_source_id: str) -> LiveStream: + """Get or create a LiveStream for the given PictureSource config. + + If a LiveStream already exists for this picture_source_id, increments + the reference count and returns the existing instance. + + Otherwise, creates a new LiveStream from the PictureSource config, + starts it, and stores it with ref_count=1. + + Args: + picture_source_id: ID of the PictureSource config + + Returns: + LiveStream instance (shared if already exists) + + Raises: + ValueError: If PictureSource not found or config invalid + RuntimeError: If stream creation/start fails + """ + if picture_source_id in self._streams: + entry = self._streams[picture_source_id] + entry.ref_count += 1 + logger.info( + f"Reusing live stream for picture source {picture_source_id} " + f"(ref_count={entry.ref_count})" + ) + return entry.live_stream + + # Create new live stream from config + live_stream, source_stream_id = self._create_live_stream(picture_source_id) + + try: + live_stream.start() + except Exception as e: + # If start fails, release any source dependency we acquired + if source_stream_id: + self.release(source_stream_id) + raise RuntimeError( + f"Failed to start live stream for picture source {picture_source_id}: {e}" + ) + + self._streams[picture_source_id] = _LiveStreamEntry( + live_stream=live_stream, + ref_count=1, + source_stream_id=source_stream_id, + ) + + logger.info(f"Created live stream for picture source {picture_source_id}") + return live_stream + + def release(self, picture_source_id: str) -> None: + """Release a reference to a LiveStream. + + Decrements the reference count. When it reaches 0, stops the + LiveStream and removes it from the registry. + + For ProcessedLiveStreams, recursively releases the source dependency. + + Args: + picture_source_id: ID of the PictureSource to release + """ + entry = self._streams.get(picture_source_id) + if not entry: + logger.warning(f"Attempted to release unknown live stream: {picture_source_id}") + return + + entry.ref_count -= 1 + logger.debug( + f"Released live stream {picture_source_id} (ref_count={entry.ref_count})" + ) + + if entry.ref_count <= 0: + # Stop and remove + try: + entry.live_stream.stop() + except Exception as e: + logger.error(f"Error stopping live stream {picture_source_id}: {e}") + + source_stream_id = entry.source_stream_id + del self._streams[picture_source_id] + logger.info(f"Removed live stream for picture source {picture_source_id}") + + # Recursively release source dependency + if source_stream_id: + self.release(source_stream_id) + + def release_all(self) -> None: + """Stop and remove all managed live streams. Safety net for shutdown.""" + stream_ids = list(self._streams.keys()) + for stream_id in stream_ids: + entry = self._streams.get(stream_id) + if entry: + try: + entry.live_stream.stop() + except Exception as e: + logger.error(f"Error stopping live stream {stream_id}: {e}") + self._streams.clear() + logger.info("Released all managed live streams") + + def get_active_stream_ids(self) -> list: + """Get list of currently active stream IDs (for diagnostics).""" + return [ + {"id": sid, "ref_count": entry.ref_count} + for sid, entry in self._streams.items() + ] + + def _create_live_stream(self, picture_source_id: str) -> tuple: + """Create a LiveStream from a PictureSource config. + + Returns: + Tuple of (LiveStream, source_stream_id or None) + """ + from wled_controller.storage.picture_source import ( + ProcessedPictureSource, + ScreenCapturePictureSource, + StaticImagePictureSource, + ) + + stream_config = self._picture_source_store.get_stream(picture_source_id) + + if isinstance(stream_config, ScreenCapturePictureSource): + return self._create_screen_capture_live_stream(stream_config), None + + elif isinstance(stream_config, ProcessedPictureSource): + return self._create_processed_live_stream(stream_config) + + elif isinstance(stream_config, StaticImagePictureSource): + return self._create_static_image_live_stream(stream_config), None + + else: + raise ValueError(f"Unknown picture source type: {type(stream_config)}") + + def _create_screen_capture_live_stream(self, config) -> ScreenCaptureLiveStream: + """Create a ScreenCaptureLiveStream from a ScreenCapturePictureSource config.""" + # Resolve capture engine from template + engine_type = "mss" + engine_config = {} + + if config.capture_template_id and self._capture_template_store: + try: + tpl = self._capture_template_store.get_template(config.capture_template_id) + engine_type = tpl.engine_type + engine_config = tpl.engine_config + except ValueError: + logger.warning( + f"Capture template {config.capture_template_id} not found, using MSS fallback" + ) + + capture_stream = EngineRegistry.create_stream( + engine_type, config.display_index, engine_config + ) + + return ScreenCaptureLiveStream(capture_stream, config.target_fps) + + def _create_processed_live_stream(self, config) -> tuple: + """Create a ProcessedLiveStream from a ProcessedPictureSource config. + + Returns: + Tuple of (ProcessedLiveStream, source_stream_id) + """ + # Recursively acquire source stream (with ref counting) + source_stream_id = config.source_stream_id + source_live = self.acquire(source_stream_id) + + # Resolve postprocessing filters + filters = [] + if config.postprocessing_template_id and self._pp_template_store: + try: + pp = self._pp_template_store.get_template(config.postprocessing_template_id) + for fi in pp.filters: + try: + filters.append( + FilterRegistry.create_instance(fi.filter_id, fi.options) + ) + except ValueError as e: + logger.warning(f"Skipping unknown filter '{fi.filter_id}': {e}") + except ValueError: + logger.warning( + f"PP template {config.postprocessing_template_id} not found, no filters applied" + ) + + return ProcessedLiveStream(source_live, filters), source_stream_id + + def _create_static_image_live_stream(self, config) -> StaticImageLiveStream: + """Create a StaticImageLiveStream from a StaticImagePictureSource config.""" + image = self._load_static_image(config.image_source) + return StaticImageLiveStream(image) + + @staticmethod + def _load_static_image(image_source: str) -> np.ndarray: + """Load a static image from URL or file path, return as RGB numpy array.""" + from io import BytesIO + from pathlib import Path + + from PIL import Image + + if image_source.startswith(("http://", "https://")): + response = httpx.get(image_source, timeout=15.0, follow_redirects=True) + response.raise_for_status() + pil_image = Image.open(BytesIO(response.content)) + else: + path = Path(image_source) + if not path.exists(): + raise FileNotFoundError(f"Image file not found: {image_source}") + pil_image = Image.open(path) + + pil_image = pil_image.convert("RGB") + return np.array(pil_image) diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py index bc6136b..662dddc 100644 --- a/server/src/wled_controller/core/processor_manager.py +++ b/server/src/wled_controller/core/processor_manager.py @@ -1,24 +1,22 @@ """Processing manager for coordinating screen capture and WLED updates.""" import asyncio -import concurrent.futures import time from dataclasses import dataclass, field from datetime import datetime from typing import Dict, List, Optional, Tuple import httpx +import numpy as np from wled_controller.core.calibration import ( CalibrationConfig, PixelMapper, create_default_calibration, ) -import numpy as np - -from wled_controller.core.capture_engines import CaptureEngine, EngineRegistry from wled_controller.core.capture_engines.base import ScreenCapture -from wled_controller.core.filters import FilterInstance, FilterRegistry, ImagePool, PostprocessingFilter +from wled_controller.core.live_stream import LiveStream +from wled_controller.core.live_stream_manager import LiveStreamManager from wled_controller.core.pixel_processor import smooth_colors from wled_controller.core.screen_capture import extract_border_pixels from wled_controller.core.wled_client import WLEDClient @@ -93,11 +91,9 @@ class ProcessorState: led_count: int settings: ProcessingSettings calibration: CalibrationConfig - capture_template_id: str = "" - picture_stream_id: str = "" + picture_source_id: str = "" wled_client: Optional[WLEDClient] = None pixel_mapper: Optional[PixelMapper] = None - capture_engine: Optional[CaptureEngine] = None is_running: bool = False task: Optional[asyncio.Task] = None metrics: ProcessingMetrics = field(default_factory=ProcessingMetrics) @@ -111,15 +107,8 @@ class ProcessorState: resolved_target_fps: Optional[int] = None resolved_engine_type: Optional[str] = None resolved_engine_config: Optional[dict] = None - resolved_filters: Optional[List[FilterInstance]] = None - # Static image: cached frame for static_image streams (no engine needed) - static_image: Optional[np.ndarray] = None - image_pool: Optional[ImagePool] = None - filter_instances: Optional[List[PostprocessingFilter]] = None - # Dedicated single-thread executor for capture engine calls. - # Capture libraries (BetterCam, MSS, DXcam) use thread-local state, - # so all calls must run on the same thread. - capture_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None + # LiveStream: runtime frame source (shared via LiveStreamManager) + live_stream: Optional[LiveStream] = None # WLED state snapshot taken before streaming starts (to restore on stop) wled_state_before: Optional[dict] = None @@ -127,20 +116,23 @@ class ProcessorState: class ProcessorManager: """Manages screen processing for multiple WLED devices.""" - def __init__(self, picture_stream_store=None, capture_template_store=None, pp_template_store=None): + def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None): """Initialize processor manager. Args: - picture_stream_store: PictureStreamStore instance (for stream resolution) + picture_source_store: PictureSourceStore instance (for stream resolution) capture_template_store: TemplateStore instance (for engine lookup) pp_template_store: PostprocessingTemplateStore instance (for PP settings) """ self._processors: Dict[str, ProcessorState] = {} self._health_monitoring_active = False self._http_client: Optional[httpx.AsyncClient] = None - self._picture_stream_store = picture_stream_store + self._picture_source_store = picture_source_store self._capture_template_store = capture_template_store self._pp_template_store = pp_template_store + self._live_stream_manager = LiveStreamManager( + picture_source_store, capture_template_store, pp_template_store + ) logger.info("Processor manager initialized") async def _get_http_client(self) -> httpx.AsyncClient: @@ -156,8 +148,7 @@ class ProcessorManager: led_count: int, settings: Optional[ProcessingSettings] = None, calibration: Optional[CalibrationConfig] = None, - capture_template_id: str = "", - picture_stream_id: str = "", + picture_source_id: str = "", ): """Add a device for processing. @@ -167,8 +158,7 @@ class ProcessorManager: led_count: Number of LEDs settings: Processing settings (uses defaults if None) calibration: Calibration config (creates default if None) - capture_template_id: Legacy template ID for screen capture engine - picture_stream_id: Picture stream ID (preferred over capture_template_id) + picture_source_id: Picture source ID """ if device_id in self._processors: raise ValueError(f"Device {device_id} already exists") @@ -185,8 +175,7 @@ class ProcessorManager: led_count=led_count, settings=settings, calibration=calibration, - capture_template_id=capture_template_id, - picture_stream_id=picture_stream_id, + picture_source_id=picture_source_id, ) self._processors[device_id] = state @@ -280,116 +269,56 @@ class ProcessorManager: logger.info(f"Updated calibration for device {device_id}") def _resolve_stream_settings(self, state: ProcessorState): - """Resolve picture stream chain to populate resolved_* fields on state. + """Resolve picture source chain to populate resolved_* metadata fields. - If device has a picture_stream_id and stores are available, resolves the - stream chain to get display_index, fps, engine type/config, and PP settings. - Otherwise falls back to legacy device settings. + Resolves metadata (display_index, fps, engine info) for status reporting. + Actual stream creation is handled by LiveStreamManager. """ - if state.picture_stream_id and self._picture_stream_store: - try: - chain = self._picture_stream_store.resolve_stream_chain(state.picture_stream_id) - raw_stream = chain["raw_stream"] - pp_template_ids = chain["postprocessing_template_ids"] + if not state.picture_source_id or not self._picture_source_store: + raise ValueError(f"Device {state.device_id} has no picture source assigned") - if raw_stream.stream_type == "static_image": - # Static image stream: load image once, no engine needed - state.resolved_display_index = -1 - state.resolved_target_fps = 1 - state.resolved_engine_type = None - state.resolved_engine_config = None - state.static_image = self._load_static_image(raw_stream.image_source) - else: - # Raw capture stream - state.resolved_display_index = raw_stream.display_index - state.resolved_target_fps = raw_stream.target_fps + from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource - # Resolve capture engine from raw stream's capture template - if raw_stream.capture_template_id and self._capture_template_store: - try: - tpl = self._capture_template_store.get_template(raw_stream.capture_template_id) - state.resolved_engine_type = tpl.engine_type - state.resolved_engine_config = tpl.engine_config - except ValueError: - logger.warning(f"Capture template {raw_stream.capture_template_id} not found, using MSS fallback") - state.resolved_engine_type = "mss" - state.resolved_engine_config = {} + chain = self._picture_source_store.resolve_stream_chain(state.picture_source_id) + raw_stream = chain["raw_stream"] + pp_template_ids = chain["postprocessing_template_ids"] - # Resolve postprocessing: use first PP template in chain - if pp_template_ids and self._pp_template_store: - try: - pp = self._pp_template_store.get_template(pp_template_ids[0]) - state.resolved_filters = pp.filters - except ValueError: - logger.warning(f"PP template {pp_template_ids[0]} not found, using defaults") + if isinstance(raw_stream, StaticImagePictureSource): + state.resolved_display_index = -1 + state.resolved_target_fps = 1 + state.resolved_engine_type = None + state.resolved_engine_config = None + elif isinstance(raw_stream, ScreenCapturePictureSource): + state.resolved_display_index = raw_stream.display_index + state.resolved_target_fps = raw_stream.target_fps - logger.info( - f"Resolved stream chain for {state.device_id}: " - f"display={state.resolved_display_index}, fps={state.resolved_target_fps}, " - f"engine={state.resolved_engine_type}, pp_templates={len(pp_template_ids)}" - ) - return - except ValueError as e: - logger.warning(f"Failed to resolve stream {state.picture_stream_id}: {e}, falling back to legacy settings") + if raw_stream.capture_template_id and self._capture_template_store: + try: + tpl = self._capture_template_store.get_template(raw_stream.capture_template_id) + state.resolved_engine_type = tpl.engine_type + state.resolved_engine_config = tpl.engine_config + except ValueError: + logger.warning(f"Capture template {raw_stream.capture_template_id} not found, using MSS fallback") + state.resolved_engine_type = "mss" + state.resolved_engine_config = {} - # Fallback: use legacy device settings (construct filters from flat fields) - state.resolved_display_index = state.settings.display_index - state.resolved_target_fps = state.settings.fps - legacy_filters = [] - if state.settings.brightness != 1.0: - legacy_filters.append(FilterInstance("brightness", {"value": state.settings.brightness})) - if state.settings.saturation != 1.0: - legacy_filters.append(FilterInstance("saturation", {"value": state.settings.saturation})) - if state.settings.gamma != 1.0: - legacy_filters.append(FilterInstance("gamma", {"value": state.settings.gamma})) - state.resolved_filters = legacy_filters - - # Resolve engine from legacy capture_template_id - if state.capture_template_id and self._capture_template_store: - try: - tpl = self._capture_template_store.get_template(state.capture_template_id) - state.resolved_engine_type = tpl.engine_type - state.resolved_engine_config = tpl.engine_config - except ValueError: - logger.warning(f"Capture template {state.capture_template_id} not found, using MSS fallback") - state.resolved_engine_type = "mss" - state.resolved_engine_config = {} - else: - state.resolved_engine_type = "mss" - state.resolved_engine_config = {} - - @staticmethod - def _load_static_image(image_source: str) -> np.ndarray: - """Load a static image from URL or file path, return as RGB numpy array.""" - from io import BytesIO - from pathlib import Path - - from PIL import Image - - if image_source.startswith(("http://", "https://")): - response = httpx.get(image_source, timeout=15.0, follow_redirects=True) - response.raise_for_status() - pil_image = Image.open(BytesIO(response.content)) - else: - path = Path(image_source) - if not path.exists(): - raise FileNotFoundError(f"Image file not found: {image_source}") - pil_image = Image.open(path) - - pil_image = pil_image.convert("RGB") - return np.array(pil_image) + logger.info( + f"Resolved stream metadata for {state.device_id}: " + f"display={state.resolved_display_index}, fps={state.resolved_target_fps}, " + f"engine={state.resolved_engine_type}, pp_templates={len(pp_template_ids)}" + ) async def start_processing(self, device_id: str): """Start screen processing for a device. - Resolves the picture stream chain (if assigned) to determine capture engine, - display, FPS, and postprocessing settings. Falls back to legacy device settings. + Resolves the picture source chain to determine capture engine, + display, FPS, and postprocessing settings. Args: device_id: Device identifier Raises: - ValueError: If device not found + ValueError: If device not found or no picture source assigned RuntimeError: If processing already running """ if device_id not in self._processors: @@ -433,34 +362,25 @@ class ProcessorManager: logger.error(f"Failed to connect to WLED device {device_id}: {e}") raise RuntimeError(f"Failed to connect to WLED device: {e}") - # Initialize capture engine from resolved settings (skip for static_image) - if state.static_image is not None: - logger.info(f"Using static image for device {device_id} ({state.static_image.shape[1]}x{state.static_image.shape[0]})") - else: - try: - engine_type = state.resolved_engine_type or "mss" - engine_config = state.resolved_engine_config or {} - engine = EngineRegistry.create_engine(engine_type, engine_config) - - # Create a dedicated single-thread executor for capture calls. - # Capture libraries use thread-local state (DXGI contexts, GDI DCs) - # so initialize + capture + cleanup must all run on the same thread. - state.capture_executor = concurrent.futures.ThreadPoolExecutor( - max_workers=1, thread_name_prefix=f"capture-{device_id}" - ) - loop = asyncio.get_event_loop() - await loop.run_in_executor(state.capture_executor, engine.initialize) - - state.capture_engine = engine - logger.info(f"Initialized capture engine for device {device_id}: {engine_type}") - except Exception as e: - logger.error(f"Failed to initialize capture engine for device {device_id}: {e}") - if state.capture_executor: - state.capture_executor.shutdown(wait=False) - state.capture_executor = None - if state.wled_client: - await state.wled_client.disconnect() - raise RuntimeError(f"Failed to initialize capture engine: {e}") + # Acquire live stream via LiveStreamManager (shared across devices) + try: + live_stream = await asyncio.to_thread( + self._live_stream_manager.acquire, state.picture_source_id + ) + state.live_stream = live_stream + # Update resolved metadata from actual live stream + if live_stream.display_index is not None: + state.resolved_display_index = live_stream.display_index + state.resolved_target_fps = live_stream.target_fps + logger.info( + f"Acquired live stream for device {device_id} " + f"(picture_source={state.picture_source_id})" + ) + except Exception as e: + logger.error(f"Failed to initialize live stream for device {device_id}: {e}") + if state.wled_client: + await state.wled_client.disconnect() + raise RuntimeError(f"Failed to initialize live stream: {e}") # Initialize pixel mapper state.pixel_mapper = PixelMapper( @@ -526,39 +446,26 @@ class ProcessorManager: await state.wled_client.close() state.wled_client = None - # Cleanup capture engine on the same dedicated thread it was created on - if state.capture_engine: - if state.capture_executor: - loop = asyncio.get_event_loop() - try: - await loop.run_in_executor( - state.capture_executor, state.capture_engine.cleanup - ) - except Exception as e: - logger.warning(f"Error cleaning up capture engine: {e}") - state.capture_executor.shutdown(wait=False) - state.capture_executor = None - else: - state.capture_engine.cleanup() - state.capture_engine = None - - # Release cached static image - state.static_image = None + # Release live stream + if state.live_stream: + try: + self._live_stream_manager.release(state.picture_source_id) + except Exception as e: + logger.warning(f"Error releasing live stream: {e}") + state.live_stream = None logger.info(f"Stopped processing for device {device_id}") async def _processing_loop(self, device_id: str): """Main processing loop for a device. - Uses resolved_* fields from stream resolution for display, FPS, - and postprocessing. Falls back to device settings for LED projection - parameters (border_width, interpolation_mode) and WLED brightness. + Reads frames from the LiveStream (which handles capture and optional + PP filters via the picture source chain). """ state = self._processors[device_id] settings = state.settings # Use resolved values (populated by _resolve_stream_settings) - display_index = state.resolved_display_index or settings.display_index target_fps = state.resolved_target_fps or settings.fps smoothing = settings.smoothing @@ -566,35 +473,13 @@ class ProcessorManager: border_width = settings.border_width wled_brightness = settings.brightness # WLED hardware brightness - # Instantiate filter objects once (not per-frame) - resolved_filters = state.resolved_filters or [] - image_pool = ImagePool() - state.image_pool = image_pool - filter_objects = [] - for fi in resolved_filters: - try: - filter_objects.append(FilterRegistry.create_instance(fi.filter_id, fi.options)) - except ValueError as e: - logger.warning(f"Skipping unknown filter '{fi.filter_id}': {e}") - state.filter_instances = filter_objects - logger.info( f"Processing loop started for {device_id} " - f"(display={display_index}, fps={target_fps}, filters={len(filter_objects)})" + f"(display={state.resolved_display_index}, fps={target_fps})" ) frame_time = 1.0 / target_fps fps_samples = [] - loop = asyncio.get_event_loop() - capture_executor = state.capture_executor # dedicated single-thread executor - - def _apply_filters(image): - """Apply all postprocessing filters to the captured image.""" - for f in filter_objects: - result = f.process_image(image, image_pool) - if result is not None: - image = result - return image try: while state.is_running: @@ -606,18 +491,8 @@ class ProcessorManager: continue try: - # Get frame: static image or live capture - if state.static_image is not None: - h, w = state.static_image.shape[:2] - capture = ScreenCapture( - image=state.static_image.copy(), width=w, height=h, display_index=-1 - ) - else: - capture = await loop.run_in_executor( - capture_executor, - state.capture_engine.capture_display, - display_index - ) + # Get frame from live stream (handles capture + PP filters) + capture = await asyncio.to_thread(state.live_stream.get_latest_frame) # Skip processing if no new frame (screen unchanged) if capture is None: @@ -626,10 +501,6 @@ class ProcessorManager: await asyncio.sleep(frame_time) continue - # Apply postprocessing filters to the full captured image - if filter_objects: - capture.image = await asyncio.to_thread(_apply_filters, capture.image) - # Extract border pixels border_pixels = await asyncio.to_thread(extract_border_pixels, capture, border_width) @@ -861,6 +732,9 @@ class ProcessorManager: except Exception as e: logger.error(f"Error stopping device {device_id}: {e}") + # Safety net: release any remaining managed live streams + self._live_stream_manager.release_all() + # Close shared HTTP client if self._http_client and not self._http_client.is_closed: await self._http_client.aclose() diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index a5108a5..cebecfd 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -11,13 +11,13 @@ from fastapi.staticfiles import StaticFiles from wled_controller import __version__ from wled_controller.api import router -from wled_controller.api.routes import init_dependencies +from wled_controller.api.dependencies import init_dependencies from wled_controller.config import get_config from wled_controller.core.processor_manager import ProcessorManager from wled_controller.storage import DeviceStore from wled_controller.storage.template_store import TemplateStore from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore -from wled_controller.storage.picture_stream_store import PictureStreamStore +from wled_controller.storage.picture_source_store import PictureSourceStore from wled_controller.utils import setup_logging, get_logger # Initialize logging @@ -31,61 +31,10 @@ config = get_config() device_store = DeviceStore(config.storage.devices_file) template_store = TemplateStore(config.storage.templates_file) pp_template_store = PostprocessingTemplateStore(config.storage.postprocessing_templates_file) -picture_stream_store = PictureStreamStore(config.storage.picture_streams_file) - -# Assign first available template to devices with missing/invalid template -all_templates = template_store.get_all_templates() -if all_templates: - valid_ids = {t.id for t in all_templates} - for device in device_store.get_all_devices(): - if not device.capture_template_id or device.capture_template_id not in valid_ids: - old_id = device.capture_template_id - device_store.update_device(device.id, capture_template_id=all_templates[0].id) - logger.info( - f"Assigned template '{all_templates[0].name}' to device '{device.name}' " - f"(was '{old_id}')" - ) - -# Migrate devices without picture_stream_id: create streams from legacy settings -for device in device_store.get_all_devices(): - if not device.picture_stream_id: - try: - # Create a raw stream from the device's current capture settings - raw_stream = picture_stream_store.create_stream( - name=f"{device.name} - Raw", - stream_type="raw", - display_index=device.settings.display_index, - capture_template_id=device.capture_template_id, - target_fps=device.settings.fps, - description=f"Auto-migrated from device '{device.name}'", - ) - - # Create a processed stream with the first PP template - pp_templates = pp_template_store.get_all_templates() - if pp_templates: - processed_stream = picture_stream_store.create_stream( - name=f"{device.name} - Processed", - stream_type="processed", - source_stream_id=raw_stream.id, - postprocessing_template_id=pp_templates[0].id, - description=f"Auto-migrated from device '{device.name}'", - ) - device_store.update_device(device.id, picture_stream_id=processed_stream.id) - logger.info( - f"Migrated device '{device.name}': created raw stream '{raw_stream.id}' " - f"+ processed stream '{processed_stream.id}'" - ) - else: - # No PP templates, assign raw stream directly - device_store.update_device(device.id, picture_stream_id=raw_stream.id) - logger.info( - f"Migrated device '{device.name}': created raw stream '{raw_stream.id}'" - ) - except Exception as e: - logger.error(f"Failed to migrate device '{device.name}': {e}") +picture_source_store = PictureSourceStore(config.storage.picture_sources_file) processor_manager = ProcessorManager( - picture_stream_store=picture_stream_store, + picture_source_store=picture_source_store, capture_template_store=template_store, pp_template_store=pp_template_store, ) @@ -124,7 +73,7 @@ async def lifespan(app: FastAPI): init_dependencies( device_store, template_store, processor_manager, pp_template_store=pp_template_store, - picture_stream_store=picture_stream_store, + picture_source_store=picture_source_store, ) # Load existing devices into processor manager @@ -137,8 +86,7 @@ async def lifespan(app: FastAPI): led_count=device.led_count, settings=device.settings, calibration=device.calibration, - capture_template_id=device.capture_template_id, - picture_stream_id=device.picture_stream_id, + picture_source_id=device.picture_source_id, ) logger.info(f"Loaded device: {device.name} ({device.id})") except Exception as e: diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 84ab5cb..9131fa2 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -91,7 +91,7 @@ function closeLightbox(event) { async function openFullImageLightbox(imageSource) { try { - const resp = await fetch(`${API_BASE}/picture-streams/full-image?source=${encodeURIComponent(imageSource)}`, { + const resp = await fetch(`${API_BASE}/picture-sources/full-image?source=${encodeURIComponent(imageSource)}`, { headers: getHeaders() }); if (!resp.ok) return; @@ -402,7 +402,7 @@ function updateAllText() { if (apiKey) { loadDisplays(); loadDevices(); - loadPictureStreams(); + loadPictureSources(); } } @@ -576,15 +576,11 @@ async function loadDisplays() { let _cachedDisplays = null; function switchTab(name) { - // Migrate legacy tab values from localStorage - if (name === 'templates' || name === 'pp-templates') { - name = 'streams'; - } document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name)); document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`)); localStorage.setItem('activeTab', name); if (name === 'streams') { - loadPictureStreams(); + loadPictureSources(); } } @@ -2532,7 +2528,7 @@ async function loadCaptureTemplates() { const data = await response.json(); _cachedCaptureTemplates = data.templates || []; // Re-render the streams tab which now contains template sections - renderPictureStreamsList(_cachedStreams); + renderPictureSourcesList(_cachedStreams); } catch (error) { console.error('Error loading capture templates:', error); } @@ -2540,7 +2536,7 @@ async function loadCaptureTemplates() { // Get engine icon function getEngineIcon(engineType) { - return '🖥️'; + return '🚀'; } // Show add template modal @@ -2786,7 +2782,7 @@ async function loadAvailableEngines() { availableEngines = data.engines || []; const select = document.getElementById('template-engine'); - select.innerHTML = ``; + select.innerHTML = ''; availableEngines.forEach(engine => { const option = document.createElement('option'); @@ -2798,6 +2794,12 @@ async function loadAvailableEngines() { } select.appendChild(option); }); + + // Auto-select first available engine if nothing selected + if (!select.value) { + const firstAvailable = availableEngines.find(e => e.available); + if (firstAvailable) select.value = firstAvailable.type; + } } catch (error) { console.error('Error loading engines:', error); showToast(t('templates.error.engines') + ': ' + error.message, 'error'); @@ -3085,14 +3087,14 @@ async function deleteTemplate(templateId) { } } -// ===== Picture Streams ===== +// ===== Picture Sources ===== let _cachedStreams = []; let _cachedPPTemplates = []; let _cachedCaptureTemplates = []; let _availableFilters = []; // Loaded from GET /filters -async function loadPictureStreams() { +async function loadPictureSources() { try { // Always fetch templates, filters, and streams in parallel // since templates are now rendered inside stream sub-tabs @@ -3100,7 +3102,7 @@ async function loadPictureStreams() { _availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null), fetchWithAuth('/postprocessing-templates'), fetchWithAuth('/capture-templates'), - fetchWithAuth('/picture-streams') + fetchWithAuth('/picture-sources') ]); if (filtersResp && filtersResp.ok) { @@ -3120,9 +3122,9 @@ async function loadPictureStreams() { } const data = await streamsResp.json(); _cachedStreams = data.streams || []; - renderPictureStreamsList(_cachedStreams); + renderPictureSourcesList(_cachedStreams); } catch (error) { - console.error('Error loading picture streams:', error); + console.error('Error loading picture sources:', error); document.getElementById('streams-list').innerHTML = `
${t('streams.error.load')}: ${error.message}
`; @@ -3139,7 +3141,7 @@ function switchStreamTab(tabKey) { localStorage.setItem('activeStreamTab', tabKey); } -function renderPictureStreamsList(streams) { +function renderPictureSourcesList(streams) { const container = document.getElementById('streams-list'); const activeTab = localStorage.getItem('activeStreamTab') || 'raw'; @@ -3157,7 +3159,7 @@ function renderPictureStreamsList(streams) { detailsHtml = `
🖥️ ${stream.display_index ?? 0} ⚡ ${stream.target_fps ?? 30} - ${capTmplName ? `📷 ${capTmplName}` : ''} + ${capTmplName ? `📋 ${capTmplName}` : ''}
`; } else if (stream.stream_type === 'processed') { const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id); @@ -3169,7 +3171,7 @@ function renderPictureStreamsList(streams) { } detailsHtml = `
📺 ${sourceName} - ${ppTmplName ? `🎨 ${ppTmplName}` : ''} + ${ppTmplName ? `📋 ${ppTmplName}` : ''}
`; } else if (stream.stream_type === 'static_image') { const src = stream.image_source || ''; @@ -3208,12 +3210,12 @@ function renderPictureStreamsList(streams) {
- ${engineIcon} ${escapeHtml(template.name)} + 📋 ${escapeHtml(template.name)}
${template.description ? `
${escapeHtml(template.description)}
` : ''}
- ⚙️ ${template.engine_type.toUpperCase()} + 🚀 ${template.engine_type.toUpperCase()} ${configEntries.length > 0 ? `🔧 ${configEntries.length}` : ''}
${configEntries.length > 0 ? ` @@ -3252,7 +3254,7 @@ function renderPictureStreamsList(streams) {
- 🎨 ${escapeHtml(tmpl.name)} + 📋 ${escapeHtml(tmpl.name)}
${tmpl.description ? `
${escapeHtml(tmpl.description)}
` : ''} @@ -3390,7 +3392,7 @@ async function showAddStreamModal(presetType) { async function editStream(streamId) { try { - const response = await fetchWithAuth(`/picture-streams/${streamId}`); + const response = await fetchWithAuth(`/picture-sources/${streamId}`); if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`); const stream = await response.json(); @@ -3450,7 +3452,7 @@ async function populateStreamModalDropdowns() { const [displaysRes, captureTemplatesRes, streamsRes, ppTemplatesRes] = await Promise.all([ fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }), fetchWithAuth('/capture-templates'), - fetchWithAuth('/picture-streams'), + fetchWithAuth('/picture-sources'), fetchWithAuth('/postprocessing-templates'), ]); @@ -3556,12 +3558,12 @@ async function saveStream() { try { let response; if (streamId) { - response = await fetchWithAuth(`/picture-streams/${streamId}`, { + response = await fetchWithAuth(`/picture-sources/${streamId}`, { method: 'PUT', body: JSON.stringify(payload) }); } else { - response = await fetchWithAuth('/picture-streams', { + response = await fetchWithAuth('/picture-sources', { method: 'POST', body: JSON.stringify(payload) }); @@ -3574,7 +3576,7 @@ async function saveStream() { showToast(streamId ? t('streams.updated') : t('streams.created'), 'success'); closeStreamModal(); - await loadPictureStreams(); + await loadPictureSources(); } catch (error) { console.error('Error saving stream:', error); errorEl.textContent = error.message; @@ -3587,7 +3589,7 @@ async function deleteStream(streamId) { if (!confirmed) return; try { - const response = await fetchWithAuth(`/picture-streams/${streamId}`, { + const response = await fetchWithAuth(`/picture-sources/${streamId}`, { method: 'DELETE' }); @@ -3597,7 +3599,7 @@ async function deleteStream(streamId) { } showToast(t('streams.deleted'), 'success'); - await loadPictureStreams(); + await loadPictureSources(); } catch (error) { console.error('Error deleting stream:', error); showToast(t('streams.error.delete') + ': ' + error.message, 'error'); @@ -3635,7 +3637,7 @@ async function validateStaticImage() { previewContainer.style.display = 'none'; try { - const response = await fetchWithAuth('/picture-streams/validate-image', { + const response = await fetchWithAuth('/picture-sources/validate-image', { method: 'POST', body: JSON.stringify({ image_source: source }), }); @@ -3662,7 +3664,7 @@ async function validateStaticImage() { } } -// ===== Picture Stream Test ===== +// ===== Picture Source Test ===== let _currentTestStreamId = null; @@ -3701,7 +3703,7 @@ async function runStreamTest() { showOverlaySpinner(t('streams.test.running'), captureDuration); try { - const response = await fetchWithAuth(`/picture-streams/${_currentTestStreamId}/test`, { + const response = await fetchWithAuth(`/picture-sources/${_currentTestStreamId}/test`, { method: 'POST', body: JSON.stringify({ capture_duration: captureDuration }) }); @@ -3740,7 +3742,7 @@ async function showTestPPTemplateModal(templateId) { // Ensure streams are cached if (_cachedStreams.length === 0) { try { - const resp = await fetchWithAuth('/picture-streams'); + const resp = await fetchWithAuth('/picture-sources'); if (resp.ok) { const d = await resp.json(); _cachedStreams = d.streams || []; } } catch (e) { console.warn('Could not load streams for PP test:', e); } } @@ -3842,7 +3844,7 @@ async function loadPPTemplates() { const data = await response.json(); _cachedPPTemplates = data.templates || []; // Re-render the streams tab which now contains template sections - renderPictureStreamsList(_cachedStreams); + renderPictureSourcesList(_cachedStreams); } catch (error) { console.error('Error loading PP templates:', error); } @@ -4163,7 +4165,7 @@ async function showStreamSelector(deviceId) { try { const [deviceResponse, streamsResponse, settingsResponse] = await Promise.all([ fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }), - fetchWithAuth('/picture-streams'), + fetchWithAuth('/picture-sources'), fetch(`${API_BASE}/devices/${deviceId}/settings`, { headers: getHeaders() }), ]); @@ -4195,7 +4197,7 @@ async function showStreamSelector(deviceId) { }); } - const currentStreamId = device.picture_stream_id || ''; + const currentStreamId = device.picture_source_id || ''; streamSelect.value = currentStreamId; // Populate LED projection fields @@ -4238,7 +4240,7 @@ async function updateStreamSelectorInfo(streamId) { } try { - const response = await fetchWithAuth(`/picture-streams/${streamId}`); + const response = await fetchWithAuth(`/picture-sources/${streamId}`); if (!response.ok) { infoPanel.style.display = 'none'; return; @@ -4266,12 +4268,12 @@ async function updateStreamSelectorInfo(streamId) { propsHtml = ` 🖥️ ${stream.display_index ?? 0} ⚡ ${stream.target_fps ?? 30} - ${capTmplName ? `📷 ${capTmplName}` : ''} + ${capTmplName ? `📋 ${capTmplName}` : ''} `; } else if (stream.stream_type === 'processed') { if ((!_cachedStreams || _cachedStreams.length === 0) && stream.source_stream_id) { try { - const streamsResp = await fetchWithAuth('/picture-streams'); + const streamsResp = await fetchWithAuth('/picture-sources'); if (streamsResp.ok) { const d = await streamsResp.json(); _cachedStreams = d.streams || []; } } catch {} } @@ -4292,7 +4294,7 @@ async function updateStreamSelectorInfo(streamId) { } propsHtml = ` 📺 ${sourceName} - ${ppTmplName ? `🎨 ${ppTmplName}` : ''} + ${ppTmplName ? `📋 ${ppTmplName}` : ''} `; } else if (stream.stream_type === 'static_image') { const src = stream.image_source || ''; @@ -4313,18 +4315,18 @@ async function updateStreamSelectorInfo(streamId) { async function saveStreamSelector() { const deviceId = document.getElementById('stream-selector-device-id').value; - const pictureStreamId = document.getElementById('stream-selector-stream').value; + const pictureSourceId = document.getElementById('stream-selector-stream').value; const borderWidth = parseInt(document.getElementById('stream-selector-border-width').value) || 10; const interpolation = document.getElementById('stream-selector-interpolation').value; const smoothing = parseFloat(document.getElementById('stream-selector-smoothing').value); const errorEl = document.getElementById('stream-selector-error'); try { - // Save picture stream assignment + // Save picture source assignment const response = await fetch(`${API_BASE}/devices/${deviceId}`, { method: 'PUT', headers: getHeaders(), - body: JSON.stringify({ picture_stream_id: pictureStreamId }) + body: JSON.stringify({ picture_source_id: pictureSourceId }) }); if (response.status === 401) { diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 4bc5f48..cc53247 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -36,7 +36,7 @@
- +
@@ -228,11 +228,11 @@
- + - +