Compare commits

..

2 Commits

Author SHA1 Message Date
493f14fba9 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 <noreply@anthropic.com>
2026-02-11 00:00:30 +03:00
3db7ba4b0e Fix DXcam engine and improve UI: loading spinners, template card gap
DXcam engine overhaul:
- Remove all user-facing config (device_idx, output_idx, output_color)
  since these are auto-resolved or hardcoded to RGB
- Use one-shot grab() mode with retry for reliability
- Lazily create camera per display via _ensure_camera()
- Clear dxcam global factory cache to prevent stale DXGI state

UI improvements:
- Replace "Loading..." text with CSS spinner animations
- Fix template card header gap on default cards (scope padding-right
  to cards with remove button only via :has selector)
- Add auto-restart server rule to CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 17:26:38 +03:00
24 changed files with 2871 additions and 265 deletions

View File

@@ -66,6 +66,16 @@
✅ Claude: [now creates the commit]
```
## IMPORTANT: Auto-Restart Server on Code Changes
**Whenever server-side Python code is modified** (any file under `/server/src/`), **automatically restart the server** so the changes take effect immediately. Do NOT wait for the user to ask for a restart.
### Restart procedure
1. Stop the running Python process: `powershell -Command "Get-Process -Name python -ErrorAction SilentlyContinue | Stop-Process -Force"`
2. Start the server: `powershell -Command "Set-Location 'c:\Users\Alexei\Documents\wled-screen-controller\server'; python -m wled_controller.main"` (run in background)
3. Wait 3 seconds and check startup logs to confirm it's running
## Project Structure
This is a monorepo containing:

View File

@@ -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}")

View File

@@ -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")

View File

@@ -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):

View File

@@ -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.

View File

@@ -1,6 +1,7 @@
"""DXcam-based screen capture engine (Windows only, DXGI Desktop Duplication)."""
import sys
import time
from typing import Any, Dict, List
import numpy as np
@@ -26,20 +27,14 @@ class DXcamEngine(CaptureEngine):
"""
ENGINE_TYPE = "dxcam"
ENGINE_PRIORITY = 3
def __init__(self, config: Dict[str, Any]):
"""Initialize DXcam engine.
Args:
config: Engine configuration
- device_idx (int): GPU index (default: 0)
- output_idx (int|None): Monitor index (default: None = primary)
- output_color (str): Color format "RGB" or "BGR" (default: "RGB")
- max_buffer_len (int): Frame buffer size (default: 64)
"""
"""Initialize DXcam engine."""
super().__init__(config)
self._camera = None
self._dxcam = None
self._current_output = None
def initialize(self) -> None:
"""Initialize DXcam capture.
@@ -55,45 +50,61 @@ class DXcamEngine(CaptureEngine):
"DXcam not installed. Install with: pip install dxcam"
)
self._initialized = True
logger.info("DXcam engine initialized")
def _ensure_camera(self, display_index: int) -> None:
"""Ensure camera is created for the requested display.
Creates or recreates the DXcam camera if needed.
DXcam caches cameras globally per (device, output). We clear the
cache before creating to avoid stale DXGI state from prior requests.
"""
if self._camera and self._current_output == display_index:
return
# Release existing camera
if self._camera:
try:
self._camera.release()
except Exception:
pass
self._camera = None
# Clear dxcam's global camera cache to avoid stale DXGI state
try:
device_idx = self.config.get("device_idx", 0)
output_idx = self.config.get("output_idx", None)
output_color = self.config.get("output_color", "RGB")
max_buffer_len = self.config.get("max_buffer_len", 64)
self._dxcam.__factory.clean_up()
except Exception:
pass
self._camera = self._dxcam.create(
device_idx=device_idx,
output_idx=output_idx,
output_color=output_color,
max_buffer_len=max_buffer_len,
)
self._camera = self._dxcam.create(
output_idx=display_index,
output_color="RGB",
)
if not self._camera:
raise RuntimeError("Failed to create DXcam camera instance")
if not self._camera:
raise RuntimeError(f"Failed to create DXcam camera for display {display_index}")
# Start the camera to begin capturing
self._camera.start()
self._initialized = True
logger.info(
f"DXcam engine initialized (device={device_idx}, "
f"output={output_idx}, color={output_color})"
)
except Exception as e:
raise RuntimeError(f"Failed to initialize DXcam: {e}")
self._current_output = display_index
logger.info(f"DXcam camera created (output={display_index})")
def cleanup(self) -> None:
"""Cleanup DXcam resources."""
if self._camera:
try:
# Stop capturing before releasing
self._camera.stop()
self._camera.release()
except Exception as e:
logger.error(f"Error releasing DXcam camera: {e}")
self._camera = None
# Clear dxcam's global cache so next create() gets fresh DXGI state
if self._dxcam:
try:
self._dxcam.__factory.clean_up()
except Exception:
pass
self._current_output = None
self._initialized = False
logger.info("DXcam engine cleaned up")
@@ -118,9 +129,7 @@ class DXcamEngine(CaptureEngine):
# Get output information from DXcam
# Note: DXcam doesn't provide comprehensive display enumeration
# We report the single configured output
output_idx = self.config.get("output_idx", 0)
if output_idx is None:
output_idx = 0
output_idx = self._current_output or 0
# DXcam camera has basic output info
if self._camera and hasattr(self._camera, "width") and hasattr(self._camera, "height"):
@@ -160,41 +169,36 @@ class DXcamEngine(CaptureEngine):
"""Capture display using DXcam.
Args:
display_index: Index of display to capture (0-based)
Note: DXcam is configured for a specific output, so this
should match the configured output_idx
display_index: Index of display to capture (0-based).
Returns:
ScreenCapture object with image data
Raises:
ValueError: If display_index doesn't match configured output
RuntimeError: If capture fails
"""
# Auto-initialize if not already initialized
if not self._initialized:
self.initialize()
# DXcam is configured for a specific output
configured_output = self.config.get("output_idx", 0)
if configured_output is None:
configured_output = 0
if display_index != configured_output:
raise ValueError(
f"DXcam engine is configured for output {configured_output}, "
f"cannot capture display {display_index}. Create a new template "
f"with output_idx={display_index} to capture this display."
)
# Ensure camera is ready for the requested display
self._ensure_camera(display_index)
try:
# Grab frame from DXcam
frame = self._camera.grab()
# Grab frame from DXcam (one-shot mode, no start() needed).
# First grab after create() often returns None as DXGI Desktop
# Duplication needs a frame change to capture. Retry a few times.
frame = None
for attempt in range(5):
frame = self._camera.grab()
if frame is not None:
break
time.sleep(0.05)
if frame is None:
raise RuntimeError(
"Failed to capture frame (no new data). This can happen if "
"the screen hasn't changed or if there's a timeout."
"Failed to capture frame after retries. "
"The screen may not have changed or the display is unavailable."
)
# DXcam returns numpy array directly in configured color format
@@ -243,9 +247,4 @@ class DXcamEngine(CaptureEngine):
Returns:
Default config dict with DXcam options
"""
return {
"device_idx": 0, # Primary GPU
"output_idx": None, # Primary monitor (None = auto-select)
"output_color": "RGB", # RGB color format
"max_buffer_len": 64, # Frame buffer size
}
return {}

View File

@@ -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).

View File

@@ -26,6 +26,7 @@ class MSSEngine(CaptureEngine):
"""
ENGINE_TYPE = "mss"
ENGINE_PRIORITY = 1
def __init__(self, config: Dict[str, Any]):
"""Initialize MSS engine.

View File

@@ -34,6 +34,7 @@ class WGCEngine(CaptureEngine):
"""
ENGINE_TYPE = "wgc"
ENGINE_PRIORITY = 2
def __init__(self, config: Dict[str, Any]):
"""Initialize WGC engine.

View File

@@ -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,

View File

@@ -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:

View File

@@ -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) {
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
⚙️
</button>
<button class="btn btn-icon btn-secondary" onclick="showCaptureSettings('${device.id}')" title="${t('device.button.capture_settings')}">
🎬
<button class="btn btn-icon btn-secondary" onclick="showStreamSelector('${device.id}')" title="${t('device.button.stream_selector')}">
📺
</button>
<button class="btn btn-icon btn-secondary" onclick="showCalibration('${device.id}')" title="${t('device.button.calibrate')}">
📐
@@ -930,20 +936,14 @@ async function showCaptureSettings(deviceId) {
templateSelect.appendChild(opt);
});
}
if (templateSelect.options.length === 0) {
const opt = document.createElement('option');
opt.value = 'tpl_mss_default';
opt.textContent = 'MSS (Default)';
templateSelect.appendChild(opt);
}
templateSelect.value = device.capture_template_id || 'tpl_mss_default';
templateSelect.value = device.capture_template_id || '';
// Store device ID, current settings snapshot, and initial values for dirty check
document.getElementById('capture-settings-device-id').value = device.id;
captureSettingsInitialValues = {
display_index: String(device.settings.display_index ?? 0),
fps: String(currentSettings.fps ?? 30),
capture_template_id: device.capture_template_id || 'tpl_mss_default',
capture_template_id: device.capture_template_id || '',
_currentSettings: currentSettings,
};
@@ -2359,25 +2359,16 @@ function renderTemplatesList(templates) {
return;
}
const defaultTemplates = templates.filter(t => t.is_default);
const customTemplates = templates.filter(t => !t.is_default);
const renderCard = (template) => {
const engineIcon = getEngineIcon(template.engine_type);
const defaultBadge = template.is_default
? `<span class="badge badge-default">${t('templates.default')}</span>`
: '';
return `
<div class="template-card" data-template-id="${template.id}">
${!template.is_default ? `
<button class="card-remove-btn" onclick="deleteTemplate('${template.id}')" title="${t('common.delete')}">&#x2715;</button>
` : ''}
<button class="card-remove-btn" onclick="deleteTemplate('${template.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">
${engineIcon} ${escapeHtml(template.name)}
</div>
${defaultBadge}
</div>
<div class="template-config">
<strong>${t('templates.engine')}</strong> ${template.engine_type.toUpperCase()}
@@ -2401,22 +2392,15 @@ function renderTemplatesList(templates) {
<button class="btn btn-icon btn-secondary" onclick="showTestTemplateModal('${template.id}')" title="${t('templates.test.title')}">
🧪
</button>
${!template.is_default ? `
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">
✏️
</button>
` : ''}
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">
✏️
</button>
</div>
</div>
`;
};
let html = defaultTemplates.map(renderCard).join('');
if (customTemplates.length > 0) {
html += `<div class="templates-separator"><span>${t('templates.custom')}</span></div>`;
html += customTemplates.map(renderCard).join('');
}
let html = templates.map(renderCard).join('');
html += `<div class="template-card add-template-card" onclick="showAddTemplateModal()">
<div class="add-template-icon">+</div>
@@ -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 = `
<div class="error-message">${t('streams.error.load')}: ${error.message}</div>
`;
}
}
function renderPictureStreamsList(streams) {
const container = document.getElementById('streams-list');
if (streams.length === 0) {
container.innerHTML = `<div class="template-card add-template-card" onclick="showAddStreamModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('streams.add')}</div>
</div>`;
return;
}
const renderCard = (stream) => {
const typeIcon = stream.stream_type === 'raw' ? '📷' : '🎨';
const typeBadge = stream.stream_type === 'raw'
? `<span class="badge badge-raw">${t('streams.type.raw')}</span>`
: `<span class="badge badge-processed">${t('streams.type.processed')}</span>`;
let detailsHtml = '';
if (stream.stream_type === 'raw') {
detailsHtml = `
<div class="template-config">
<strong>${t('streams.display')}</strong> ${stream.display_index ?? 0}
</div>
<div class="template-config">
<strong>${t('streams.target_fps')}</strong> ${stream.target_fps ?? 30}
</div>
`;
} 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 = `
<div class="template-config">
<strong>${t('streams.source')}</strong> ${sourceName}
</div>
`;
}
return `
<div class="template-card" data-stream-id="${stream.id}">
<button class="card-remove-btn" onclick="deleteStream('${stream.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">
${typeIcon} ${escapeHtml(stream.name)}
</div>
${typeBadge}
</div>
${detailsHtml}
${stream.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(stream.description)}</div>` : ''}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="showTestStreamModal('${stream.id}')" title="${t('streams.test.title')}">
🧪
</button>
<button class="btn btn-icon btn-secondary" onclick="editStream('${stream.id}')" title="${t('common.edit')}">
✏️
</button>
</div>
</div>
`;
};
let html = streams.map(renderCard).join('');
html += `<div class="template-card add-template-card" onclick="showAddStreamModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('streams.add')}</div>
</div>`;
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 = `<img src="${result.full_capture.image}" alt="Stream preview" style="max-width: 100%; border-radius: 4px;">`;
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 = `
<div class="error-message">${t('postprocessing.error.load')}: ${error.message}</div>
`;
}
}
function renderPPTemplatesList(templates) {
const container = document.getElementById('pp-templates-list');
if (templates.length === 0) {
container.innerHTML = `<div class="template-card add-template-card" onclick="showAddPPTemplateModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('postprocessing.add')}</div>
</div>`;
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 `
<div class="template-card" data-pp-template-id="${tmpl.id}">
<button class="card-remove-btn" onclick="deletePPTemplate('${tmpl.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">
🎨 ${escapeHtml(tmpl.name)}
</div>
</div>
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
<details class="template-config-details">
<summary>${t('postprocessing.config.show')}</summary>
<table class="config-table">
${Object.entries(configEntries).map(([key, val]) => `
<tr>
<td class="config-key">${escapeHtml(key)}</td>
<td class="config-value">${escapeHtml(String(val))}</td>
</tr>
`).join('')}
</table>
</details>
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="editPPTemplate('${tmpl.id}')" title="${t('common.edit')}">
✏️
</button>
</div>
</div>
`;
};
let html = templates.map(renderCard).join('');
html += `<div class="template-card add-template-card" onclick="showAddPPTemplateModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('postprocessing.add')}</div>
</div>`;
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 = `<div class="stream-info-type"><strong>${t('streams.type')}</strong> ${stream.stream_type === 'raw' ? t('streams.type.raw') : t('streams.type.processed')}</div>`;
if (stream.stream_type === 'raw') {
infoHtml += `<div><strong>${t('streams.display')}</strong> ${stream.display_index ?? 0}</div>`;
infoHtml += `<div><strong>${t('streams.target_fps')}</strong> ${stream.target_fps ?? 30}</div>`;
} else {
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
infoHtml += `<div><strong>${t('streams.source')}</strong> ${sourceStream ? escapeHtml(sourceStream.name) : stream.source_stream_id}</div>`;
}
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 = {};
}

View File

@@ -36,7 +36,9 @@
<div class="tab-bar">
<button class="tab-btn active" data-tab="devices" onclick="switchTab('devices')"><span data-i18n="devices.title">💡 Devices</span></button>
<button class="tab-btn" data-tab="displays" onclick="switchTab('displays')"><span data-i18n="displays.layout">🖥️ Displays</span></button>
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Picture Streams</span></button>
<button class="tab-btn" data-tab="templates" onclick="switchTab('templates')"><span data-i18n="templates.title">🎯 Capture Templates</span></button>
<button class="tab-btn" data-tab="pp-templates" onclick="switchTab('pp-templates')"><span data-i18n="postprocessing.title">🎨 Processing Templates</span></button>
</div>
<div class="tab-panel active" id="tab-devices">
@@ -49,19 +51,30 @@
<span data-i18n="devices.wled_note2">This controller sends pixel color data and controls brightness per device.</span>
</p>
<div id="devices-list" class="devices-grid">
<div class="loading" data-i18n="devices.loading">Loading devices...</div>
<div class="loading-spinner"></div>
</div>
</div>
<div class="tab-panel" id="tab-displays">
<div class="display-layout-preview">
<div id="display-layout-canvas" class="display-layout-canvas">
<div class="loading" data-i18n="displays.loading">Loading layout...</div>
<div class="loading-spinner"></div>
</div>
</div>
<div id="displays-list" style="display: none;"></div>
</div>
<div class="tab-panel" id="tab-streams">
<p class="section-tip">
<span data-i18n="streams.description">
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.
</span>
</p>
<div id="streams-list" class="templates-grid">
<div class="loading-spinner"></div>
</div>
</div>
<div class="tab-panel" id="tab-templates">
<p class="section-tip">
<span data-i18n="templates.description">
@@ -69,7 +82,18 @@
</span>
</p>
<div id="templates-list" class="templates-grid">
<div class="loading" data-i18n="templates.loading">Loading templates...</div>
<div class="loading-spinner"></div>
</div>
</div>
<div class="tab-panel" id="tab-pp-templates">
<p class="section-tip">
<span data-i18n="postprocessing.description">
Processing templates define color correction and smoothing settings. Assign them to processed picture streams for consistent postprocessing across devices.
</span>
</p>
<div id="pp-templates-list" class="templates-grid">
<div class="loading-spinner"></div>
</div>
</div>
</div>
@@ -244,44 +268,47 @@
</div>
</div>
<!-- Capture Settings Modal -->
<div id="capture-settings-modal" class="modal">
<!-- Stream Settings Modal (picture stream + LED projection settings) -->
<div id="stream-selector-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 data-i18n="settings.capture.title">🎬 Capture Settings</h2>
<button class="modal-close-btn" onclick="closeCaptureSettingsModal()" title="Close">&#x2715;</button>
<h2 data-i18n="device.stream_settings.title">📺 Stream Settings</h2>
<button class="modal-close-btn" onclick="closeStreamSelectorModal()" title="Close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="capture-settings-form">
<input type="hidden" id="capture-settings-device-id">
<form id="stream-selector-form">
<input type="hidden" id="stream-selector-device-id">
<div class="form-group">
<label for="capture-settings-display-index" data-i18n="settings.display_index">Display:</label>
<select id="capture-settings-display-index"></select>
<small class="input-hint" data-i18n="settings.display_index.hint">Which screen to capture for this device</small>
<label for="stream-selector-stream" data-i18n="device.stream_selector.label">Assigned Picture Stream:</label>
<select id="stream-selector-stream"></select>
<small class="input-hint" data-i18n="device.stream_selector.hint">Select a picture stream that defines what this device captures and processes</small>
</div>
<div id="stream-selector-info" class="stream-info-panel" style="display: none;"></div>
<div class="form-group">
<label for="stream-selector-border-width" data-i18n="device.stream_settings.border_width">Border Width (px):</label>
<input type="number" id="stream-selector-border-width" min="1" max="100" value="10">
<small class="input-hint" data-i18n="device.stream_settings.border_width_hint">How many pixels from the screen edge to sample for LED colors (1-100)</small>
</div>
<div class="form-group">
<label for="capture-settings-fps" data-i18n="settings.fps">Target FPS:</label>
<div class="slider-row">
<input type="range" id="capture-settings-fps" min="10" max="90" value="30" oninput="document.getElementById('capture-settings-fps-value').textContent = this.value">
<span id="capture-settings-fps-value" class="slider-value">30</span>
</div>
<small class="input-hint" data-i18n="settings.fps.hint">Target frames per second (10-90)</small>
<label for="stream-selector-interpolation" data-i18n="device.stream_settings.interpolation">Interpolation Mode:</label>
<select id="stream-selector-interpolation">
<option value="average" data-i18n="device.stream_settings.interpolation.average">Average</option>
<option value="median" data-i18n="device.stream_settings.interpolation.median">Median</option>
<option value="dominant" data-i18n="device.stream_settings.interpolation.dominant">Dominant</option>
</select>
<small class="input-hint" data-i18n="device.stream_settings.interpolation_hint">How to calculate LED color from sampled pixels</small>
</div>
<div class="form-group">
<label for="capture-settings-template" data-i18n="settings.capture_template">Capture Template:</label>
<select id="capture-settings-template"></select>
<small class="input-hint" data-i18n="settings.capture_template.hint">Screen capture engine and configuration for this device</small>
</div>
<div id="capture-settings-error" class="error-message" style="display: none;"></div>
<div id="stream-selector-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeCaptureSettingsModal()" title="Cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveCaptureSettings()" title="Save">&#x2713;</button>
<button class="btn btn-icon btn-secondary" onclick="closeStreamSelectorModal()" title="Cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveStreamSelector()" title="Save">&#x2713;</button>
</div>
</div>
</div>
@@ -465,6 +492,189 @@
</div>
</div>
<!-- Test Stream Modal -->
<div id="test-stream-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 data-i18n="streams.test.title">Test Picture Stream</h2>
<button class="modal-close-btn" onclick="closeTestStreamModal()" title="Close">&#x2715;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="test-stream-duration">
<span data-i18n="streams.test.duration">Capture Duration (s):</span>
<span id="test-stream-duration-value">5</span>
</label>
<input type="range" id="test-stream-duration" min="1" max="10" step="1" value="5" oninput="updateStreamTestDuration(this.value)" />
</div>
<button type="button" class="btn btn-primary" onclick="runStreamTest()" style="margin-top: 16px;">
<span data-i18n="streams.test.run">🧪 Run Test</span>
</button>
<div id="test-stream-results" style="display: none; margin-top: 16px;">
<div class="test-results-container">
<div class="test-preview-section">
<div id="test-stream-preview-image" class="test-preview-image"></div>
</div>
<div class="test-performance-section">
<div class="test-performance-stats">
<div class="stat-item">
<span data-i18n="templates.test.results.duration">Duration:</span>
<strong id="test-stream-actual-duration">-</strong>
</div>
<div class="stat-item">
<span data-i18n="templates.test.results.frame_count">Frames:</span>
<strong id="test-stream-frame-count">-</strong>
</div>
<div class="stat-item">
<span data-i18n="templates.test.results.actual_fps">Actual FPS:</span>
<strong id="test-stream-actual-fps">-</strong>
</div>
<div class="stat-item">
<span data-i18n="templates.test.results.avg_capture_time">Avg Capture:</span>
<strong id="test-stream-avg-capture-time">-</strong>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Picture Stream Modal -->
<div id="stream-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="stream-modal-title" data-i18n="streams.add">Add Picture Stream</h2>
<button class="modal-close-btn" onclick="closeStreamModal()" title="Close">&#x2715;</button>
</div>
<div class="modal-body">
<input type="hidden" id="stream-id">
<form id="stream-form">
<div class="form-group">
<label for="stream-name" data-i18n="streams.name">Stream Name:</label>
<input type="text" id="stream-name" data-i18n-placeholder="streams.name.placeholder" placeholder="My Stream" required>
</div>
<div class="form-group">
<label for="stream-type" data-i18n="streams.type">Stream Type:</label>
<select id="stream-type" onchange="onStreamTypeChange()">
<option value="raw" data-i18n="streams.type.raw">Screen Capture</option>
<option value="processed" data-i18n="streams.type.processed">Processed</option>
</select>
</div>
<!-- Raw stream fields -->
<div id="stream-raw-fields">
<div class="form-group">
<label for="stream-display-index" data-i18n="streams.display">Display:</label>
<select id="stream-display-index"></select>
</div>
<div class="form-group">
<label for="stream-capture-template" data-i18n="streams.capture_template">Capture Template:</label>
<select id="stream-capture-template"></select>
</div>
<div class="form-group">
<label for="stream-target-fps" data-i18n="streams.target_fps">Target FPS:</label>
<div class="slider-row">
<input type="range" id="stream-target-fps" min="10" max="90" value="30" oninput="document.getElementById('stream-target-fps-value').textContent = this.value">
<span id="stream-target-fps-value" class="slider-value">30</span>
</div>
</div>
</div>
<!-- Processed stream fields -->
<div id="stream-processed-fields" style="display: none;">
<div class="form-group">
<label for="stream-source" data-i18n="streams.source">Source Stream:</label>
<select id="stream-source"></select>
</div>
<div class="form-group">
<label for="stream-pp-template" data-i18n="streams.pp_template">Processing Template:</label>
<select id="stream-pp-template"></select>
</div>
</div>
<div class="form-group">
<label for="stream-description" data-i18n="streams.description_label">Description (optional):</label>
<input type="text" id="stream-description" data-i18n-placeholder="streams.description_placeholder" placeholder="Describe this stream...">
</div>
<div id="stream-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeStreamModal()" title="Cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveStream()" title="Save">&#x2713;</button>
</div>
</div>
</div>
<!-- Processing Template Modal -->
<div id="pp-template-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="pp-template-modal-title" data-i18n="postprocessing.add">Add Processing Template</h2>
<button class="modal-close-btn" onclick="closePPTemplateModal()" title="Close">&#x2715;</button>
</div>
<div class="modal-body">
<input type="hidden" id="pp-template-id">
<form id="pp-template-form">
<div class="form-group">
<label for="pp-template-name" data-i18n="postprocessing.name">Template Name:</label>
<input type="text" id="pp-template-name" data-i18n-placeholder="postprocessing.name.placeholder" placeholder="My Processing Template" required>
</div>
<div class="form-group">
<label for="pp-template-gamma">
<span data-i18n="postprocessing.gamma">Gamma:</span>
<span id="pp-template-gamma-value">2.2</span>
</label>
<input type="range" id="pp-template-gamma" min="0.1" max="5.0" step="0.1" value="2.2" oninput="document.getElementById('pp-template-gamma-value').textContent = this.value">
</div>
<div class="form-group">
<label for="pp-template-saturation">
<span data-i18n="postprocessing.saturation">Saturation:</span>
<span id="pp-template-saturation-value">1.0</span>
</label>
<input type="range" id="pp-template-saturation" min="0.0" max="2.0" step="0.1" value="1.0" oninput="document.getElementById('pp-template-saturation-value').textContent = this.value">
</div>
<div class="form-group">
<label for="pp-template-brightness">
<span data-i18n="postprocessing.brightness">Brightness:</span>
<span id="pp-template-brightness-value">1.0</span>
</label>
<input type="range" id="pp-template-brightness" min="0.0" max="1.0" step="0.05" value="1.0" oninput="document.getElementById('pp-template-brightness-value').textContent = this.value">
</div>
<div class="form-group">
<label for="pp-template-smoothing">
<span data-i18n="postprocessing.smoothing">Smoothing:</span>
<span id="pp-template-smoothing-value">0.3</span>
</label>
<input type="range" id="pp-template-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('pp-template-smoothing-value').textContent = this.value">
</div>
<div class="form-group">
<label for="pp-template-description" data-i18n="postprocessing.description_label">Description (optional):</label>
<input type="text" id="pp-template-description" data-i18n-placeholder="postprocessing.description_placeholder" placeholder="Describe this template...">
</div>
<div id="pp-template-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closePPTemplateModal()" title="Cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="savePPTemplate()" title="Save">&#x2713;</button>
</div>
</div>
</div>
<!-- Device Tutorial Overlay (viewport-level) -->
<div id="device-tutorial-overlay" class="tutorial-overlay tutorial-overlay-fixed">
<div class="tutorial-backdrop"></div>

View File

@@ -50,9 +50,6 @@
"templates.config.show": "Show configuration",
"templates.config.none": "No additional configuration",
"templates.config.default": "Default",
"templates.default": "Default",
"templates.custom": "Custom Templates",
"templates.default.locked": "Default template (cannot edit/delete)",
"templates.created": "Template created successfully",
"templates.updated": "Template updated successfully",
"templates.deleted": "Template deleted successfully",
@@ -195,5 +192,69 @@
"modal.discard_changes": "You have unsaved changes. Discard them?",
"confirm.title": "Confirm Action",
"confirm.yes": "Yes",
"confirm.no": "No"
"confirm.no": "No",
"common.delete": "Delete",
"common.edit": "Edit",
"streams.title": "\uD83D\uDCFA Picture Streams",
"streams.description": "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.",
"streams.add": "Add Picture Stream",
"streams.edit": "Edit Picture Stream",
"streams.name": "Stream Name:",
"streams.name.placeholder": "My Stream",
"streams.type": "Type:",
"streams.type.raw": "Screen Capture",
"streams.type.processed": "Processed",
"streams.display": "Display:",
"streams.capture_template": "Capture Template:",
"streams.target_fps": "Target FPS:",
"streams.source": "Source Stream:",
"streams.pp_template": "Processing Template:",
"streams.description_label": "Description (optional):",
"streams.description_placeholder": "Describe this stream...",
"streams.created": "Stream created successfully",
"streams.updated": "Stream updated successfully",
"streams.deleted": "Stream deleted successfully",
"streams.delete.confirm": "Are you sure you want to delete this stream?",
"streams.error.load": "Failed to load streams",
"streams.error.required": "Please fill in all required fields",
"streams.error.delete": "Failed to delete stream",
"streams.test.title": "Test Picture Stream",
"streams.test.run": "🧪 Run Test",
"streams.test.running": "Testing stream...",
"streams.test.duration": "Capture Duration (s):",
"streams.test.error.failed": "Stream test failed",
"postprocessing.title": "\uD83C\uDFA8 Processing Templates",
"postprocessing.description": "Processing templates define color correction and smoothing settings. Assign them to processed picture streams for consistent postprocessing across devices.",
"postprocessing.add": "Add Processing Template",
"postprocessing.edit": "Edit Processing Template",
"postprocessing.name": "Template Name:",
"postprocessing.name.placeholder": "My Processing Template",
"postprocessing.gamma": "Gamma:",
"postprocessing.saturation": "Saturation:",
"postprocessing.brightness": "Brightness:",
"postprocessing.smoothing": "Smoothing:",
"postprocessing.description_label": "Description (optional):",
"postprocessing.description_placeholder": "Describe this template...",
"postprocessing.created": "Template created successfully",
"postprocessing.updated": "Template updated successfully",
"postprocessing.deleted": "Template deleted successfully",
"postprocessing.delete.confirm": "Are you sure you want to delete this processing template?",
"postprocessing.error.load": "Failed to load processing templates",
"postprocessing.error.required": "Please fill in all required fields",
"postprocessing.error.delete": "Failed to delete processing template",
"postprocessing.config.show": "Show settings",
"device.button.stream_selector": "Stream Settings",
"device.stream_settings.title": "📺 Stream Settings",
"device.stream_selector.label": "Assigned Picture Stream:",
"device.stream_selector.hint": "Select a picture stream that defines what this device captures and processes",
"device.stream_selector.none": "-- No stream assigned --",
"device.stream_selector.saved": "Stream settings updated",
"device.stream_settings.border_width": "Border Width (px):",
"device.stream_settings.border_width_hint": "How many pixels from the screen edge to sample for LED colors (1-100)",
"device.stream_settings.interpolation": "Interpolation Mode:",
"device.stream_settings.interpolation.average": "Average",
"device.stream_settings.interpolation.median": "Median",
"device.stream_settings.interpolation.dominant": "Dominant",
"device.stream_settings.interpolation_hint": "How to calculate LED color from sampled pixels",
"device.tip.stream_selector": "Configure picture stream and LED projection settings for this device"
}

View File

@@ -50,9 +50,6 @@
"templates.config.show": "Показать конфигурацию",
"templates.config.none": "Нет дополнительных настроек",
"templates.config.default": "По умолчанию",
"templates.default": "По умолчанию",
"templates.custom": "Пользовательские шаблоны",
"templates.default.locked": "Системный шаблон (нельзя редактировать/удалить)",
"templates.created": "Шаблон успешно создан",
"templates.updated": "Шаблон успешно обновлён",
"templates.deleted": "Шаблон успешно удалён",
@@ -195,5 +192,69 @@
"modal.discard_changes": "У вас есть несохранённые изменения. Отменить их?",
"confirm.title": "Подтверждение Действия",
"confirm.yes": "Да",
"confirm.no": "Нет"
"confirm.no": "Нет",
"common.delete": "Удалить",
"common.edit": "Редактировать",
"streams.title": "\uD83D\uDCFA Видеопотоки",
"streams.description": "Видеопотоки определяют конвейер захвата. Сырой поток захватывает экран с помощью шаблона захвата. Обработанный поток применяет постобработку к другому потоку. Назначайте потоки устройствам.",
"streams.add": "Добавить Видеопоток",
"streams.edit": "Редактировать Видеопоток",
"streams.name": "Имя Потока:",
"streams.name.placeholder": "Мой Поток",
"streams.type": "Тип:",
"streams.type.raw": "Захват экрана",
"streams.type.processed": "Обработанный",
"streams.display": "Дисплей:",
"streams.capture_template": "Шаблон Захвата:",
"streams.target_fps": "Целевой FPS:",
"streams.source": "Исходный Поток:",
"streams.pp_template": "Шаблон Обработки:",
"streams.description_label": "Описание (необязательно):",
"streams.description_placeholder": "Опишите этот поток...",
"streams.created": "Поток успешно создан",
"streams.updated": "Поток успешно обновлён",
"streams.deleted": "Поток успешно удалён",
"streams.delete.confirm": "Вы уверены, что хотите удалить этот поток?",
"streams.error.load": "Не удалось загрузить потоки",
"streams.error.required": "Пожалуйста, заполните все обязательные поля",
"streams.error.delete": "Не удалось удалить поток",
"streams.test.title": "Тест Видеопотока",
"streams.test.run": "🧪 Запустить Тест",
"streams.test.running": "Тестирование потока...",
"streams.test.duration": "Длительность Захвата (с):",
"streams.test.error.failed": "Тест потока не удался",
"postprocessing.title": "\uD83C\uDFA8 Шаблоны Обработки",
"postprocessing.description": "Шаблоны обработки определяют настройки цветокоррекции и сглаживания. Назначайте их обработанным видеопотокам для единообразной постобработки на всех устройствах.",
"postprocessing.add": "Добавить Шаблон Обработки",
"postprocessing.edit": "Редактировать Шаблон Обработки",
"postprocessing.name": "Имя Шаблона:",
"postprocessing.name.placeholder": "Мой Шаблон Обработки",
"postprocessing.gamma": "Гамма:",
"postprocessing.saturation": "Насыщенность:",
"postprocessing.brightness": "Яркость:",
"postprocessing.smoothing": "Сглаживание:",
"postprocessing.description_label": "Описание (необязательно):",
"postprocessing.description_placeholder": "Опишите этот шаблон...",
"postprocessing.created": "Шаблон успешно создан",
"postprocessing.updated": "Шаблон успешно обновлён",
"postprocessing.deleted": "Шаблон успешно удалён",
"postprocessing.delete.confirm": "Вы уверены, что хотите удалить этот шаблон обработки?",
"postprocessing.error.load": "Не удалось загрузить шаблоны обработки",
"postprocessing.error.required": "Пожалуйста, заполните все обязательные поля",
"postprocessing.error.delete": "Не удалось удалить шаблон обработки",
"postprocessing.config.show": "Показать настройки",
"device.button.stream_selector": "Настройки потока",
"device.stream_settings.title": "📺 Настройки потока",
"device.stream_selector.label": "Назначенный Видеопоток:",
"device.stream_selector.hint": "Выберите видеопоток, определяющий что это устройство захватывает и обрабатывает",
"device.stream_selector.none": "-- Поток не назначен --",
"device.stream_selector.saved": "Настройки потока обновлены",
"device.stream_settings.border_width": "Ширина границы (px):",
"device.stream_settings.border_width_hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)",
"device.stream_settings.interpolation": "Режим интерполяции:",
"device.stream_settings.interpolation.average": "Среднее",
"device.stream_settings.interpolation.median": "Медиана",
"device.stream_settings.interpolation.dominant": "Доминантный",
"device.stream_settings.interpolation_hint": "Как вычислять цвет LED из выбранных пикселей",
"device.tip.stream_selector": "Настройки видеопотока и проекции LED для этого устройства"
}

View File

@@ -165,7 +165,8 @@ section {
gap: 20px;
}
.devices-grid > .loading {
.devices-grid > .loading,
.devices-grid > .loading-spinner {
grid-column: 1 / -1;
}
@@ -735,6 +736,27 @@ input:-webkit-autofill:focus {
color: #999;
}
.loading-spinner {
display: flex;
justify-content: center;
align-items: center;
padding: 40px;
}
.loading-spinner::after {
content: '';
width: 28px;
height: 28px;
border: 3px solid var(--border-color);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Full-page overlay spinner */
.overlay-spinner {
position: fixed;
@@ -1740,24 +1762,6 @@ input:-webkit-autofill:focus {
gap: 20px;
}
.templates-separator {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 12px;
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 500;
}
.templates-separator::before,
.templates-separator::after {
content: '';
flex: 1;
height: 1px;
background: var(--border-color);
}
.template-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
@@ -1815,6 +1819,9 @@ input:-webkit-autofill:focus {
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.template-card .template-card-header {
padding-right: 24px;
}
@@ -1832,11 +1839,6 @@ input:-webkit-autofill:focus {
text-transform: uppercase;
}
.badge-default {
background: var(--primary-color);
color: white;
}
.template-description {
color: var(--text-secondary);
font-size: 14px;
@@ -2013,6 +2015,36 @@ input:-webkit-autofill:focus {
font-size: 16px;
}
/* Stream type badges */
.badge-raw {
background: #1976d2;
color: white;
}
.badge-processed {
background: #7b1fa2;
color: white;
}
/* Stream info panel in stream selector modal */
.stream-info-panel {
background: var(--bg-secondary, #2a2a2a);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px 16px;
margin-top: 12px;
font-size: 14px;
line-height: 1.6;
}
.stream-info-panel div {
margin-bottom: 4px;
}
.stream-info-panel strong {
margin-right: 6px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.templates-grid {

View File

@@ -1,5 +1,7 @@
"""Storage layer for device and configuration persistence."""
from .device_store import DeviceStore
from .picture_stream_store import PictureStreamStore
from .postprocessing_template_store import PostprocessingTemplateStore
__all__ = ["DeviceStore"]
__all__ = ["DeviceStore", "PictureStreamStore", "PostprocessingTemplateStore"]

View File

@@ -30,7 +30,8 @@ class Device:
enabled: bool = True,
settings: Optional[ProcessingSettings] = None,
calibration: Optional[CalibrationConfig] = None,
capture_template_id: str = "tpl_mss_default",
capture_template_id: str = "",
picture_stream_id: str = "",
created_at: Optional[datetime] = None,
updated_at: Optional[datetime] = None,
):
@@ -44,7 +45,8 @@ class Device:
enabled: Whether device is enabled
settings: Processing settings
calibration: Calibration configuration
capture_template_id: ID of assigned capture template
capture_template_id: ID of assigned capture template (legacy, use picture_stream_id)
picture_stream_id: ID of assigned picture stream
created_at: Creation timestamp
updated_at: Last update timestamp
"""
@@ -56,6 +58,7 @@ class Device:
self.settings = settings or ProcessingSettings()
self.calibration = calibration or create_default_calibration(led_count)
self.capture_template_id = capture_template_id
self.picture_stream_id = picture_stream_id
self.created_at = created_at or datetime.utcnow()
self.updated_at = updated_at or datetime.utcnow()
@@ -84,6 +87,7 @@ class Device:
},
"calibration": calibration_to_dict(self.calibration),
"capture_template_id": self.capture_template_id,
"picture_stream_id": self.picture_stream_id,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@@ -121,11 +125,8 @@ class Device:
else create_default_calibration(data["led_count"])
)
# Migration: assign default MSS template if no template set
capture_template_id = data.get("capture_template_id")
if not capture_template_id:
capture_template_id = "tpl_mss_default"
logger.info(f"Migrating device {data['id']} to default MSS template")
capture_template_id = data.get("capture_template_id", "")
picture_stream_id = data.get("picture_stream_id", "")
return cls(
device_id=data["id"],
@@ -136,6 +137,7 @@ class Device:
settings=settings,
calibration=calibration,
capture_template_id=capture_template_id,
picture_stream_id=picture_stream_id,
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
)
@@ -217,7 +219,8 @@ class DeviceStore:
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 = "",
) -> Device:
"""Create a new device.
@@ -247,6 +250,7 @@ class DeviceStore:
settings=settings,
calibration=calibration,
capture_template_id=capture_template_id,
picture_stream_id=picture_stream_id,
)
# Store
@@ -285,6 +289,7 @@ class DeviceStore:
settings: Optional[ProcessingSettings] = None,
calibration: Optional[CalibrationConfig] = None,
capture_template_id: Optional[str] = None,
picture_stream_id: Optional[str] = None,
) -> Device:
"""Update device.
@@ -331,6 +336,8 @@ class DeviceStore:
device.calibration = calibration
if capture_template_id is not None:
device.capture_template_id = capture_template_id
if picture_stream_id is not None:
device.picture_stream_id = picture_stream_id
device.updated_at = datetime.utcnow()

View File

@@ -0,0 +1,69 @@
"""Picture stream data model."""
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass
class PictureStream:
"""Represents a picture stream configuration.
A picture stream is either:
- "raw": captures from a display using a capture engine template at a target FPS
- "processed": applies postprocessing to another picture stream
"""
id: str
name: str
stream_type: str # "raw" or "processed"
created_at: datetime
updated_at: datetime
# Raw stream fields (used when stream_type == "raw")
display_index: Optional[int] = None
capture_template_id: Optional[str] = None
target_fps: Optional[int] = None
# Processed stream fields (used when stream_type == "processed")
source_stream_id: Optional[str] = None
postprocessing_template_id: Optional[str] = None
description: Optional[str] = None
def to_dict(self) -> dict:
"""Convert stream to dictionary."""
return {
"id": self.id,
"name": self.name,
"stream_type": self.stream_type,
"display_index": self.display_index,
"capture_template_id": self.capture_template_id,
"target_fps": self.target_fps,
"source_stream_id": self.source_stream_id,
"postprocessing_template_id": self.postprocessing_template_id,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"description": self.description,
}
@classmethod
def from_dict(cls, data: dict) -> "PictureStream":
"""Create stream from dictionary."""
return cls(
id=data["id"],
name=data["name"],
stream_type=data["stream_type"],
display_index=data.get("display_index"),
capture_template_id=data.get("capture_template_id"),
target_fps=data.get("target_fps"),
source_stream_id=data.get("source_stream_id"),
postprocessing_template_id=data.get("postprocessing_template_id"),
created_at=datetime.fromisoformat(data["created_at"])
if isinstance(data.get("created_at"), str)
else data.get("created_at", datetime.utcnow()),
updated_at=datetime.fromisoformat(data["updated_at"])
if isinstance(data.get("updated_at"), str)
else data.get("updated_at", datetime.utcnow()),
description=data.get("description"),
)

View File

@@ -0,0 +1,332 @@
"""Picture stream storage using JSON files."""
import json
import uuid
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Set
from wled_controller.storage.picture_stream import PictureStream
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class PictureStreamStore:
"""Storage for picture streams.
Supports raw and processed stream types with cycle detection
for processed streams that reference other streams.
"""
def __init__(self, file_path: str):
"""Initialize picture stream store.
Args:
file_path: Path to streams JSON file
"""
self.file_path = Path(file_path)
self._streams: Dict[str, PictureStream] = {}
self._load()
def _load(self) -> None:
"""Load streams from file."""
if not self.file_path.exists():
return
try:
with open(self.file_path, "r", encoding="utf-8") as f:
data = json.load(f)
streams_data = data.get("picture_streams", {})
loaded = 0
for stream_id, stream_dict in streams_data.items():
try:
stream = PictureStream.from_dict(stream_dict)
self._streams[stream_id] = stream
loaded += 1
except Exception as e:
logger.error(
f"Failed to load picture stream {stream_id}: {e}",
exc_info=True,
)
if loaded > 0:
logger.info(f"Loaded {loaded} picture streams from storage")
except Exception as e:
logger.error(f"Failed to load picture streams from {self.file_path}: {e}")
raise
logger.info(f"Picture stream store initialized with {len(self._streams)} streams")
def _save(self) -> None:
"""Save all streams to file."""
try:
self.file_path.parent.mkdir(parents=True, exist_ok=True)
streams_dict = {
stream_id: stream.to_dict()
for stream_id, stream in self._streams.items()
}
data = {
"version": "1.0.0",
"picture_streams": streams_dict,
}
with open(self.file_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
except Exception as e:
logger.error(f"Failed to save picture streams to {self.file_path}: {e}")
raise
def _detect_cycle(self, source_stream_id: str, exclude_stream_id: Optional[str] = None) -> bool:
"""Detect if following the source chain from source_stream_id would create a cycle.
Args:
source_stream_id: The source stream ID to start walking from
exclude_stream_id: Stream ID to exclude (the stream being created/updated)
Returns:
True if a cycle would be created
"""
visited: Set[str] = set()
if exclude_stream_id:
visited.add(exclude_stream_id)
current_id = source_stream_id
while current_id:
if current_id in visited:
return True
visited.add(current_id)
current_stream = self._streams.get(current_id)
if not current_stream:
break
if current_stream.stream_type == "raw":
break
current_id = current_stream.source_stream_id
return False
def get_all_streams(self) -> List[PictureStream]:
"""Get all picture streams."""
return list(self._streams.values())
def get_stream(self, stream_id: str) -> PictureStream:
"""Get stream by ID.
Raises:
ValueError: If stream not found
"""
if stream_id not in self._streams:
raise ValueError(f"Picture stream not found: {stream_id}")
return self._streams[stream_id]
def create_stream(
self,
name: str,
stream_type: str,
display_index: Optional[int] = None,
capture_template_id: Optional[str] = None,
target_fps: Optional[int] = None,
source_stream_id: Optional[str] = None,
postprocessing_template_id: Optional[str] = None,
description: Optional[str] = None,
) -> PictureStream:
"""Create a new picture stream.
Args:
name: Stream name
stream_type: "raw" or "processed"
display_index: Display index (raw streams)
capture_template_id: Capture template ID (raw streams)
target_fps: Target FPS (raw streams)
source_stream_id: Source stream ID (processed streams)
postprocessing_template_id: Postprocessing template ID (processed streams)
description: Optional description
Raises:
ValueError: If validation fails or cycle detected
"""
if stream_type not in ("raw", "processed"):
raise ValueError(f"Invalid stream type: {stream_type}")
if stream_type == "raw":
if display_index is None:
raise ValueError("Raw streams require display_index")
if not capture_template_id:
raise ValueError("Raw streams require capture_template_id")
if target_fps is None:
raise ValueError("Raw streams require target_fps")
elif stream_type == "processed":
if not source_stream_id:
raise ValueError("Processed streams require source_stream_id")
if not postprocessing_template_id:
raise ValueError("Processed streams require postprocessing_template_id")
# Validate source stream exists
if source_stream_id not in self._streams:
raise ValueError(f"Source stream not found: {source_stream_id}")
# Check for cycles
if self._detect_cycle(source_stream_id):
raise ValueError("Cycle detected in stream chain")
# Check for duplicate name
for stream in self._streams.values():
if stream.name == name:
raise ValueError(f"Picture stream with name '{name}' already exists")
stream_id = f"ps_{uuid.uuid4().hex[:8]}"
now = datetime.utcnow()
stream = PictureStream(
id=stream_id,
name=name,
stream_type=stream_type,
display_index=display_index,
capture_template_id=capture_template_id,
target_fps=target_fps,
source_stream_id=source_stream_id,
postprocessing_template_id=postprocessing_template_id,
created_at=now,
updated_at=now,
description=description,
)
self._streams[stream_id] = stream
self._save()
logger.info(f"Created picture stream: {name} ({stream_id}, type={stream_type})")
return stream
def update_stream(
self,
stream_id: str,
name: Optional[str] = None,
display_index: Optional[int] = None,
capture_template_id: Optional[str] = None,
target_fps: Optional[int] = None,
source_stream_id: Optional[str] = None,
postprocessing_template_id: Optional[str] = None,
description: Optional[str] = None,
) -> PictureStream:
"""Update an existing picture stream.
Raises:
ValueError: If stream not found, validation fails, or cycle detected
"""
if stream_id not in self._streams:
raise ValueError(f"Picture stream not found: {stream_id}")
stream = self._streams[stream_id]
# If changing source_stream_id on a processed stream, check for cycles
if source_stream_id is not None and stream.stream_type == "processed":
if source_stream_id not in self._streams:
raise ValueError(f"Source stream not found: {source_stream_id}")
if self._detect_cycle(source_stream_id, exclude_stream_id=stream_id):
raise ValueError("Cycle detected in stream chain")
if name is not None:
stream.name = name
if display_index is not None:
stream.display_index = display_index
if capture_template_id is not None:
stream.capture_template_id = capture_template_id
if target_fps is not None:
stream.target_fps = target_fps
if source_stream_id is not None:
stream.source_stream_id = source_stream_id
if postprocessing_template_id is not None:
stream.postprocessing_template_id = postprocessing_template_id
if description is not None:
stream.description = description
stream.updated_at = datetime.utcnow()
self._save()
logger.info(f"Updated picture stream: {stream_id}")
return stream
def delete_stream(self, stream_id: str) -> None:
"""Delete a picture stream.
Raises:
ValueError: If stream not found or is referenced by another stream
"""
if stream_id not in self._streams:
raise ValueError(f"Picture stream not found: {stream_id}")
# Check if any other stream references this one as source
for other_stream in self._streams.values():
if other_stream.source_stream_id == stream_id:
raise ValueError(
f"Cannot delete stream '{self._streams[stream_id].name}': "
f"it is referenced by stream '{other_stream.name}'"
)
del self._streams[stream_id]
self._save()
logger.info(f"Deleted picture stream: {stream_id}")
def is_referenced_by_device(self, stream_id: str, device_store) -> bool:
"""Check if this stream is referenced by any device.
Args:
stream_id: Stream ID to check
device_store: DeviceStore instance
Returns:
True if any device references this stream
"""
for device in device_store.get_all_devices():
if getattr(device, "picture_stream_id", None) == stream_id:
return True
return False
def resolve_stream_chain(self, stream_id: str) -> dict:
"""Resolve a stream chain to get the final raw stream and collected postprocessing templates.
Walks the chain from the given stream to the root raw stream,
collecting postprocessing template IDs along the way.
Args:
stream_id: Starting stream ID
Returns:
Dict with:
- raw_stream: The root raw PictureStream
- postprocessing_template_ids: List of PP template IDs (in chain order)
Raises:
ValueError: If stream not found or chain is broken
"""
postprocessing_template_ids = []
visited = set()
current_id = stream_id
while True:
if current_id in visited:
raise ValueError(f"Cycle detected in stream chain at {current_id}")
visited.add(current_id)
stream = self.get_stream(current_id)
if stream.stream_type == "raw":
return {
"raw_stream": stream,
"postprocessing_template_ids": postprocessing_template_ids,
}
# Processed stream — collect PP template and follow source
if stream.postprocessing_template_id:
postprocessing_template_ids.append(stream.postprocessing_template_id)
if not stream.source_stream_id:
raise ValueError(f"Processed stream {current_id} has no source_stream_id")
current_id = stream.source_stream_id

View File

@@ -0,0 +1,53 @@
"""Postprocessing template data model."""
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass
class PostprocessingTemplate:
"""Postprocessing settings template for color correction and smoothing."""
id: str
name: str
gamma: float
saturation: float
brightness: float
smoothing: float
created_at: datetime
updated_at: datetime
description: Optional[str] = None
def to_dict(self) -> dict:
"""Convert template to dictionary."""
return {
"id": self.id,
"name": self.name,
"gamma": self.gamma,
"saturation": self.saturation,
"brightness": self.brightness,
"smoothing": self.smoothing,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"description": self.description,
}
@classmethod
def from_dict(cls, data: dict) -> "PostprocessingTemplate":
"""Create template from dictionary."""
return cls(
id=data["id"],
name=data["name"],
gamma=data.get("gamma", 2.2),
saturation=data.get("saturation", 1.0),
brightness=data.get("brightness", 1.0),
smoothing=data.get("smoothing", 0.3),
created_at=datetime.fromisoformat(data["created_at"])
if isinstance(data.get("created_at"), str)
else data.get("created_at", datetime.utcnow()),
updated_at=datetime.fromisoformat(data["updated_at"])
if isinstance(data.get("updated_at"), str)
else data.get("updated_at", datetime.utcnow()),
description=data.get("description"),
)

View File

@@ -0,0 +1,230 @@
"""Postprocessing template storage using JSON files."""
import json
import uuid
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from wled_controller.storage.postprocessing_template import PostprocessingTemplate
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class PostprocessingTemplateStore:
"""Storage for postprocessing templates.
All templates are persisted to the JSON file.
On startup, if no templates exist, a default one is auto-created.
"""
def __init__(self, file_path: str):
"""Initialize postprocessing template store.
Args:
file_path: Path to templates JSON file
"""
self.file_path = Path(file_path)
self._templates: Dict[str, PostprocessingTemplate] = {}
self._load()
self._ensure_initial_template()
def _ensure_initial_template(self) -> None:
"""Auto-create a default postprocessing template if none exist."""
if self._templates:
return
now = datetime.utcnow()
template_id = f"pp_{uuid.uuid4().hex[:8]}"
template = PostprocessingTemplate(
id=template_id,
name="Default",
gamma=2.2,
saturation=1.0,
brightness=1.0,
smoothing=0.3,
created_at=now,
updated_at=now,
description="Default postprocessing template",
)
self._templates[template_id] = template
self._save()
logger.info(f"Auto-created initial postprocessing template: {template.name} ({template_id})")
def _load(self) -> None:
"""Load templates from file."""
if not self.file_path.exists():
return
try:
with open(self.file_path, "r", encoding="utf-8") as f:
data = json.load(f)
templates_data = data.get("postprocessing_templates", {})
loaded = 0
for template_id, template_dict in templates_data.items():
try:
template = PostprocessingTemplate.from_dict(template_dict)
self._templates[template_id] = template
loaded += 1
except Exception as e:
logger.error(
f"Failed to load postprocessing template {template_id}: {e}",
exc_info=True,
)
if loaded > 0:
logger.info(f"Loaded {loaded} postprocessing templates from storage")
except Exception as e:
logger.error(f"Failed to load postprocessing templates from {self.file_path}: {e}")
raise
logger.info(f"Postprocessing template store initialized with {len(self._templates)} templates")
def _save(self) -> None:
"""Save all templates to file."""
try:
self.file_path.parent.mkdir(parents=True, exist_ok=True)
templates_dict = {
template_id: template.to_dict()
for template_id, template in self._templates.items()
}
data = {
"version": "1.0.0",
"postprocessing_templates": templates_dict,
}
with open(self.file_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
except Exception as e:
logger.error(f"Failed to save postprocessing templates to {self.file_path}: {e}")
raise
def get_all_templates(self) -> List[PostprocessingTemplate]:
"""Get all postprocessing templates."""
return list(self._templates.values())
def get_template(self, template_id: str) -> PostprocessingTemplate:
"""Get template by ID.
Raises:
ValueError: If template not found
"""
if template_id not in self._templates:
raise ValueError(f"Postprocessing template not found: {template_id}")
return self._templates[template_id]
def create_template(
self,
name: str,
gamma: float = 2.2,
saturation: float = 1.0,
brightness: float = 1.0,
smoothing: float = 0.3,
description: Optional[str] = None,
) -> PostprocessingTemplate:
"""Create a new postprocessing template.
Raises:
ValueError: If template with same name exists
"""
for template in self._templates.values():
if template.name == name:
raise ValueError(f"Postprocessing template with name '{name}' already exists")
template_id = f"pp_{uuid.uuid4().hex[:8]}"
now = datetime.utcnow()
template = PostprocessingTemplate(
id=template_id,
name=name,
gamma=gamma,
saturation=saturation,
brightness=brightness,
smoothing=smoothing,
created_at=now,
updated_at=now,
description=description,
)
self._templates[template_id] = template
self._save()
logger.info(f"Created postprocessing template: {name} ({template_id})")
return template
def update_template(
self,
template_id: str,
name: Optional[str] = None,
gamma: Optional[float] = None,
saturation: Optional[float] = None,
brightness: Optional[float] = None,
smoothing: Optional[float] = None,
description: Optional[str] = None,
) -> PostprocessingTemplate:
"""Update an existing postprocessing template.
Raises:
ValueError: If template not found
"""
if template_id not in self._templates:
raise ValueError(f"Postprocessing template not found: {template_id}")
template = self._templates[template_id]
if name is not None:
template.name = name
if gamma is not None:
template.gamma = gamma
if saturation is not None:
template.saturation = saturation
if brightness is not None:
template.brightness = brightness
if smoothing is not None:
template.smoothing = smoothing
if description is not None:
template.description = description
template.updated_at = datetime.utcnow()
self._save()
logger.info(f"Updated postprocessing template: {template_id}")
return template
def delete_template(self, template_id: str) -> None:
"""Delete a postprocessing template.
Raises:
ValueError: If template not found or is referenced by a picture stream
"""
if template_id not in self._templates:
raise ValueError(f"Postprocessing template not found: {template_id}")
del self._templates[template_id]
self._save()
logger.info(f"Deleted postprocessing template: {template_id}")
def is_referenced_by(self, template_id: str, picture_stream_store) -> bool:
"""Check if this template is referenced by any picture stream.
Args:
template_id: Template ID to check
picture_stream_store: PictureStreamStore instance
Returns:
True if any picture stream references this template
"""
for stream in picture_stream_store.get_all_streams():
if stream.postprocessing_template_id == template_id:
return True
return False

View File

@@ -13,7 +13,6 @@ class CaptureTemplate:
name: str
engine_type: str
engine_config: Dict[str, Any]
is_default: bool
created_at: datetime
updated_at: datetime
description: Optional[str] = None
@@ -29,7 +28,6 @@ class CaptureTemplate:
"name": self.name,
"engine_type": self.engine_type,
"engine_config": self.engine_config,
"is_default": self.is_default,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"description": self.description,
@@ -50,7 +48,6 @@ class CaptureTemplate:
name=data["name"],
engine_type=data["engine_type"],
engine_config=data.get("engine_config", {}),
is_default=data.get("is_default", False),
created_at=datetime.fromisoformat(data["created_at"])
if isinstance(data.get("created_at"), str)
else data.get("created_at", datetime.utcnow()),

View File

@@ -16,8 +16,9 @@ logger = get_logger(__name__)
class TemplateStore:
"""Storage for capture templates.
Default templates for each available engine are created in memory at startup.
Only user-created templates are persisted to the JSON file.
All templates are persisted to the JSON file.
On startup, if no templates exist, one is auto-created using the
highest-priority available engine.
"""
def __init__(self, file_path: str):
@@ -28,34 +29,40 @@ class TemplateStore:
"""
self.file_path = Path(file_path)
self._templates: Dict[str, CaptureTemplate] = {}
self._ensure_defaults()
self._load()
self._ensure_initial_template()
def _ensure_defaults(self) -> None:
"""Create default templates in memory for all available engines."""
available = EngineRegistry.get_available_engines()
def _ensure_initial_template(self) -> None:
"""Auto-create a template if none exist, using the best available engine."""
if self._templates:
return
best_engine = EngineRegistry.get_best_available_engine()
if not best_engine:
logger.warning("No capture engines available, cannot create initial template")
return
engine_class = EngineRegistry.get_engine(best_engine)
default_config = engine_class.get_default_config()
now = datetime.utcnow()
template_id = f"tpl_{uuid.uuid4().hex[:8]}"
for engine_type in available:
template_id = f"tpl_{engine_type}_default"
engine_class = EngineRegistry.get_engine(engine_type)
default_config = engine_class.get_default_config()
template = CaptureTemplate(
id=template_id,
name=best_engine.upper(),
engine_type=best_engine,
engine_config=default_config,
created_at=now,
updated_at=now,
description=f"Auto-created {best_engine.upper()} template",
)
self._templates[template_id] = CaptureTemplate(
id=template_id,
name=engine_type.upper(),
engine_type=engine_type,
engine_config=default_config,
is_default=True,
created_at=now,
updated_at=now,
description=f"Default {engine_type} capture template",
)
logger.info(f"Created {len(available)} default templates in memory")
self._templates[template_id] = template
self._save()
logger.info(f"Auto-created initial template: {template.name} ({template_id}, engine={best_engine})")
def _load(self) -> None:
"""Load user-created templates from file."""
"""Load templates from file."""
if not self.file_path.exists():
return
@@ -66,9 +73,6 @@ class TemplateStore:
templates_data = data.get("templates", {})
loaded = 0
for template_id, template_dict in templates_data.items():
# Skip any default templates that may exist in old files
if template_dict.get("is_default", False):
continue
try:
template = CaptureTemplate.from_dict(template_dict)
self._templates[template_id] = template
@@ -80,26 +84,23 @@ class TemplateStore:
)
if loaded > 0:
logger.info(f"Loaded {loaded} user templates from storage")
logger.info(f"Loaded {loaded} templates from storage")
except Exception as e:
logger.error(f"Failed to load templates from {self.file_path}: {e}")
raise
total = len(self._templates)
logger.info(f"Template store initialized with {total} templates")
logger.info(f"Template store initialized with {len(self._templates)} templates")
def _save(self) -> None:
"""Save only user-created templates to file."""
"""Save all templates to file."""
try:
# Ensure directory exists
self.file_path.parent.mkdir(parents=True, exist_ok=True)
# Only persist non-default templates
templates_dict = {
template_id: template.to_dict()
for template_id, template in self._templates.items()
if not template.is_default
}
data = {
@@ -162,7 +163,7 @@ class TemplateStore:
"""
# Check for duplicate name
for template in self._templates.values():
if template.name == name and not template.is_default:
if template.name == name:
raise ValueError(f"Template with name '{name}' already exists")
# Generate new ID
@@ -175,7 +176,6 @@ class TemplateStore:
name=name,
engine_type=engine_type,
engine_config=engine_config,
is_default=False,
created_at=now,
updated_at=now,
description=description,
@@ -209,16 +209,13 @@ class TemplateStore:
Updated template
Raises:
ValueError: If template not found or is a default template
ValueError: If template not found
"""
if template_id not in self._templates:
raise ValueError(f"Template not found: {template_id}")
template = self._templates[template_id]
if template.is_default:
raise ValueError("Cannot modify default templates")
# Update fields
if name is not None:
template.name = name
@@ -244,16 +241,11 @@ class TemplateStore:
template_id: Template ID
Raises:
ValueError: If template not found or is a default template
ValueError: If template not found
"""
if template_id not in self._templates:
raise ValueError(f"Template not found: {template_id}")
template = self._templates[template_id]
if template.is_default:
raise ValueError("Cannot delete default templates")
# Remove and save
del self._templates[template_id]
self._save()