Refactor capture engine architecture, rename PictureStream to PictureSource, and split API modules
- Separate CaptureEngine into stateless factory + stateful CaptureStream session - Add LiveStream/LiveStreamManager for shared capture with reference counting - Rename PictureStream to PictureSource across storage, API, and UI - Remove legacy migration logic and unused compatibility code - Split monolithic routes.py (1935 lines) into 5 focused route modules - Split schemas.py (480 lines) into 7 schema modules with re-exports - Extract dependency injection into dedicated dependencies.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
410
server/src/wled_controller/api/routes/templates.py
Normal file
410
server/src/wled_controller/api/routes/templates.py
Normal file
@@ -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))
|
||||
Reference in New Issue
Block a user