From 493f14fba9193f585a7efe886001a52a8f97a276 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 11 Feb 2026 00:00:30 +0300 Subject: [PATCH] Add Picture Streams architecture with postprocessing templates and stream test UI Introduce Picture Stream abstraction that separates the capture pipeline into composable layers: raw streams (display + capture engine + FPS) and processed streams (source stream + postprocessing template). Devices reference a picture stream instead of managing individual capture settings. - Add PictureStream and PostprocessingTemplate data models and stores - Add CRUD API endpoints for picture streams and postprocessing templates - Add stream chain resolution in ProcessorManager for start_processing - Add picture stream test endpoint with postprocessing preview support - Add Stream Settings modal with border_width and interpolation_mode controls - Add stream test modal with capture preview and performance metrics - Add full frontend: Picture Streams tab, Processing Templates tab, stream selector on device cards, test buttons on stream cards - Add localization keys for all new features (en, ru) - Migrate existing devices to picture streams on startup Co-Authored-By: Claude Opus 4.6 --- server/src/wled_controller/api/routes.py | 530 +++++++++++- server/src/wled_controller/api/schemas.py | 110 ++- server/src/wled_controller/config.py | 2 + .../core/capture_engines/base.py | 1 + .../core/capture_engines/dxcam_engine.py | 1 + .../core/capture_engines/factory.py | 22 +- .../core/capture_engines/mss_engine.py | 1 + .../core/capture_engines/wgc_engine.py | 1 + .../wled_controller/core/processor_manager.py | 175 +++- server/src/wled_controller/main.py | 69 +- server/src/wled_controller/static/app.js | 816 +++++++++++++++++- server/src/wled_controller/static/index.html | 258 +++++- .../wled_controller/static/locales/en.json | 69 +- .../wled_controller/static/locales/ru.json | 69 +- server/src/wled_controller/static/style.css | 55 +- .../src/wled_controller/storage/__init__.py | 4 +- .../wled_controller/storage/device_store.py | 23 +- .../wled_controller/storage/picture_stream.py | 69 ++ .../storage/picture_stream_store.py | 332 +++++++ .../storage/postprocessing_template.py | 53 ++ .../storage/postprocessing_template_store.py | 230 +++++ .../src/wled_controller/storage/template.py | 3 - .../wled_controller/storage/template_store.py | 80 +- 23 files changed, 2773 insertions(+), 200 deletions(-) create mode 100644 server/src/wled_controller/storage/picture_stream.py create mode 100644 server/src/wled_controller/storage/picture_stream_store.py create mode 100644 server/src/wled_controller/storage/postprocessing_template.py create mode 100644 server/src/wled_controller/storage/postprocessing_template_store.py diff --git a/server/src/wled_controller/api/routes.py b/server/src/wled_controller/api/routes.py index 9fc93f3..7e06bd9 100644 --- a/server/src/wled_controller/api/routes.py +++ b/server/src/wled_controller/api/routes.py @@ -40,6 +40,15 @@ from wled_controller.api.schemas import ( CaptureImage, BorderExtraction, PerformanceMetrics, + PostprocessingTemplateCreate, + PostprocessingTemplateUpdate, + PostprocessingTemplateResponse, + PostprocessingTemplateListResponse, + PictureStreamCreate, + PictureStreamUpdate, + PictureStreamResponse, + PictureStreamListResponse, + PictureStreamTestRequest, ) from wled_controller.config import get_config from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings @@ -49,6 +58,8 @@ from wled_controller.core.calibration import ( ) 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.utils import get_logger from wled_controller.core.screen_capture import get_available_displays @@ -60,6 +71,8 @@ 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 @@ -77,6 +90,20 @@ def get_template_store() -> TemplateStore: 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: @@ -88,12 +115,16 @@ 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 + 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"]) @@ -230,7 +261,10 @@ async def create_device( if all_templates: capture_template_id = all_templates[0].id else: - capture_template_id = "tpl_mss_default" + 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( @@ -260,11 +294,13 @@ async def create_device( 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, 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, ) @@ -300,6 +336,7 @@ async def list_devices( ), 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, ) @@ -359,8 +396,12 @@ async def update_device( ): """Update device information.""" try: - # Check if template changed and device is processing (for hot-swap) + # 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 @@ -374,16 +415,17 @@ async def update_device( url=update_data.url, enabled=update_data.enabled, capture_template_id=update_data.capture_template_id, + picture_stream_id=update_data.picture_stream_id, ) - # Hot-swap: If template changed and device was processing, restart it - if template_changed and was_processing: - logger.info(f"Hot-swapping template for device {device_id}") + # Hot-swap: If stream/template changed and device was processing, restart it + if (stream_changed or template_changed) and was_processing: + logger.info(f"Hot-swapping stream/template for device {device_id}") try: # Stop current processing await manager.stop_processing(device_id) - # Update processor with new template + # Update processor with new settings manager.remove_device(device_id) manager.add_device( device_id=device.id, @@ -392,11 +434,12 @@ async def update_device( settings=device.settings, calibration=device.calibration, capture_template_id=device.capture_template_id, + picture_stream_id=device.picture_stream_id, ) # Restart processing await manager.start_processing(device_id) - logger.info(f"Successfully hot-swapped template for device {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}") @@ -413,11 +456,13 @@ async def update_device( 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, 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, ) @@ -540,6 +585,7 @@ async def get_settings( 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, state_check_interval=device.settings.state_check_interval, ) @@ -553,16 +599,28 @@ async def update_settings( store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), ): - """Update processing settings for a device.""" + """Update processing settings for a device. + + Merges with existing settings so callers can send partial updates. + """ try: - # Create ProcessingSettings from schema + # 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 + + # Merge: use new values where provided, keep existing otherwise new_settings = ProcessingSettings( display_index=settings.display_index, fps=settings.fps, border_width=settings.border_width, - brightness=settings.color_correction.brightness if settings.color_correction else 1.0, - gamma=settings.color_correction.gamma if settings.color_correction else 2.2, - saturation=settings.color_correction.saturation if settings.color_correction else 1.0, + interpolation_mode=settings.interpolation_mode, + brightness=settings.color_correction.brightness if settings.color_correction else existing.brightness, + gamma=settings.color_correction.gamma if settings.color_correction else existing.gamma, + saturation=settings.color_correction.saturation if settings.color_correction else existing.saturation, + smoothing=existing.smoothing, state_check_interval=settings.state_check_interval, ) @@ -580,6 +638,7 @@ async def update_settings( 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, state_check_interval=device.settings.state_check_interval, ) @@ -736,7 +795,7 @@ async def list_templates( name=t.name, engine_type=t.engine_type, engine_config=t.engine_config, - is_default=t.is_default, + created_at=t.created_at, updated_at=t.updated_at, description=t.description, @@ -774,7 +833,7 @@ async def create_template( name=template.name, engine_type=template.engine_type, engine_config=template.engine_config, - is_default=template.is_default, + created_at=template.created_at, updated_at=template.updated_at, description=template.description, @@ -804,7 +863,6 @@ async def get_template( name=template.name, engine_type=template.engine_type, engine_config=template.engine_config, - is_default=template.is_default, created_at=template.created_at, updated_at=template.updated_at, description=template.description, @@ -833,7 +891,7 @@ async def update_template( name=template.name, engine_type=template.engine_type, engine_config=template.engine_config, - is_default=template.is_default, + created_at=template.created_at, updated_at=template.updated_at, description=template.description, @@ -1038,3 +1096,441 @@ async def test_template( engine.cleanup() except Exception as e: logger.error(f"Error cleaning up test engine: {e}") + + +# ===== 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, + gamma=t.gamma, + saturation=t.saturation, + brightness=t.brightness, + smoothing=t.smoothing, + 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: + template = store.create_template( + name=data.name, + gamma=data.gamma, + saturation=data.saturation, + brightness=data.brightness, + smoothing=data.smoothing, + 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: + template = store.update_template( + template_id=template_id, + name=data.name, + gamma=data.gamma, + saturation=data.saturation, + brightness=data.brightness, + smoothing=data.smoothing, + 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)) + + +# ===== 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, + 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", 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, + 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, + 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"] + + # Get capture template from raw stream + 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 + + # Validate engine + 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", + ) + + # Check display lock + 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.", + ) + + # Create engine and run test + 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 + + 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") + + # Convert 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 + 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 to preview if this is a processed stream + pp_template_ids = chain["postprocessing_template_ids"] + if pp_template_ids: + try: + pp = pp_store.get_template(pp_template_ids[0]) + img_array = np.array(thumbnail, dtype=np.float32) / 255.0 + + if pp.brightness != 1.0: + img_array *= pp.brightness + + if pp.saturation != 1.0: + luminance = np.dot(img_array[..., :3], [0.299, 0.587, 0.114]) + luminance = luminance[..., np.newaxis] + img_array[..., :3] = luminance + (img_array[..., :3] - luminance) * pp.saturation + + if pp.gamma != 1.0: + img_array = np.power(np.clip(img_array, 0, 1), 1.0 / pp.gamma) + + img_array = np.clip(img_array * 255.0, 0, 255).astype(np.uint8) + thumbnail = Image.fromarray(img_array) + except ValueError: + logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview") + + img_buffer = io.BytesIO() + thumbnail.save(img_buffer, format='JPEG', quality=85) + img_buffer.seek(0) + full_capture_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8') + full_capture_data_uri = f"data:image/jpeg;base64,{full_capture_b64}" + + actual_fps = frame_count / actual_duration if actual_duration > 0 else 0 + avg_capture_time_ms = (total_capture_time / frame_count * 1000) if frame_count > 0 else 0 + width, height = pil_image.size + + return TemplateTestResponse( + full_capture=CaptureImage( + image=full_capture_data_uri, + width=width, + height=height, + thumbnail_width=thumbnail_width, + thumbnail_height=thumbnail_height, + ), + border_extraction=None, + performance=PerformanceMetrics( + capture_duration_s=actual_duration, + frame_count=frame_count, + actual_fps=actual_fps, + avg_capture_time_ms=avg_capture_time_ms, + ), + ) + + except 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/schemas.py b/server/src/wled_controller/api/schemas.py index 42cfa45..9c1675a 100644 --- a/server/src/wled_controller/api/schemas.py +++ b/server/src/wled_controller/api/schemas.py @@ -64,7 +64,8 @@ class DeviceUpdate(BaseModel): 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") + 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): @@ -81,6 +82,7 @@ class ProcessingSettings(BaseModel): 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) state_check_interval: int = Field( default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600, @@ -155,7 +157,8 @@ class DeviceResponse(BaseModel): ) 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") + 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") @@ -242,7 +245,6 @@ class TemplateResponse(BaseModel): name: str = Field(description="Template name") engine_type: str = Field(description="Engine type identifier") engine_config: Dict = Field(description="Engine-specific configuration") - is_default: bool = Field(description="Whether this is a system default template") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") description: Optional[str] = Field(None, description="Template description") @@ -321,3 +323,105 @@ class TemplateTestResponse(BaseModel): 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") + + +# Postprocessing Template Schemas + +class PostprocessingTemplateCreate(BaseModel): + """Request to create a postprocessing template.""" + + name: str = Field(description="Template name", min_length=1, max_length=100) + 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) + smoothing: float = Field(default=0.3, description="Temporal smoothing factor", ge=0.0, le=1.0) + 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) + gamma: Optional[float] = Field(None, description="Gamma correction", ge=0.1, le=5.0) + saturation: Optional[float] = Field(None, description="Saturation multiplier", ge=0.0, le=2.0) + brightness: Optional[float] = Field(None, description="Brightness multiplier", ge=0.0, le=1.0) + smoothing: Optional[float] = Field(None, description="Temporal smoothing factor", ge=0.0, le=1.0) + 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") + gamma: float = Field(description="Gamma correction") + saturation: float = Field(description="Saturation multiplier") + brightness: float = Field(description="Brightness multiplier") + smoothing: float = Field(description="Temporal smoothing factor") + 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"] = 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)") + 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)") + 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 or processed)") + 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") + 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") diff --git a/server/src/wled_controller/config.py b/server/src/wled_controller/config.py index 4cff52f..8ef7092 100644 --- a/server/src/wled_controller/config.py +++ b/server/src/wled_controller/config.py @@ -55,6 +55,8 @@ 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" class LoggingConfig(BaseSettings): diff --git a/server/src/wled_controller/core/capture_engines/base.py b/server/src/wled_controller/core/capture_engines/base.py index 8827735..490bc7b 100644 --- a/server/src/wled_controller/core/capture_engines/base.py +++ b/server/src/wled_controller/core/capture_engines/base.py @@ -39,6 +39,7 @@ class CaptureEngine(ABC): """ 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. 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 2025ef4..a40bd50 100644 --- a/server/src/wled_controller/core/capture_engines/dxcam_engine.py +++ b/server/src/wled_controller/core/capture_engines/dxcam_engine.py @@ -27,6 +27,7 @@ class DXcamEngine(CaptureEngine): """ ENGINE_TYPE = "dxcam" + ENGINE_PRIORITY = 3 def __init__(self, config: Dict[str, Any]): """Initialize DXcam engine.""" diff --git a/server/src/wled_controller/core/capture_engines/factory.py b/server/src/wled_controller/core/capture_engines/factory.py index 9cb063e..cf7d048 100644 --- a/server/src/wled_controller/core/capture_engines/factory.py +++ b/server/src/wled_controller/core/capture_engines/factory.py @@ -1,6 +1,6 @@ """Engine registry and factory for screen capture engines.""" -from typing import Any, Dict, List, Type +from typing import Any, Dict, List, Optional, Type from wled_controller.core.capture_engines.base import CaptureEngine from wled_controller.utils import get_logger @@ -85,6 +85,26 @@ class EngineRegistry: return available + @classmethod + def get_best_available_engine(cls) -> Optional[str]: + """Get the highest-priority available engine type. + + Returns: + Engine type string, or None if no engines are available. + """ + best_type = None + best_priority = -1 + for engine_type, engine_class in cls._engines.items(): + try: + if engine_class.is_available() and engine_class.ENGINE_PRIORITY > best_priority: + best_priority = engine_class.ENGINE_PRIORITY + best_type = engine_type + except Exception as e: + logger.error( + f"Error checking availability for engine '{engine_type}': {e}" + ) + return best_type + @classmethod def get_all_engines(cls) -> Dict[str, Type[CaptureEngine]]: """Get all registered engines (available or not). 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 1187f33..30bff53 100644 --- a/server/src/wled_controller/core/capture_engines/mss_engine.py +++ b/server/src/wled_controller/core/capture_engines/mss_engine.py @@ -26,6 +26,7 @@ class MSSEngine(CaptureEngine): """ ENGINE_TYPE = "mss" + ENGINE_PRIORITY = 1 def __init__(self, config: Dict[str, Any]): """Initialize MSS engine. 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 b498b92..35ccbc3 100644 --- a/server/src/wled_controller/core/capture_engines/wgc_engine.py +++ b/server/src/wled_controller/core/capture_engines/wgc_engine.py @@ -34,6 +34,7 @@ class WGCEngine(CaptureEngine): """ ENGINE_TYPE = "wgc" + ENGINE_PRIORITY = 2 def __init__(self, config: Dict[str, Any]): """Initialize WGC engine. diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py index 1219165..df34132 100644 --- a/server/src/wled_controller/core/processor_manager.py +++ b/server/src/wled_controller/core/processor_manager.py @@ -88,10 +88,11 @@ class ProcessorState: led_count: int settings: ProcessingSettings calibration: CalibrationConfig - capture_template_id: str = "tpl_mss_default" # NEW: template ID for capture engine + capture_template_id: str = "" + picture_stream_id: str = "" wled_client: Optional[WLEDClient] = None pixel_mapper: Optional[PixelMapper] = None - capture_engine: Optional[CaptureEngine] = None # NEW: initialized capture engine + capture_engine: Optional[CaptureEngine] = None is_running: bool = False task: Optional[asyncio.Task] = None metrics: ProcessingMetrics = field(default_factory=ProcessingMetrics) @@ -100,16 +101,34 @@ class ProcessorState: test_mode_active: bool = False test_mode_edges: Dict[str, Tuple[int, int, int]] = field(default_factory=dict) health_task: Optional[asyncio.Task] = None + # Resolved stream values (populated at start_processing time) + resolved_display_index: Optional[int] = None + resolved_target_fps: Optional[int] = None + resolved_engine_type: Optional[str] = None + resolved_engine_config: Optional[dict] = None + resolved_gamma: Optional[float] = None + resolved_saturation: Optional[float] = None + resolved_brightness: Optional[float] = None + resolved_smoothing: Optional[float] = None class ProcessorManager: """Manages screen processing for multiple WLED devices.""" - def __init__(self): - """Initialize processor manager.""" + def __init__(self, picture_stream_store=None, capture_template_store=None, pp_template_store=None): + """Initialize processor manager. + + Args: + picture_stream_store: PictureStreamStore 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._capture_template_store = capture_template_store + self._pp_template_store = pp_template_store logger.info("Processor manager initialized") async def _get_http_client(self) -> httpx.AsyncClient: @@ -125,7 +144,8 @@ class ProcessorManager: led_count: int, settings: Optional[ProcessingSettings] = None, calibration: Optional[CalibrationConfig] = None, - capture_template_id: str = "tpl_mss_default", + capture_template_id: str = "", + picture_stream_id: str = "", ): """Add a device for processing. @@ -135,7 +155,8 @@ class ProcessorManager: led_count: Number of LEDs settings: Processing settings (uses defaults if None) calibration: Calibration config (creates default if None) - capture_template_id: Template ID for screen capture engine + capture_template_id: Legacy template ID for screen capture engine + picture_stream_id: Picture stream ID (preferred over capture_template_id) """ if device_id in self._processors: raise ValueError(f"Device {device_id} already exists") @@ -153,6 +174,7 @@ class ProcessorManager: settings=settings, calibration=calibration, capture_template_id=capture_template_id, + picture_stream_id=picture_stream_id, ) self._processors[device_id] = state @@ -245,9 +267,81 @@ 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. + + 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. + """ + 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"] + + state.resolved_display_index = raw_stream.display_index + state.resolved_target_fps = raw_stream.target_fps + + # 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 = {} + + # 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_gamma = pp.gamma + state.resolved_saturation = pp.saturation + state.resolved_brightness = pp.brightness + state.resolved_smoothing = pp.smoothing + except ValueError: + logger.warning(f"PP template {pp_template_ids[0]} not found, using defaults") + + 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") + + # Fallback: use legacy device settings + state.resolved_display_index = state.settings.display_index + state.resolved_target_fps = state.settings.fps + state.resolved_gamma = state.settings.gamma + state.resolved_saturation = state.settings.saturation + state.resolved_brightness = state.settings.brightness + state.resolved_smoothing = state.settings.smoothing + + # 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 = {} + 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. + Args: device_id: Device identifier @@ -263,9 +357,11 @@ class ProcessorManager: if state.is_running: raise RuntimeError(f"Processing already running for device {device_id}") + # Resolve stream settings + self._resolve_stream_settings(state) + # Connect to WLED device try: - # Enable DDP for large LED counts (>500 LEDs) use_ddp = state.led_count > 500 state.wled_client = WLEDClient(state.device_url, use_ddp=use_ddp) await state.wled_client.connect() @@ -276,17 +372,16 @@ 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 - # Phase 2: Use MSS engine for all devices (template integration in Phase 5) + # Initialize capture engine from resolved settings try: - # For now, always use MSS engine (Phase 5 will load from template) - engine = EngineRegistry.create_engine("mss", {}) + engine_type = state.resolved_engine_type or "mss" + engine_config = state.resolved_engine_config or {} + engine = EngineRegistry.create_engine(engine_type, engine_config) engine.initialize() state.capture_engine = engine - logger.debug(f"Initialized capture engine for device {device_id}: mss") + 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}") - # Cleanup WLED client before raising if state.wled_client: await state.wled_client.disconnect() raise RuntimeError(f"Failed to initialize capture engine: {e}") @@ -352,18 +447,31 @@ class ProcessorManager: async def _processing_loop(self, device_id: str): """Main processing loop for a device. - Args: - device_id: Device identifier + 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. """ 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 + gamma = state.resolved_gamma if state.resolved_gamma is not None else settings.gamma + saturation = state.resolved_saturation if state.resolved_saturation is not None else settings.saturation + pp_brightness = state.resolved_brightness if state.resolved_brightness is not None else settings.brightness + smoothing = state.resolved_smoothing if state.resolved_smoothing is not None else settings.smoothing + + # These always come from device settings (LED projection) + border_width = settings.border_width + wled_brightness = settings.brightness # WLED hardware brightness + logger.info( f"Processing loop started for {device_id} " - f"(display={settings.display_index}, fps={settings.fps})" + f"(display={display_index}, fps={target_fps})" ) - frame_time = 1.0 / settings.fps + frame_time = 1.0 / target_fps fps_samples = [] try: @@ -376,39 +484,38 @@ class ProcessorManager: continue try: - # Run blocking operations in thread pool to avoid blocking event loop - # Capture screen using engine (blocking I/O) + # Capture screen using engine capture = await asyncio.to_thread( state.capture_engine.capture_display, - settings.display_index + display_index ) - # Extract border pixels (CPU-intensive) - border_pixels = await asyncio.to_thread(extract_border_pixels, capture, settings.border_width) + # Extract border pixels + border_pixels = await asyncio.to_thread(extract_border_pixels, capture, border_width) - # Map to LED colors (CPU-intensive) + # Map to LED colors led_colors = await asyncio.to_thread(state.pixel_mapper.map_border_to_leds, border_pixels) - # Apply color correction (CPU-intensive) + # Apply color correction from postprocessing led_colors = await asyncio.to_thread( apply_color_correction, led_colors, - gamma=settings.gamma, - saturation=settings.saturation, - brightness=settings.brightness, + gamma=gamma, + saturation=saturation, + brightness=pp_brightness, ) - # Apply smoothing (CPU-intensive) - if state.previous_colors and settings.smoothing > 0: + # Apply smoothing from postprocessing + if state.previous_colors and smoothing > 0: led_colors = await asyncio.to_thread( smooth_colors, led_colors, state.previous_colors, - settings.smoothing, + smoothing, ) - # Send to WLED with brightness - brightness_value = int(settings.brightness * 255) + # Send to WLED with device brightness + brightness_value = int(wled_brightness * 255) await state.wled_client.send_pixels(led_colors, brightness=brightness_value) # Update metrics @@ -468,8 +575,8 @@ class ProcessorManager: "device_id": device_id, "processing": state.is_running, "fps_actual": metrics.fps_actual if state.is_running else None, - "fps_target": state.settings.fps, - "display_index": state.settings.display_index, + "fps_target": state.resolved_target_fps or state.settings.fps, + "display_index": state.resolved_display_index if state.resolved_display_index is not None else state.settings.display_index, "last_update": metrics.last_update, "errors": [metrics.last_error] if metrics.last_error else [], "wled_online": h.online, diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index 36325f3..5821e42 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -16,6 +16,8 @@ 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.utils import setup_logging, get_logger # Initialize logging @@ -28,7 +30,65 @@ config = get_config() # Initialize storage and processing device_store = DeviceStore(config.storage.devices_file) template_store = TemplateStore(config.storage.templates_file) -processor_manager = ProcessorManager() +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}") + +processor_manager = ProcessorManager( + picture_stream_store=picture_stream_store, + capture_template_store=template_store, + pp_template_store=pp_template_store, +) @asynccontextmanager @@ -61,7 +121,11 @@ async def lifespan(app: FastAPI): logger.info("All API requests require valid Bearer token authentication") # Initialize API dependencies - init_dependencies(device_store, template_store, processor_manager) + init_dependencies( + device_store, template_store, processor_manager, + pp_template_store=pp_template_store, + picture_stream_store=picture_stream_store, + ) # Load existing devices into processor manager devices = device_store.get_all_devices() @@ -74,6 +138,7 @@ async def lifespan(app: FastAPI): settings=device.settings, calibration=device.calibration, capture_template_id=device.capture_template_id, + picture_stream_id=device.picture_stream_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 8a9984d..273c778 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -347,6 +347,12 @@ function switchTab(name) { if (name === 'templates') { loadCaptureTemplates(); } + if (name === 'streams') { + loadPictureStreams(); + } + if (name === 'pp-templates') { + loadPPTemplates(); + } } function initTabs() { @@ -625,8 +631,8 @@ function createDeviceCard(device) { - - ` : ''} +
${engineIcon} ${escapeHtml(template.name)}
- ${defaultBadge}
${t('templates.engine')} ${template.engine_type.toUpperCase()} @@ -2401,22 +2392,15 @@ function renderTemplatesList(templates) { - ${!template.is_default ? ` - - ` : ''} +
`; }; - let html = defaultTemplates.map(renderCard).join(''); - - if (customTemplates.length > 0) { - html += `
${t('templates.custom')}
`; - html += customTemplates.map(renderCard).join(''); - } + let html = templates.map(renderCard).join(''); html += `
+
@@ -2978,3 +2962,773 @@ async function deleteTemplate(templateId) { showToast(t('templates.error.delete') + ': ' + error.message, 'error'); } } + +// ===== Picture Streams ===== + +let _cachedStreams = []; +let _cachedPPTemplates = []; + +async function loadPictureStreams() { + try { + const response = await fetchWithAuth('/picture-streams'); + if (!response.ok) { + throw new Error(`Failed to load streams: ${response.status}`); + } + const data = await response.json(); + _cachedStreams = data.streams || []; + renderPictureStreamsList(_cachedStreams); + } catch (error) { + console.error('Error loading picture streams:', error); + document.getElementById('streams-list').innerHTML = ` +
${t('streams.error.load')}: ${error.message}
+ `; + } +} + +function renderPictureStreamsList(streams) { + const container = document.getElementById('streams-list'); + + if (streams.length === 0) { + container.innerHTML = `
+
+
+
${t('streams.add')}
+
`; + return; + } + + const renderCard = (stream) => { + const typeIcon = stream.stream_type === 'raw' ? '📷' : '🎨'; + const typeBadge = stream.stream_type === 'raw' + ? `${t('streams.type.raw')}` + : `${t('streams.type.processed')}`; + + let detailsHtml = ''; + if (stream.stream_type === 'raw') { + detailsHtml = ` +
+ ${t('streams.display')} ${stream.display_index ?? 0} +
+
+ ${t('streams.target_fps')} ${stream.target_fps ?? 30} +
+ `; + } else { + // Find source stream name and PP template name + const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id); + const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-'); + detailsHtml = ` +
+ ${t('streams.source')} ${sourceName} +
+ `; + } + + return ` +
+ +
+
+ ${typeIcon} ${escapeHtml(stream.name)} +
+ ${typeBadge} +
+ ${detailsHtml} + ${stream.description ? `
${escapeHtml(stream.description)}
` : ''} +
+ + +
+
+ `; + }; + + let html = streams.map(renderCard).join(''); + html += `
+
+
+
${t('streams.add')}
+
`; + + container.innerHTML = html; +} + +function onStreamTypeChange() { + const streamType = document.getElementById('stream-type').value; + document.getElementById('stream-raw-fields').style.display = streamType === 'raw' ? '' : 'none'; + document.getElementById('stream-processed-fields').style.display = streamType === 'processed' ? '' : 'none'; +} + +async function showAddStreamModal() { + document.getElementById('stream-modal-title').textContent = t('streams.add'); + document.getElementById('stream-form').reset(); + document.getElementById('stream-id').value = ''; + document.getElementById('stream-error').style.display = 'none'; + document.getElementById('stream-type').disabled = false; + + // Reset to raw type + document.getElementById('stream-type').value = 'raw'; + onStreamTypeChange(); + + // Populate dropdowns + await populateStreamModalDropdowns(); + + const modal = document.getElementById('stream-modal'); + modal.style.display = 'flex'; + lockBody(); + modal.onclick = (e) => { if (e.target === modal) closeStreamModal(); }; +} + +async function editStream(streamId) { + try { + const response = await fetchWithAuth(`/picture-streams/${streamId}`); + if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`); + const stream = await response.json(); + + document.getElementById('stream-modal-title').textContent = t('streams.edit'); + document.getElementById('stream-id').value = streamId; + document.getElementById('stream-name').value = stream.name; + document.getElementById('stream-description').value = stream.description || ''; + document.getElementById('stream-error').style.display = 'none'; + + // Set type and disable changing it for existing streams + document.getElementById('stream-type').value = stream.stream_type; + document.getElementById('stream-type').disabled = true; + onStreamTypeChange(); + + // Populate dropdowns before setting values + await populateStreamModalDropdowns(); + + if (stream.stream_type === 'raw') { + document.getElementById('stream-display-index').value = String(stream.display_index ?? 0); + document.getElementById('stream-capture-template').value = stream.capture_template_id || ''; + const fps = stream.target_fps ?? 30; + document.getElementById('stream-target-fps').value = fps; + document.getElementById('stream-target-fps-value').textContent = fps; + } else { + document.getElementById('stream-source').value = stream.source_stream_id || ''; + document.getElementById('stream-pp-template').value = stream.postprocessing_template_id || ''; + } + + const modal = document.getElementById('stream-modal'); + modal.style.display = 'flex'; + lockBody(); + modal.onclick = (e) => { if (e.target === modal) closeStreamModal(); }; + } catch (error) { + console.error('Error loading stream:', error); + showToast(t('streams.error.load') + ': ' + error.message, 'error'); + } +} + +async function populateStreamModalDropdowns() { + // Load displays, capture templates, streams, and PP templates in parallel + const [displaysRes, captureTemplatesRes, streamsRes, ppTemplatesRes] = await Promise.all([ + fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }), + fetchWithAuth('/capture-templates'), + fetchWithAuth('/picture-streams'), + fetchWithAuth('/postprocessing-templates'), + ]); + + // Displays + const displaySelect = document.getElementById('stream-display-index'); + displaySelect.innerHTML = ''; + if (displaysRes.ok) { + const displaysData = await displaysRes.json(); + (displaysData.displays || []).forEach(d => { + const opt = document.createElement('option'); + opt.value = d.index; + opt.textContent = `${d.index}: ${d.width}x${d.height}${d.is_primary ? ` (${t('displays.badge.primary')})` : ''}`; + displaySelect.appendChild(opt); + }); + } + if (displaySelect.options.length === 0) { + const opt = document.createElement('option'); + opt.value = '0'; + opt.textContent = '0'; + displaySelect.appendChild(opt); + } + + // Capture templates + const templateSelect = document.getElementById('stream-capture-template'); + templateSelect.innerHTML = ''; + if (captureTemplatesRes.ok) { + const data = await captureTemplatesRes.json(); + (data.templates || []).forEach(tmpl => { + const opt = document.createElement('option'); + opt.value = tmpl.id; + opt.textContent = `${getEngineIcon(tmpl.engine_type)} ${tmpl.name}`; + templateSelect.appendChild(opt); + }); + } + + // Source streams (all existing streams) + const sourceSelect = document.getElementById('stream-source'); + sourceSelect.innerHTML = ''; + if (streamsRes.ok) { + const data = await streamsRes.json(); + const editingId = document.getElementById('stream-id').value; + (data.streams || []).forEach(s => { + // Don't show the current stream as a possible source + if (s.id === editingId) return; + const opt = document.createElement('option'); + opt.value = s.id; + const typeLabel = s.stream_type === 'raw' ? '📷' : '🎨'; + opt.textContent = `${typeLabel} ${s.name}`; + sourceSelect.appendChild(opt); + }); + } + + // PP templates + const ppSelect = document.getElementById('stream-pp-template'); + ppSelect.innerHTML = ''; + if (ppTemplatesRes.ok) { + const data = await ppTemplatesRes.json(); + (data.templates || []).forEach(tmpl => { + const opt = document.createElement('option'); + opt.value = tmpl.id; + opt.textContent = tmpl.name; + ppSelect.appendChild(opt); + }); + } +} + +async function saveStream() { + const streamId = document.getElementById('stream-id').value; + const name = document.getElementById('stream-name').value.trim(); + const streamType = document.getElementById('stream-type').value; + const description = document.getElementById('stream-description').value.trim(); + const errorEl = document.getElementById('stream-error'); + + if (!name) { + showToast(t('streams.error.required'), 'error'); + return; + } + + const payload = { name, description: description || null }; + + if (!streamId) { + // Creating - include stream_type + payload.stream_type = streamType; + } + + if (streamType === 'raw') { + payload.display_index = parseInt(document.getElementById('stream-display-index').value) || 0; + payload.capture_template_id = document.getElementById('stream-capture-template').value; + payload.target_fps = parseInt(document.getElementById('stream-target-fps').value) || 30; + } else { + payload.source_stream_id = document.getElementById('stream-source').value; + payload.postprocessing_template_id = document.getElementById('stream-pp-template').value; + } + + try { + let response; + if (streamId) { + response = await fetchWithAuth(`/picture-streams/${streamId}`, { + method: 'PUT', + body: JSON.stringify(payload) + }); + } else { + response = await fetchWithAuth('/picture-streams', { + method: 'POST', + body: JSON.stringify(payload) + }); + } + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || error.message || 'Failed to save stream'); + } + + showToast(streamId ? t('streams.updated') : t('streams.created'), 'success'); + closeStreamModal(); + await loadPictureStreams(); + } catch (error) { + console.error('Error saving stream:', error); + errorEl.textContent = error.message; + errorEl.style.display = 'block'; + } +} + +async function deleteStream(streamId) { + const confirmed = await showConfirm(t('streams.delete.confirm')); + if (!confirmed) return; + + try { + const response = await fetchWithAuth(`/picture-streams/${streamId}`, { + method: 'DELETE' + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || error.message || 'Failed to delete stream'); + } + + showToast(t('streams.deleted'), 'success'); + await loadPictureStreams(); + } catch (error) { + console.error('Error deleting stream:', error); + showToast(t('streams.error.delete') + ': ' + error.message, 'error'); + } +} + +function closeStreamModal() { + document.getElementById('stream-modal').style.display = 'none'; + document.getElementById('stream-type').disabled = false; + unlockBody(); +} + +// ===== Picture Stream Test ===== + +let _currentTestStreamId = null; + +async function showTestStreamModal(streamId) { + _currentTestStreamId = streamId; + restoreStreamTestDuration(); + document.getElementById('test-stream-results').style.display = 'none'; + + const modal = document.getElementById('test-stream-modal'); + modal.style.display = 'flex'; + lockBody(); + modal.onclick = (e) => { if (e.target === modal) closeTestStreamModal(); }; +} + +function closeTestStreamModal() { + document.getElementById('test-stream-modal').style.display = 'none'; + unlockBody(); + _currentTestStreamId = null; +} + +function updateStreamTestDuration(value) { + document.getElementById('test-stream-duration-value').textContent = value; + localStorage.setItem('lastStreamTestDuration', value); +} + +function restoreStreamTestDuration() { + const saved = localStorage.getItem('lastStreamTestDuration') || '5'; + document.getElementById('test-stream-duration').value = saved; + document.getElementById('test-stream-duration-value').textContent = saved; +} + +async function runStreamTest() { + if (!_currentTestStreamId) return; + + const captureDuration = parseFloat(document.getElementById('test-stream-duration').value); + + showOverlaySpinner(t('streams.test.running'), captureDuration); + + try { + const response = await fetchWithAuth(`/picture-streams/${_currentTestStreamId}/test`, { + method: 'POST', + body: JSON.stringify({ capture_duration: captureDuration }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || error.message || 'Test failed'); + } + + const result = await response.json(); + displayStreamTestResults(result); + } catch (error) { + console.error('Error running stream test:', error); + hideOverlaySpinner(); + showToast(t('streams.test.error.failed') + ': ' + error.message, 'error'); + } +} + +function displayStreamTestResults(result) { + hideOverlaySpinner(); + + const previewImg = document.getElementById('test-stream-preview-image'); + previewImg.innerHTML = `Stream preview`; + + document.getElementById('test-stream-actual-duration').textContent = `${result.performance.capture_duration_s.toFixed(2)}s`; + document.getElementById('test-stream-frame-count').textContent = result.performance.frame_count; + document.getElementById('test-stream-actual-fps').textContent = `${result.performance.actual_fps.toFixed(1)} FPS`; + document.getElementById('test-stream-avg-capture-time').textContent = `${result.performance.avg_capture_time_ms.toFixed(1)}ms`; + + document.getElementById('test-stream-results').style.display = 'block'; +} + +// ===== Processing Templates ===== + +async function loadPPTemplates() { + try { + const response = await fetchWithAuth('/postprocessing-templates'); + if (!response.ok) { + throw new Error(`Failed to load templates: ${response.status}`); + } + const data = await response.json(); + _cachedPPTemplates = data.templates || []; + renderPPTemplatesList(_cachedPPTemplates); + } catch (error) { + console.error('Error loading PP templates:', error); + document.getElementById('pp-templates-list').innerHTML = ` +
${t('postprocessing.error.load')}: ${error.message}
+ `; + } +} + +function renderPPTemplatesList(templates) { + const container = document.getElementById('pp-templates-list'); + + if (templates.length === 0) { + container.innerHTML = `
+
+
+
${t('postprocessing.add')}
+
`; + return; + } + + const renderCard = (tmpl) => { + const configEntries = { + [t('postprocessing.gamma')]: tmpl.gamma, + [t('postprocessing.saturation')]: tmpl.saturation, + [t('postprocessing.brightness')]: tmpl.brightness, + [t('postprocessing.smoothing')]: tmpl.smoothing, + }; + + return ` +
+ +
+
+ 🎨 ${escapeHtml(tmpl.name)} +
+
+ ${tmpl.description ? `
${escapeHtml(tmpl.description)}
` : ''} +
+ ${t('postprocessing.config.show')} + + ${Object.entries(configEntries).map(([key, val]) => ` + + + + + `).join('')} +
${escapeHtml(key)}${escapeHtml(String(val))}
+
+
+ +
+
+ `; + }; + + let html = templates.map(renderCard).join(''); + html += `
+
+
+
${t('postprocessing.add')}
+
`; + + container.innerHTML = html; +} + +async function showAddPPTemplateModal() { + document.getElementById('pp-template-modal-title').textContent = t('postprocessing.add'); + document.getElementById('pp-template-form').reset(); + document.getElementById('pp-template-id').value = ''; + document.getElementById('pp-template-error').style.display = 'none'; + + // Reset slider displays to defaults + document.getElementById('pp-template-gamma').value = '2.2'; + document.getElementById('pp-template-gamma-value').textContent = '2.2'; + document.getElementById('pp-template-saturation').value = '1.0'; + document.getElementById('pp-template-saturation-value').textContent = '1.0'; + document.getElementById('pp-template-brightness').value = '1.0'; + document.getElementById('pp-template-brightness-value').textContent = '1.0'; + document.getElementById('pp-template-smoothing').value = '0.3'; + document.getElementById('pp-template-smoothing-value').textContent = '0.3'; + + const modal = document.getElementById('pp-template-modal'); + modal.style.display = 'flex'; + lockBody(); + modal.onclick = (e) => { if (e.target === modal) closePPTemplateModal(); }; +} + +async function editPPTemplate(templateId) { + try { + const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`); + if (!response.ok) throw new Error(`Failed to load template: ${response.status}`); + const tmpl = await response.json(); + + document.getElementById('pp-template-modal-title').textContent = t('postprocessing.edit'); + document.getElementById('pp-template-id').value = templateId; + document.getElementById('pp-template-name').value = tmpl.name; + document.getElementById('pp-template-description').value = tmpl.description || ''; + document.getElementById('pp-template-error').style.display = 'none'; + + // Set sliders + document.getElementById('pp-template-gamma').value = tmpl.gamma; + document.getElementById('pp-template-gamma-value').textContent = tmpl.gamma; + document.getElementById('pp-template-saturation').value = tmpl.saturation; + document.getElementById('pp-template-saturation-value').textContent = tmpl.saturation; + document.getElementById('pp-template-brightness').value = tmpl.brightness; + document.getElementById('pp-template-brightness-value').textContent = tmpl.brightness; + document.getElementById('pp-template-smoothing').value = tmpl.smoothing; + document.getElementById('pp-template-smoothing-value').textContent = tmpl.smoothing; + + const modal = document.getElementById('pp-template-modal'); + modal.style.display = 'flex'; + lockBody(); + modal.onclick = (e) => { if (e.target === modal) closePPTemplateModal(); }; + } catch (error) { + console.error('Error loading PP template:', error); + showToast(t('postprocessing.error.load') + ': ' + error.message, 'error'); + } +} + +async function savePPTemplate() { + const templateId = document.getElementById('pp-template-id').value; + const name = document.getElementById('pp-template-name').value.trim(); + const description = document.getElementById('pp-template-description').value.trim(); + const errorEl = document.getElementById('pp-template-error'); + + if (!name) { + showToast(t('postprocessing.error.required'), 'error'); + return; + } + + const payload = { + name, + gamma: parseFloat(document.getElementById('pp-template-gamma').value), + saturation: parseFloat(document.getElementById('pp-template-saturation').value), + brightness: parseFloat(document.getElementById('pp-template-brightness').value), + smoothing: parseFloat(document.getElementById('pp-template-smoothing').value), + description: description || null, + }; + + try { + let response; + if (templateId) { + response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, { + method: 'PUT', + body: JSON.stringify(payload) + }); + } else { + response = await fetchWithAuth('/postprocessing-templates', { + method: 'POST', + body: JSON.stringify(payload) + }); + } + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || error.message || 'Failed to save template'); + } + + showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success'); + closePPTemplateModal(); + await loadPPTemplates(); + } catch (error) { + console.error('Error saving PP template:', error); + errorEl.textContent = error.message; + errorEl.style.display = 'block'; + } +} + +async function deletePPTemplate(templateId) { + const confirmed = await showConfirm(t('postprocessing.delete.confirm')); + if (!confirmed) return; + + try { + const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, { + method: 'DELETE' + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || error.message || 'Failed to delete template'); + } + + showToast(t('postprocessing.deleted'), 'success'); + await loadPPTemplates(); + } catch (error) { + console.error('Error deleting PP template:', error); + showToast(t('postprocessing.error.delete') + ': ' + error.message, 'error'); + } +} + +function closePPTemplateModal() { + document.getElementById('pp-template-modal').style.display = 'none'; + unlockBody(); +} + +// ===== Device Stream Selector ===== + +let streamSelectorInitialValues = {}; + +async function showStreamSelector(deviceId) { + try { + const [deviceResponse, streamsResponse, settingsResponse] = await Promise.all([ + fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }), + fetchWithAuth('/picture-streams'), + fetch(`${API_BASE}/devices/${deviceId}/settings`, { headers: getHeaders() }), + ]); + + if (deviceResponse.status === 401) { + handle401Error(); + return; + } + + if (!deviceResponse.ok) { + showToast('Failed to load device', 'error'); + return; + } + + const device = await deviceResponse.json(); + const settings = settingsResponse.ok ? await settingsResponse.json() : {}; + + // Populate stream select + const streamSelect = document.getElementById('stream-selector-stream'); + streamSelect.innerHTML = ''; + + if (streamsResponse.ok) { + const data = await streamsResponse.json(); + (data.streams || []).forEach(s => { + const opt = document.createElement('option'); + opt.value = s.id; + const typeIcon = s.stream_type === 'raw' ? '📷' : '🎨'; + opt.textContent = `${typeIcon} ${s.name}`; + streamSelect.appendChild(opt); + }); + } + + const currentStreamId = device.picture_stream_id || ''; + streamSelect.value = currentStreamId; + + // Populate LED projection fields + const borderWidth = settings.border_width ?? device.settings?.border_width ?? 10; + document.getElementById('stream-selector-border-width').value = borderWidth; + document.getElementById('stream-selector-interpolation').value = device.settings?.interpolation_mode || 'average'; + + streamSelectorInitialValues = { + stream: currentStreamId, + border_width: String(borderWidth), + interpolation: device.settings?.interpolation_mode || 'average', + }; + + document.getElementById('stream-selector-device-id').value = deviceId; + document.getElementById('stream-selector-error').style.display = 'none'; + + // Show info about selected stream + updateStreamSelectorInfo(streamSelect.value); + streamSelect.onchange = () => updateStreamSelectorInfo(streamSelect.value); + + const modal = document.getElementById('stream-selector-modal'); + modal.style.display = 'flex'; + lockBody(); + modal.onclick = (e) => { if (e.target === modal) closeStreamSelectorModal(); }; + } catch (error) { + console.error('Failed to load stream settings:', error); + showToast('Failed to load stream settings', 'error'); + } +} + +async function updateStreamSelectorInfo(streamId) { + const infoPanel = document.getElementById('stream-selector-info'); + if (!streamId) { + infoPanel.style.display = 'none'; + return; + } + + try { + const response = await fetchWithAuth(`/picture-streams/${streamId}`); + if (!response.ok) { + infoPanel.style.display = 'none'; + return; + } + const stream = await response.json(); + + let infoHtml = `
${t('streams.type')} ${stream.stream_type === 'raw' ? t('streams.type.raw') : t('streams.type.processed')}
`; + + if (stream.stream_type === 'raw') { + infoHtml += `
${t('streams.display')} ${stream.display_index ?? 0}
`; + infoHtml += `
${t('streams.target_fps')} ${stream.target_fps ?? 30}
`; + } else { + const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id); + infoHtml += `
${t('streams.source')} ${sourceStream ? escapeHtml(sourceStream.name) : stream.source_stream_id}
`; + } + + infoPanel.innerHTML = infoHtml; + infoPanel.style.display = ''; + } catch { + infoPanel.style.display = 'none'; + } +} + +async function saveStreamSelector() { + const deviceId = document.getElementById('stream-selector-device-id').value; + const pictureStreamId = 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 errorEl = document.getElementById('stream-selector-error'); + + try { + // Save picture stream assignment + const response = await fetch(`${API_BASE}/devices/${deviceId}`, { + method: 'PUT', + headers: getHeaders(), + body: JSON.stringify({ picture_stream_id: pictureStreamId }) + }); + + if (response.status === 401) { + handle401Error(); + return; + } + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || error.message || 'Failed to save'); + } + + // Save LED projection settings — merge with existing to avoid overwriting other fields + const currentSettingsRes = await fetch(`${API_BASE}/devices/${deviceId}/settings`, { headers: getHeaders() }); + const currentSettings = currentSettingsRes.ok ? await currentSettingsRes.json() : {}; + const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, { + method: 'PUT', + headers: getHeaders(), + body: JSON.stringify({ ...currentSettings, border_width: borderWidth, interpolation_mode: interpolation }) + }); + + if (!settingsResponse.ok) { + const error = await settingsResponse.json(); + throw new Error(error.detail || error.message || 'Failed to save settings'); + } + + showToast(t('device.stream_selector.saved'), 'success'); + forceCloseStreamSelectorModal(); + await loadDevices(); + } catch (error) { + console.error('Error saving stream settings:', error); + errorEl.textContent = error.message; + errorEl.style.display = 'block'; + } +} + +function isStreamSettingsDirty() { + return ( + document.getElementById('stream-selector-stream').value !== streamSelectorInitialValues.stream || + document.getElementById('stream-selector-border-width').value !== streamSelectorInitialValues.border_width || + document.getElementById('stream-selector-interpolation').value !== streamSelectorInitialValues.interpolation + ); +} + +async function closeStreamSelectorModal() { + if (isStreamSettingsDirty()) { + const confirmed = await showConfirm(t('modal.discard_changes')); + if (!confirmed) return; + } + forceCloseStreamSelectorModal(); +} + +function forceCloseStreamSelectorModal() { + document.getElementById('stream-selector-modal').style.display = 'none'; + document.getElementById('stream-selector-error').style.display = 'none'; + unlockBody(); + streamSelectorInitialValues = {}; +} diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 1b10473..2ad89f2 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -36,7 +36,9 @@
+ +
@@ -62,6 +64,17 @@
+
+

+ + Picture streams define the capture pipeline. A raw stream captures from a display using a capture template. A processed stream applies postprocessing to another stream. Assign streams to devices. + +

+
+
+
+
+

@@ -72,6 +85,17 @@

+ +
+

+ + Processing templates define color correction and smoothing settings. Assign them to processed picture streams for consistent postprocessing across devices. + +

+
+
+
+