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>
This commit is contained in:
@@ -40,6 +40,15 @@ from wled_controller.api.schemas import (
|
|||||||
CaptureImage,
|
CaptureImage,
|
||||||
BorderExtraction,
|
BorderExtraction,
|
||||||
PerformanceMetrics,
|
PerformanceMetrics,
|
||||||
|
PostprocessingTemplateCreate,
|
||||||
|
PostprocessingTemplateUpdate,
|
||||||
|
PostprocessingTemplateResponse,
|
||||||
|
PostprocessingTemplateListResponse,
|
||||||
|
PictureStreamCreate,
|
||||||
|
PictureStreamUpdate,
|
||||||
|
PictureStreamResponse,
|
||||||
|
PictureStreamListResponse,
|
||||||
|
PictureStreamTestRequest,
|
||||||
)
|
)
|
||||||
from wled_controller.config import get_config
|
from wled_controller.config import get_config
|
||||||
from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings
|
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 import DeviceStore
|
||||||
from wled_controller.storage.template_store import TemplateStore
|
from wled_controller.storage.template_store import TemplateStore
|
||||||
|
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||||
|
from wled_controller.storage.picture_stream_store import PictureStreamStore
|
||||||
from wled_controller.core.capture_engines import EngineRegistry
|
from wled_controller.core.capture_engines import EngineRegistry
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
from wled_controller.core.screen_capture import get_available_displays
|
from wled_controller.core.screen_capture import get_available_displays
|
||||||
@@ -60,6 +71,8 @@ router = APIRouter()
|
|||||||
# Global instances (initialized in main.py)
|
# Global instances (initialized in main.py)
|
||||||
_device_store: DeviceStore | None = None
|
_device_store: DeviceStore | None = None
|
||||||
_template_store: TemplateStore | None = None
|
_template_store: TemplateStore | None = None
|
||||||
|
_pp_template_store: PostprocessingTemplateStore | None = None
|
||||||
|
_picture_stream_store: PictureStreamStore | None = None
|
||||||
_processor_manager: ProcessorManager | None = None
|
_processor_manager: ProcessorManager | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -77,6 +90,20 @@ def get_template_store() -> TemplateStore:
|
|||||||
return _template_store
|
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:
|
def get_processor_manager() -> ProcessorManager:
|
||||||
"""Get processor manager dependency."""
|
"""Get processor manager dependency."""
|
||||||
if _processor_manager is None:
|
if _processor_manager is None:
|
||||||
@@ -88,12 +115,16 @@ def init_dependencies(
|
|||||||
device_store: DeviceStore,
|
device_store: DeviceStore,
|
||||||
template_store: TemplateStore,
|
template_store: TemplateStore,
|
||||||
processor_manager: ProcessorManager,
|
processor_manager: ProcessorManager,
|
||||||
|
pp_template_store: PostprocessingTemplateStore | None = None,
|
||||||
|
picture_stream_store: PictureStreamStore | None = None,
|
||||||
):
|
):
|
||||||
"""Initialize global dependencies."""
|
"""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
|
_device_store = device_store
|
||||||
_template_store = template_store
|
_template_store = template_store
|
||||||
_processor_manager = processor_manager
|
_processor_manager = processor_manager
|
||||||
|
_pp_template_store = pp_template_store
|
||||||
|
_picture_stream_store = picture_stream_store
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health", response_model=HealthResponse, tags=["Health"])
|
@router.get("/health", response_model=HealthResponse, tags=["Health"])
|
||||||
@@ -230,7 +261,10 @@ async def create_device(
|
|||||||
if all_templates:
|
if all_templates:
|
||||||
capture_template_id = all_templates[0].id
|
capture_template_id = all_templates[0].id
|
||||||
else:
|
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)
|
# Create device in storage (LED count auto-detected from WLED)
|
||||||
device = store.create_device(
|
device = store.create_device(
|
||||||
@@ -260,11 +294,13 @@ async def create_device(
|
|||||||
display_index=device.settings.display_index,
|
display_index=device.settings.display_index,
|
||||||
fps=device.settings.fps,
|
fps=device.settings.fps,
|
||||||
border_width=device.settings.border_width,
|
border_width=device.settings.border_width,
|
||||||
|
interpolation_mode=device.settings.interpolation_mode,
|
||||||
brightness=device.settings.brightness,
|
brightness=device.settings.brightness,
|
||||||
state_check_interval=device.settings.state_check_interval,
|
state_check_interval=device.settings.state_check_interval,
|
||||||
),
|
),
|
||||||
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
||||||
capture_template_id=device.capture_template_id,
|
capture_template_id=device.capture_template_id,
|
||||||
|
picture_stream_id=device.picture_stream_id,
|
||||||
created_at=device.created_at,
|
created_at=device.created_at,
|
||||||
updated_at=device.updated_at,
|
updated_at=device.updated_at,
|
||||||
)
|
)
|
||||||
@@ -300,6 +336,7 @@ async def list_devices(
|
|||||||
),
|
),
|
||||||
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
||||||
capture_template_id=device.capture_template_id,
|
capture_template_id=device.capture_template_id,
|
||||||
|
picture_stream_id=device.picture_stream_id,
|
||||||
created_at=device.created_at,
|
created_at=device.created_at,
|
||||||
updated_at=device.updated_at,
|
updated_at=device.updated_at,
|
||||||
)
|
)
|
||||||
@@ -359,8 +396,12 @@ async def update_device(
|
|||||||
):
|
):
|
||||||
"""Update device information."""
|
"""Update device information."""
|
||||||
try:
|
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)
|
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 = (
|
template_changed = (
|
||||||
update_data.capture_template_id is not None
|
update_data.capture_template_id is not None
|
||||||
and update_data.capture_template_id != old_device.capture_template_id
|
and update_data.capture_template_id != old_device.capture_template_id
|
||||||
@@ -374,16 +415,17 @@ async def update_device(
|
|||||||
url=update_data.url,
|
url=update_data.url,
|
||||||
enabled=update_data.enabled,
|
enabled=update_data.enabled,
|
||||||
capture_template_id=update_data.capture_template_id,
|
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
|
# Hot-swap: If stream/template changed and device was processing, restart it
|
||||||
if template_changed and was_processing:
|
if (stream_changed or template_changed) and was_processing:
|
||||||
logger.info(f"Hot-swapping template for device {device_id}")
|
logger.info(f"Hot-swapping stream/template for device {device_id}")
|
||||||
try:
|
try:
|
||||||
# Stop current processing
|
# Stop current processing
|
||||||
await manager.stop_processing(device_id)
|
await manager.stop_processing(device_id)
|
||||||
|
|
||||||
# Update processor with new template
|
# Update processor with new settings
|
||||||
manager.remove_device(device_id)
|
manager.remove_device(device_id)
|
||||||
manager.add_device(
|
manager.add_device(
|
||||||
device_id=device.id,
|
device_id=device.id,
|
||||||
@@ -392,11 +434,12 @@ async def update_device(
|
|||||||
settings=device.settings,
|
settings=device.settings,
|
||||||
calibration=device.calibration,
|
calibration=device.calibration,
|
||||||
capture_template_id=device.capture_template_id,
|
capture_template_id=device.capture_template_id,
|
||||||
|
picture_stream_id=device.picture_stream_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Restart processing
|
# Restart processing
|
||||||
await manager.start_processing(device_id)
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error during template hot-swap: {e}")
|
logger.error(f"Error during template hot-swap: {e}")
|
||||||
@@ -413,11 +456,13 @@ async def update_device(
|
|||||||
display_index=device.settings.display_index,
|
display_index=device.settings.display_index,
|
||||||
fps=device.settings.fps,
|
fps=device.settings.fps,
|
||||||
border_width=device.settings.border_width,
|
border_width=device.settings.border_width,
|
||||||
|
interpolation_mode=device.settings.interpolation_mode,
|
||||||
brightness=device.settings.brightness,
|
brightness=device.settings.brightness,
|
||||||
state_check_interval=device.settings.state_check_interval,
|
state_check_interval=device.settings.state_check_interval,
|
||||||
),
|
),
|
||||||
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
||||||
capture_template_id=device.capture_template_id,
|
capture_template_id=device.capture_template_id,
|
||||||
|
picture_stream_id=device.picture_stream_id,
|
||||||
created_at=device.created_at,
|
created_at=device.created_at,
|
||||||
updated_at=device.updated_at,
|
updated_at=device.updated_at,
|
||||||
)
|
)
|
||||||
@@ -540,6 +585,7 @@ async def get_settings(
|
|||||||
display_index=device.settings.display_index,
|
display_index=device.settings.display_index,
|
||||||
fps=device.settings.fps,
|
fps=device.settings.fps,
|
||||||
border_width=device.settings.border_width,
|
border_width=device.settings.border_width,
|
||||||
|
interpolation_mode=device.settings.interpolation_mode,
|
||||||
brightness=device.settings.brightness,
|
brightness=device.settings.brightness,
|
||||||
state_check_interval=device.settings.state_check_interval,
|
state_check_interval=device.settings.state_check_interval,
|
||||||
)
|
)
|
||||||
@@ -553,16 +599,28 @@ async def update_settings(
|
|||||||
store: DeviceStore = Depends(get_device_store),
|
store: DeviceStore = Depends(get_device_store),
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
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:
|
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(
|
new_settings = ProcessingSettings(
|
||||||
display_index=settings.display_index,
|
display_index=settings.display_index,
|
||||||
fps=settings.fps,
|
fps=settings.fps,
|
||||||
border_width=settings.border_width,
|
border_width=settings.border_width,
|
||||||
brightness=settings.color_correction.brightness if settings.color_correction else 1.0,
|
interpolation_mode=settings.interpolation_mode,
|
||||||
gamma=settings.color_correction.gamma if settings.color_correction else 2.2,
|
brightness=settings.color_correction.brightness if settings.color_correction else existing.brightness,
|
||||||
saturation=settings.color_correction.saturation if settings.color_correction else 1.0,
|
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,
|
state_check_interval=settings.state_check_interval,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -580,6 +638,7 @@ async def update_settings(
|
|||||||
display_index=device.settings.display_index,
|
display_index=device.settings.display_index,
|
||||||
fps=device.settings.fps,
|
fps=device.settings.fps,
|
||||||
border_width=device.settings.border_width,
|
border_width=device.settings.border_width,
|
||||||
|
interpolation_mode=device.settings.interpolation_mode,
|
||||||
brightness=device.settings.brightness,
|
brightness=device.settings.brightness,
|
||||||
state_check_interval=device.settings.state_check_interval,
|
state_check_interval=device.settings.state_check_interval,
|
||||||
)
|
)
|
||||||
@@ -736,7 +795,7 @@ async def list_templates(
|
|||||||
name=t.name,
|
name=t.name,
|
||||||
engine_type=t.engine_type,
|
engine_type=t.engine_type,
|
||||||
engine_config=t.engine_config,
|
engine_config=t.engine_config,
|
||||||
is_default=t.is_default,
|
|
||||||
created_at=t.created_at,
|
created_at=t.created_at,
|
||||||
updated_at=t.updated_at,
|
updated_at=t.updated_at,
|
||||||
description=t.description,
|
description=t.description,
|
||||||
@@ -774,7 +833,7 @@ async def create_template(
|
|||||||
name=template.name,
|
name=template.name,
|
||||||
engine_type=template.engine_type,
|
engine_type=template.engine_type,
|
||||||
engine_config=template.engine_config,
|
engine_config=template.engine_config,
|
||||||
is_default=template.is_default,
|
|
||||||
created_at=template.created_at,
|
created_at=template.created_at,
|
||||||
updated_at=template.updated_at,
|
updated_at=template.updated_at,
|
||||||
description=template.description,
|
description=template.description,
|
||||||
@@ -804,7 +863,6 @@ async def get_template(
|
|||||||
name=template.name,
|
name=template.name,
|
||||||
engine_type=template.engine_type,
|
engine_type=template.engine_type,
|
||||||
engine_config=template.engine_config,
|
engine_config=template.engine_config,
|
||||||
is_default=template.is_default,
|
|
||||||
created_at=template.created_at,
|
created_at=template.created_at,
|
||||||
updated_at=template.updated_at,
|
updated_at=template.updated_at,
|
||||||
description=template.description,
|
description=template.description,
|
||||||
@@ -833,7 +891,7 @@ async def update_template(
|
|||||||
name=template.name,
|
name=template.name,
|
||||||
engine_type=template.engine_type,
|
engine_type=template.engine_type,
|
||||||
engine_config=template.engine_config,
|
engine_config=template.engine_config,
|
||||||
is_default=template.is_default,
|
|
||||||
created_at=template.created_at,
|
created_at=template.created_at,
|
||||||
updated_at=template.updated_at,
|
updated_at=template.updated_at,
|
||||||
description=template.description,
|
description=template.description,
|
||||||
@@ -1038,3 +1096,441 @@ async def test_template(
|
|||||||
engine.cleanup()
|
engine.cleanup()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error cleaning up test engine: {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}")
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ class DeviceUpdate(BaseModel):
|
|||||||
name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100)
|
name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100)
|
||||||
url: Optional[str] = Field(None, description="WLED device URL")
|
url: Optional[str] = Field(None, description="WLED device URL")
|
||||||
enabled: Optional[bool] = Field(None, description="Whether device is enabled")
|
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):
|
class ColorCorrection(BaseModel):
|
||||||
@@ -81,6 +82,7 @@ class ProcessingSettings(BaseModel):
|
|||||||
display_index: int = Field(default=0, description="Display to capture", ge=0)
|
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)
|
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)
|
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)
|
brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0)
|
||||||
state_check_interval: int = Field(
|
state_check_interval: int = Field(
|
||||||
default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600,
|
default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600,
|
||||||
@@ -155,7 +157,8 @@ class DeviceResponse(BaseModel):
|
|||||||
)
|
)
|
||||||
settings: ProcessingSettings = Field(description="Processing settings")
|
settings: ProcessingSettings = Field(description="Processing settings")
|
||||||
calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
|
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")
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
updated_at: datetime = Field(description="Last update timestamp")
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
|
|
||||||
@@ -242,7 +245,6 @@ class TemplateResponse(BaseModel):
|
|||||||
name: str = Field(description="Template name")
|
name: str = Field(description="Template name")
|
||||||
engine_type: str = Field(description="Engine type identifier")
|
engine_type: str = Field(description="Engine type identifier")
|
||||||
engine_config: Dict = Field(description="Engine-specific configuration")
|
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")
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
updated_at: datetime = Field(description="Last update timestamp")
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
description: Optional[str] = Field(None, description="Template description")
|
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")
|
full_capture: CaptureImage = Field(description="Full screen capture with thumbnail")
|
||||||
border_extraction: Optional[BorderExtraction] = Field(None, description="Extracted border images (deprecated)")
|
border_extraction: Optional[BorderExtraction] = Field(None, description="Extracted border images (deprecated)")
|
||||||
performance: PerformanceMetrics = Field(description="Performance metrics")
|
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")
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ class StorageConfig(BaseSettings):
|
|||||||
|
|
||||||
devices_file: str = "data/devices.json"
|
devices_file: str = "data/devices.json"
|
||||||
templates_file: str = "data/capture_templates.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):
|
class LoggingConfig(BaseSettings):
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class CaptureEngine(ABC):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
ENGINE_TYPE: str = "base" # Override in subclasses
|
ENGINE_TYPE: str = "base" # Override in subclasses
|
||||||
|
ENGINE_PRIORITY: int = 0 # Higher = preferred. Override in subclasses.
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any]):
|
def __init__(self, config: Dict[str, Any]):
|
||||||
"""Initialize engine with configuration.
|
"""Initialize engine with configuration.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class DXcamEngine(CaptureEngine):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
ENGINE_TYPE = "dxcam"
|
ENGINE_TYPE = "dxcam"
|
||||||
|
ENGINE_PRIORITY = 3
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any]):
|
def __init__(self, config: Dict[str, Any]):
|
||||||
"""Initialize DXcam engine."""
|
"""Initialize DXcam engine."""
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Engine registry and factory for screen capture engines."""
|
"""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.core.capture_engines.base import CaptureEngine
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
@@ -85,6 +85,26 @@ class EngineRegistry:
|
|||||||
|
|
||||||
return available
|
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
|
@classmethod
|
||||||
def get_all_engines(cls) -> Dict[str, Type[CaptureEngine]]:
|
def get_all_engines(cls) -> Dict[str, Type[CaptureEngine]]:
|
||||||
"""Get all registered engines (available or not).
|
"""Get all registered engines (available or not).
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class MSSEngine(CaptureEngine):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
ENGINE_TYPE = "mss"
|
ENGINE_TYPE = "mss"
|
||||||
|
ENGINE_PRIORITY = 1
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any]):
|
def __init__(self, config: Dict[str, Any]):
|
||||||
"""Initialize MSS engine.
|
"""Initialize MSS engine.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class WGCEngine(CaptureEngine):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
ENGINE_TYPE = "wgc"
|
ENGINE_TYPE = "wgc"
|
||||||
|
ENGINE_PRIORITY = 2
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any]):
|
def __init__(self, config: Dict[str, Any]):
|
||||||
"""Initialize WGC engine.
|
"""Initialize WGC engine.
|
||||||
|
|||||||
@@ -88,10 +88,11 @@ class ProcessorState:
|
|||||||
led_count: int
|
led_count: int
|
||||||
settings: ProcessingSettings
|
settings: ProcessingSettings
|
||||||
calibration: CalibrationConfig
|
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
|
wled_client: Optional[WLEDClient] = None
|
||||||
pixel_mapper: Optional[PixelMapper] = None
|
pixel_mapper: Optional[PixelMapper] = None
|
||||||
capture_engine: Optional[CaptureEngine] = None # NEW: initialized capture engine
|
capture_engine: Optional[CaptureEngine] = None
|
||||||
is_running: bool = False
|
is_running: bool = False
|
||||||
task: Optional[asyncio.Task] = None
|
task: Optional[asyncio.Task] = None
|
||||||
metrics: ProcessingMetrics = field(default_factory=ProcessingMetrics)
|
metrics: ProcessingMetrics = field(default_factory=ProcessingMetrics)
|
||||||
@@ -100,16 +101,34 @@ class ProcessorState:
|
|||||||
test_mode_active: bool = False
|
test_mode_active: bool = False
|
||||||
test_mode_edges: Dict[str, Tuple[int, int, int]] = field(default_factory=dict)
|
test_mode_edges: Dict[str, Tuple[int, int, int]] = field(default_factory=dict)
|
||||||
health_task: Optional[asyncio.Task] = None
|
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:
|
class ProcessorManager:
|
||||||
"""Manages screen processing for multiple WLED devices."""
|
"""Manages screen processing for multiple WLED devices."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, picture_stream_store=None, capture_template_store=None, pp_template_store=None):
|
||||||
"""Initialize processor manager."""
|
"""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._processors: Dict[str, ProcessorState] = {}
|
||||||
self._health_monitoring_active = False
|
self._health_monitoring_active = False
|
||||||
self._http_client: Optional[httpx.AsyncClient] = None
|
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")
|
logger.info("Processor manager initialized")
|
||||||
|
|
||||||
async def _get_http_client(self) -> httpx.AsyncClient:
|
async def _get_http_client(self) -> httpx.AsyncClient:
|
||||||
@@ -125,7 +144,8 @@ class ProcessorManager:
|
|||||||
led_count: int,
|
led_count: int,
|
||||||
settings: Optional[ProcessingSettings] = None,
|
settings: Optional[ProcessingSettings] = None,
|
||||||
calibration: Optional[CalibrationConfig] = 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.
|
"""Add a device for processing.
|
||||||
|
|
||||||
@@ -135,7 +155,8 @@ class ProcessorManager:
|
|||||||
led_count: Number of LEDs
|
led_count: Number of LEDs
|
||||||
settings: Processing settings (uses defaults if None)
|
settings: Processing settings (uses defaults if None)
|
||||||
calibration: Calibration config (creates default 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:
|
if device_id in self._processors:
|
||||||
raise ValueError(f"Device {device_id} already exists")
|
raise ValueError(f"Device {device_id} already exists")
|
||||||
@@ -153,6 +174,7 @@ class ProcessorManager:
|
|||||||
settings=settings,
|
settings=settings,
|
||||||
calibration=calibration,
|
calibration=calibration,
|
||||||
capture_template_id=capture_template_id,
|
capture_template_id=capture_template_id,
|
||||||
|
picture_stream_id=picture_stream_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._processors[device_id] = state
|
self._processors[device_id] = state
|
||||||
@@ -245,9 +267,81 @@ class ProcessorManager:
|
|||||||
|
|
||||||
logger.info(f"Updated calibration for device {device_id}")
|
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):
|
async def start_processing(self, device_id: str):
|
||||||
"""Start screen processing for a device.
|
"""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:
|
Args:
|
||||||
device_id: Device identifier
|
device_id: Device identifier
|
||||||
|
|
||||||
@@ -263,9 +357,11 @@ class ProcessorManager:
|
|||||||
if state.is_running:
|
if state.is_running:
|
||||||
raise RuntimeError(f"Processing already running for device {device_id}")
|
raise RuntimeError(f"Processing already running for device {device_id}")
|
||||||
|
|
||||||
|
# Resolve stream settings
|
||||||
|
self._resolve_stream_settings(state)
|
||||||
|
|
||||||
# Connect to WLED device
|
# Connect to WLED device
|
||||||
try:
|
try:
|
||||||
# Enable DDP for large LED counts (>500 LEDs)
|
|
||||||
use_ddp = state.led_count > 500
|
use_ddp = state.led_count > 500
|
||||||
state.wled_client = WLEDClient(state.device_url, use_ddp=use_ddp)
|
state.wled_client = WLEDClient(state.device_url, use_ddp=use_ddp)
|
||||||
await state.wled_client.connect()
|
await state.wled_client.connect()
|
||||||
@@ -276,17 +372,16 @@ class ProcessorManager:
|
|||||||
logger.error(f"Failed to connect to WLED device {device_id}: {e}")
|
logger.error(f"Failed to connect to WLED device {device_id}: {e}")
|
||||||
raise RuntimeError(f"Failed to connect to WLED device: {e}")
|
raise RuntimeError(f"Failed to connect to WLED device: {e}")
|
||||||
|
|
||||||
# Initialize capture engine
|
# Initialize capture engine from resolved settings
|
||||||
# Phase 2: Use MSS engine for all devices (template integration in Phase 5)
|
|
||||||
try:
|
try:
|
||||||
# For now, always use MSS engine (Phase 5 will load from template)
|
engine_type = state.resolved_engine_type or "mss"
|
||||||
engine = EngineRegistry.create_engine("mss", {})
|
engine_config = state.resolved_engine_config or {}
|
||||||
|
engine = EngineRegistry.create_engine(engine_type, engine_config)
|
||||||
engine.initialize()
|
engine.initialize()
|
||||||
state.capture_engine = engine
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to initialize capture engine for device {device_id}: {e}")
|
logger.error(f"Failed to initialize capture engine for device {device_id}: {e}")
|
||||||
# Cleanup WLED client before raising
|
|
||||||
if state.wled_client:
|
if state.wled_client:
|
||||||
await state.wled_client.disconnect()
|
await state.wled_client.disconnect()
|
||||||
raise RuntimeError(f"Failed to initialize capture engine: {e}")
|
raise RuntimeError(f"Failed to initialize capture engine: {e}")
|
||||||
@@ -352,18 +447,31 @@ class ProcessorManager:
|
|||||||
async def _processing_loop(self, device_id: str):
|
async def _processing_loop(self, device_id: str):
|
||||||
"""Main processing loop for a device.
|
"""Main processing loop for a device.
|
||||||
|
|
||||||
Args:
|
Uses resolved_* fields from stream resolution for display, FPS,
|
||||||
device_id: Device identifier
|
and postprocessing. Falls back to device settings for LED projection
|
||||||
|
parameters (border_width, interpolation_mode) and WLED brightness.
|
||||||
"""
|
"""
|
||||||
state = self._processors[device_id]
|
state = self._processors[device_id]
|
||||||
settings = state.settings
|
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(
|
logger.info(
|
||||||
f"Processing loop started for {device_id} "
|
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 = []
|
fps_samples = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -376,39 +484,38 @@ class ProcessorManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Run blocking operations in thread pool to avoid blocking event loop
|
# Capture screen using engine
|
||||||
# Capture screen using engine (blocking I/O)
|
|
||||||
capture = await asyncio.to_thread(
|
capture = await asyncio.to_thread(
|
||||||
state.capture_engine.capture_display,
|
state.capture_engine.capture_display,
|
||||||
settings.display_index
|
display_index
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract border pixels (CPU-intensive)
|
# Extract border pixels
|
||||||
border_pixels = await asyncio.to_thread(extract_border_pixels, capture, settings.border_width)
|
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)
|
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(
|
led_colors = await asyncio.to_thread(
|
||||||
apply_color_correction,
|
apply_color_correction,
|
||||||
led_colors,
|
led_colors,
|
||||||
gamma=settings.gamma,
|
gamma=gamma,
|
||||||
saturation=settings.saturation,
|
saturation=saturation,
|
||||||
brightness=settings.brightness,
|
brightness=pp_brightness,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply smoothing (CPU-intensive)
|
# Apply smoothing from postprocessing
|
||||||
if state.previous_colors and settings.smoothing > 0:
|
if state.previous_colors and smoothing > 0:
|
||||||
led_colors = await asyncio.to_thread(
|
led_colors = await asyncio.to_thread(
|
||||||
smooth_colors,
|
smooth_colors,
|
||||||
led_colors,
|
led_colors,
|
||||||
state.previous_colors,
|
state.previous_colors,
|
||||||
settings.smoothing,
|
smoothing,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send to WLED with brightness
|
# Send to WLED with device brightness
|
||||||
brightness_value = int(settings.brightness * 255)
|
brightness_value = int(wled_brightness * 255)
|
||||||
await state.wled_client.send_pixels(led_colors, brightness=brightness_value)
|
await state.wled_client.send_pixels(led_colors, brightness=brightness_value)
|
||||||
|
|
||||||
# Update metrics
|
# Update metrics
|
||||||
@@ -468,8 +575,8 @@ class ProcessorManager:
|
|||||||
"device_id": device_id,
|
"device_id": device_id,
|
||||||
"processing": state.is_running,
|
"processing": state.is_running,
|
||||||
"fps_actual": metrics.fps_actual if state.is_running else None,
|
"fps_actual": metrics.fps_actual if state.is_running else None,
|
||||||
"fps_target": state.settings.fps,
|
"fps_target": state.resolved_target_fps or state.settings.fps,
|
||||||
"display_index": state.settings.display_index,
|
"display_index": state.resolved_display_index if state.resolved_display_index is not None else state.settings.display_index,
|
||||||
"last_update": metrics.last_update,
|
"last_update": metrics.last_update,
|
||||||
"errors": [metrics.last_error] if metrics.last_error else [],
|
"errors": [metrics.last_error] if metrics.last_error else [],
|
||||||
"wled_online": h.online,
|
"wled_online": h.online,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ from wled_controller.config import get_config
|
|||||||
from wled_controller.core.processor_manager import ProcessorManager
|
from wled_controller.core.processor_manager import ProcessorManager
|
||||||
from wled_controller.storage import DeviceStore
|
from wled_controller.storage import DeviceStore
|
||||||
from wled_controller.storage.template_store import TemplateStore
|
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
|
from wled_controller.utils import setup_logging, get_logger
|
||||||
|
|
||||||
# Initialize logging
|
# Initialize logging
|
||||||
@@ -28,7 +30,65 @@ config = get_config()
|
|||||||
# Initialize storage and processing
|
# Initialize storage and processing
|
||||||
device_store = DeviceStore(config.storage.devices_file)
|
device_store = DeviceStore(config.storage.devices_file)
|
||||||
template_store = TemplateStore(config.storage.templates_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
|
@asynccontextmanager
|
||||||
@@ -61,7 +121,11 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info("All API requests require valid Bearer token authentication")
|
logger.info("All API requests require valid Bearer token authentication")
|
||||||
|
|
||||||
# Initialize API dependencies
|
# 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
|
# Load existing devices into processor manager
|
||||||
devices = device_store.get_all_devices()
|
devices = device_store.get_all_devices()
|
||||||
@@ -74,6 +138,7 @@ async def lifespan(app: FastAPI):
|
|||||||
settings=device.settings,
|
settings=device.settings,
|
||||||
calibration=device.calibration,
|
calibration=device.calibration,
|
||||||
capture_template_id=device.capture_template_id,
|
capture_template_id=device.capture_template_id,
|
||||||
|
picture_stream_id=device.picture_stream_id,
|
||||||
)
|
)
|
||||||
logger.info(f"Loaded device: {device.name} ({device.id})")
|
logger.info(f"Loaded device: {device.name} ({device.id})")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -347,6 +347,12 @@ function switchTab(name) {
|
|||||||
if (name === 'templates') {
|
if (name === 'templates') {
|
||||||
loadCaptureTemplates();
|
loadCaptureTemplates();
|
||||||
}
|
}
|
||||||
|
if (name === 'streams') {
|
||||||
|
loadPictureStreams();
|
||||||
|
}
|
||||||
|
if (name === 'pp-templates') {
|
||||||
|
loadPPTemplates();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTabs() {
|
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 class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
|
||||||
⚙️
|
⚙️
|
||||||
</button>
|
</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>
|
||||||
<button class="btn btn-icon btn-secondary" onclick="showCalibration('${device.id}')" title="${t('device.button.calibrate')}">
|
<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);
|
templateSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (templateSelect.options.length === 0) {
|
templateSelect.value = device.capture_template_id || '';
|
||||||
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';
|
|
||||||
|
|
||||||
// Store device ID, current settings snapshot, and initial values for dirty check
|
// Store device ID, current settings snapshot, and initial values for dirty check
|
||||||
document.getElementById('capture-settings-device-id').value = device.id;
|
document.getElementById('capture-settings-device-id').value = device.id;
|
||||||
captureSettingsInitialValues = {
|
captureSettingsInitialValues = {
|
||||||
display_index: String(device.settings.display_index ?? 0),
|
display_index: String(device.settings.display_index ?? 0),
|
||||||
fps: String(currentSettings.fps ?? 30),
|
fps: String(currentSettings.fps ?? 30),
|
||||||
capture_template_id: device.capture_template_id || 'tpl_mss_default',
|
capture_template_id: device.capture_template_id || '',
|
||||||
_currentSettings: currentSettings,
|
_currentSettings: currentSettings,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2359,25 +2359,16 @@ function renderTemplatesList(templates) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultTemplates = templates.filter(t => t.is_default);
|
|
||||||
const customTemplates = templates.filter(t => !t.is_default);
|
|
||||||
|
|
||||||
const renderCard = (template) => {
|
const renderCard = (template) => {
|
||||||
const engineIcon = getEngineIcon(template.engine_type);
|
const engineIcon = getEngineIcon(template.engine_type);
|
||||||
const defaultBadge = template.is_default
|
|
||||||
? `<span class="badge badge-default">${t('templates.default')}</span>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="template-card" data-template-id="${template.id}">
|
<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')}">✕</button>
|
||||||
<button class="card-remove-btn" onclick="deleteTemplate('${template.id}')" title="${t('common.delete')}">✕</button>
|
|
||||||
` : ''}
|
|
||||||
<div class="template-card-header">
|
<div class="template-card-header">
|
||||||
<div class="template-name">
|
<div class="template-name">
|
||||||
${engineIcon} ${escapeHtml(template.name)}
|
${engineIcon} ${escapeHtml(template.name)}
|
||||||
</div>
|
</div>
|
||||||
${defaultBadge}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="template-config">
|
<div class="template-config">
|
||||||
<strong>${t('templates.engine')}</strong> ${template.engine_type.toUpperCase()}
|
<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 class="btn btn-icon btn-secondary" onclick="showTestTemplateModal('${template.id}')" title="${t('templates.test.title')}">
|
||||||
🧪
|
🧪
|
||||||
</button>
|
</button>
|
||||||
${!template.is_default ? `
|
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">
|
||||||
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">
|
✏️
|
||||||
✏️
|
</button>
|
||||||
</button>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
let html = defaultTemplates.map(renderCard).join('');
|
let html = templates.map(renderCard).join('');
|
||||||
|
|
||||||
if (customTemplates.length > 0) {
|
|
||||||
html += `<div class="templates-separator"><span>${t('templates.custom')}</span></div>`;
|
|
||||||
html += customTemplates.map(renderCard).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
html += `<div class="template-card add-template-card" onclick="showAddTemplateModal()">
|
html += `<div class="template-card add-template-card" onclick="showAddTemplateModal()">
|
||||||
<div class="add-template-icon">+</div>
|
<div class="add-template-icon">+</div>
|
||||||
@@ -2978,3 +2962,773 @@ async function deleteTemplate(templateId) {
|
|||||||
showToast(t('templates.error.delete') + ': ' + error.message, 'error');
|
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')}">✕</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')}">✕</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 = {};
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,7 +36,9 @@
|
|||||||
<div class="tab-bar">
|
<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 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="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="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>
|
||||||
|
|
||||||
<div class="tab-panel active" id="tab-devices">
|
<div class="tab-panel active" id="tab-devices">
|
||||||
@@ -62,6 +64,17 @@
|
|||||||
<div id="displays-list" style="display: none;"></div>
|
<div id="displays-list" style="display: none;"></div>
|
||||||
</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">
|
<div class="tab-panel" id="tab-templates">
|
||||||
<p class="section-tip">
|
<p class="section-tip">
|
||||||
<span data-i18n="templates.description">
|
<span data-i18n="templates.description">
|
||||||
@@ -72,6 +85,17 @@
|
|||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
@@ -244,44 +268,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Capture Settings Modal -->
|
<!-- Stream Settings Modal (picture stream + LED projection settings) -->
|
||||||
<div id="capture-settings-modal" class="modal">
|
<div id="stream-selector-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 data-i18n="settings.capture.title">🎬 Capture Settings</h2>
|
<h2 data-i18n="device.stream_settings.title">📺 Stream Settings</h2>
|
||||||
<button class="modal-close-btn" onclick="closeCaptureSettingsModal()" title="Close">✕</button>
|
<button class="modal-close-btn" onclick="closeStreamSelectorModal()" title="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form id="capture-settings-form">
|
<form id="stream-selector-form">
|
||||||
<input type="hidden" id="capture-settings-device-id">
|
<input type="hidden" id="stream-selector-device-id">
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="capture-settings-display-index" data-i18n="settings.display_index">Display:</label>
|
<label for="stream-selector-stream" data-i18n="device.stream_selector.label">Assigned Picture Stream:</label>
|
||||||
<select id="capture-settings-display-index"></select>
|
<select id="stream-selector-stream"></select>
|
||||||
<small class="input-hint" data-i18n="settings.display_index.hint">Which screen to capture for this device</small>
|
<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>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="capture-settings-fps" data-i18n="settings.fps">Target FPS:</label>
|
<label for="stream-selector-interpolation" data-i18n="device.stream_settings.interpolation">Interpolation Mode:</label>
|
||||||
<div class="slider-row">
|
<select id="stream-selector-interpolation">
|
||||||
<input type="range" id="capture-settings-fps" min="10" max="90" value="30" oninput="document.getElementById('capture-settings-fps-value').textContent = this.value">
|
<option value="average" data-i18n="device.stream_settings.interpolation.average">Average</option>
|
||||||
<span id="capture-settings-fps-value" class="slider-value">30</span>
|
<option value="median" data-i18n="device.stream_settings.interpolation.median">Median</option>
|
||||||
</div>
|
<option value="dominant" data-i18n="device.stream_settings.interpolation.dominant">Dominant</option>
|
||||||
<small class="input-hint" data-i18n="settings.fps.hint">Target frames per second (10-90)</small>
|
</select>
|
||||||
|
<small class="input-hint" data-i18n="device.stream_settings.interpolation_hint">How to calculate LED color from sampled pixels</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div id="stream-selector-error" class="error-message" style="display: none;"></div>
|
||||||
<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>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-icon btn-secondary" onclick="closeCaptureSettingsModal()" title="Cancel">✕</button>
|
<button class="btn btn-icon btn-secondary" onclick="closeStreamSelectorModal()" title="Cancel">✕</button>
|
||||||
<button class="btn btn-icon btn-primary" onclick="saveCaptureSettings()" title="Save">✓</button>
|
<button class="btn btn-icon btn-primary" onclick="saveStreamSelector()" title="Save">✓</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -465,6 +492,189 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">✕</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">✕</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">✕</button>
|
||||||
|
<button class="btn btn-icon btn-primary" onclick="saveStream()" title="Save">✓</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">✕</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">✕</button>
|
||||||
|
<button class="btn btn-icon btn-primary" onclick="savePPTemplate()" title="Save">✓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Device Tutorial Overlay (viewport-level) -->
|
<!-- Device Tutorial Overlay (viewport-level) -->
|
||||||
<div id="device-tutorial-overlay" class="tutorial-overlay tutorial-overlay-fixed">
|
<div id="device-tutorial-overlay" class="tutorial-overlay tutorial-overlay-fixed">
|
||||||
<div class="tutorial-backdrop"></div>
|
<div class="tutorial-backdrop"></div>
|
||||||
|
|||||||
@@ -50,9 +50,6 @@
|
|||||||
"templates.config.show": "Show configuration",
|
"templates.config.show": "Show configuration",
|
||||||
"templates.config.none": "No additional configuration",
|
"templates.config.none": "No additional configuration",
|
||||||
"templates.config.default": "Default",
|
"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.created": "Template created successfully",
|
||||||
"templates.updated": "Template updated successfully",
|
"templates.updated": "Template updated successfully",
|
||||||
"templates.deleted": "Template deleted successfully",
|
"templates.deleted": "Template deleted successfully",
|
||||||
@@ -195,5 +192,69 @@
|
|||||||
"modal.discard_changes": "You have unsaved changes. Discard them?",
|
"modal.discard_changes": "You have unsaved changes. Discard them?",
|
||||||
"confirm.title": "Confirm Action",
|
"confirm.title": "Confirm Action",
|
||||||
"confirm.yes": "Yes",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,6 @@
|
|||||||
"templates.config.show": "Показать конфигурацию",
|
"templates.config.show": "Показать конфигурацию",
|
||||||
"templates.config.none": "Нет дополнительных настроек",
|
"templates.config.none": "Нет дополнительных настроек",
|
||||||
"templates.config.default": "По умолчанию",
|
"templates.config.default": "По умолчанию",
|
||||||
"templates.default": "По умолчанию",
|
|
||||||
"templates.custom": "Пользовательские шаблоны",
|
|
||||||
"templates.default.locked": "Системный шаблон (нельзя редактировать/удалить)",
|
|
||||||
"templates.created": "Шаблон успешно создан",
|
"templates.created": "Шаблон успешно создан",
|
||||||
"templates.updated": "Шаблон успешно обновлён",
|
"templates.updated": "Шаблон успешно обновлён",
|
||||||
"templates.deleted": "Шаблон успешно удалён",
|
"templates.deleted": "Шаблон успешно удалён",
|
||||||
@@ -195,5 +192,69 @@
|
|||||||
"modal.discard_changes": "У вас есть несохранённые изменения. Отменить их?",
|
"modal.discard_changes": "У вас есть несохранённые изменения. Отменить их?",
|
||||||
"confirm.title": "Подтверждение Действия",
|
"confirm.title": "Подтверждение Действия",
|
||||||
"confirm.yes": "Да",
|
"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 для этого устройства"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1762,24 +1762,6 @@ input:-webkit-autofill:focus {
|
|||||||
gap: 20px;
|
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 {
|
.template-card {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -1839,7 +1821,7 @@ input:-webkit-autofill:focus {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-card:has(.card-remove-btn) .template-card-header {
|
.template-card .template-card-header {
|
||||||
padding-right: 24px;
|
padding-right: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1857,11 +1839,6 @@ input:-webkit-autofill:focus {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-default {
|
|
||||||
background: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.template-description {
|
.template-description {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -2038,6 +2015,36 @@ input:-webkit-autofill:focus {
|
|||||||
font-size: 16px;
|
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 */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.templates-grid {
|
.templates-grid {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Storage layer for device and configuration persistence."""
|
"""Storage layer for device and configuration persistence."""
|
||||||
|
|
||||||
from .device_store import DeviceStore
|
from .device_store import DeviceStore
|
||||||
|
from .picture_stream_store import PictureStreamStore
|
||||||
|
from .postprocessing_template_store import PostprocessingTemplateStore
|
||||||
|
|
||||||
__all__ = ["DeviceStore"]
|
__all__ = ["DeviceStore", "PictureStreamStore", "PostprocessingTemplateStore"]
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ class Device:
|
|||||||
enabled: bool = True,
|
enabled: bool = True,
|
||||||
settings: Optional[ProcessingSettings] = None,
|
settings: Optional[ProcessingSettings] = None,
|
||||||
calibration: Optional[CalibrationConfig] = 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,
|
created_at: Optional[datetime] = None,
|
||||||
updated_at: Optional[datetime] = None,
|
updated_at: Optional[datetime] = None,
|
||||||
):
|
):
|
||||||
@@ -44,7 +45,8 @@ class Device:
|
|||||||
enabled: Whether device is enabled
|
enabled: Whether device is enabled
|
||||||
settings: Processing settings
|
settings: Processing settings
|
||||||
calibration: Calibration configuration
|
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
|
created_at: Creation timestamp
|
||||||
updated_at: Last update timestamp
|
updated_at: Last update timestamp
|
||||||
"""
|
"""
|
||||||
@@ -56,6 +58,7 @@ class Device:
|
|||||||
self.settings = settings or ProcessingSettings()
|
self.settings = settings or ProcessingSettings()
|
||||||
self.calibration = calibration or create_default_calibration(led_count)
|
self.calibration = calibration or create_default_calibration(led_count)
|
||||||
self.capture_template_id = capture_template_id
|
self.capture_template_id = capture_template_id
|
||||||
|
self.picture_stream_id = picture_stream_id
|
||||||
self.created_at = created_at or datetime.utcnow()
|
self.created_at = created_at or datetime.utcnow()
|
||||||
self.updated_at = updated_at or datetime.utcnow()
|
self.updated_at = updated_at or datetime.utcnow()
|
||||||
|
|
||||||
@@ -84,6 +87,7 @@ class Device:
|
|||||||
},
|
},
|
||||||
"calibration": calibration_to_dict(self.calibration),
|
"calibration": calibration_to_dict(self.calibration),
|
||||||
"capture_template_id": self.capture_template_id,
|
"capture_template_id": self.capture_template_id,
|
||||||
|
"picture_stream_id": self.picture_stream_id,
|
||||||
"created_at": self.created_at.isoformat(),
|
"created_at": self.created_at.isoformat(),
|
||||||
"updated_at": self.updated_at.isoformat(),
|
"updated_at": self.updated_at.isoformat(),
|
||||||
}
|
}
|
||||||
@@ -121,11 +125,8 @@ class Device:
|
|||||||
else create_default_calibration(data["led_count"])
|
else create_default_calibration(data["led_count"])
|
||||||
)
|
)
|
||||||
|
|
||||||
# Migration: assign default MSS template if no template set
|
capture_template_id = data.get("capture_template_id", "")
|
||||||
capture_template_id = data.get("capture_template_id")
|
picture_stream_id = data.get("picture_stream_id", "")
|
||||||
if not capture_template_id:
|
|
||||||
capture_template_id = "tpl_mss_default"
|
|
||||||
logger.info(f"Migrating device {data['id']} to default MSS template")
|
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
device_id=data["id"],
|
device_id=data["id"],
|
||||||
@@ -136,6 +137,7 @@ class Device:
|
|||||||
settings=settings,
|
settings=settings,
|
||||||
calibration=calibration,
|
calibration=calibration,
|
||||||
capture_template_id=capture_template_id,
|
capture_template_id=capture_template_id,
|
||||||
|
picture_stream_id=picture_stream_id,
|
||||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
||||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
||||||
)
|
)
|
||||||
@@ -217,7 +219,8 @@ class DeviceStore:
|
|||||||
led_count: int,
|
led_count: int,
|
||||||
settings: Optional[ProcessingSettings] = None,
|
settings: Optional[ProcessingSettings] = None,
|
||||||
calibration: Optional[CalibrationConfig] = None,
|
calibration: Optional[CalibrationConfig] = None,
|
||||||
capture_template_id: str = "tpl_mss_default",
|
capture_template_id: str = "",
|
||||||
|
picture_stream_id: str = "",
|
||||||
) -> Device:
|
) -> Device:
|
||||||
"""Create a new device.
|
"""Create a new device.
|
||||||
|
|
||||||
@@ -247,6 +250,7 @@ class DeviceStore:
|
|||||||
settings=settings,
|
settings=settings,
|
||||||
calibration=calibration,
|
calibration=calibration,
|
||||||
capture_template_id=capture_template_id,
|
capture_template_id=capture_template_id,
|
||||||
|
picture_stream_id=picture_stream_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store
|
# Store
|
||||||
@@ -285,6 +289,7 @@ class DeviceStore:
|
|||||||
settings: Optional[ProcessingSettings] = None,
|
settings: Optional[ProcessingSettings] = None,
|
||||||
calibration: Optional[CalibrationConfig] = None,
|
calibration: Optional[CalibrationConfig] = None,
|
||||||
capture_template_id: Optional[str] = None,
|
capture_template_id: Optional[str] = None,
|
||||||
|
picture_stream_id: Optional[str] = None,
|
||||||
) -> Device:
|
) -> Device:
|
||||||
"""Update device.
|
"""Update device.
|
||||||
|
|
||||||
@@ -331,6 +336,8 @@ class DeviceStore:
|
|||||||
device.calibration = calibration
|
device.calibration = calibration
|
||||||
if capture_template_id is not None:
|
if capture_template_id is not None:
|
||||||
device.capture_template_id = capture_template_id
|
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()
|
device.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
|||||||
69
server/src/wled_controller/storage/picture_stream.py
Normal file
69
server/src/wled_controller/storage/picture_stream.py
Normal 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"),
|
||||||
|
)
|
||||||
332
server/src/wled_controller/storage/picture_stream_store.py
Normal file
332
server/src/wled_controller/storage/picture_stream_store.py
Normal 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
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -13,7 +13,6 @@ class CaptureTemplate:
|
|||||||
name: str
|
name: str
|
||||||
engine_type: str
|
engine_type: str
|
||||||
engine_config: Dict[str, Any]
|
engine_config: Dict[str, Any]
|
||||||
is_default: bool
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
@@ -29,7 +28,6 @@ class CaptureTemplate:
|
|||||||
"name": self.name,
|
"name": self.name,
|
||||||
"engine_type": self.engine_type,
|
"engine_type": self.engine_type,
|
||||||
"engine_config": self.engine_config,
|
"engine_config": self.engine_config,
|
||||||
"is_default": self.is_default,
|
|
||||||
"created_at": self.created_at.isoformat(),
|
"created_at": self.created_at.isoformat(),
|
||||||
"updated_at": self.updated_at.isoformat(),
|
"updated_at": self.updated_at.isoformat(),
|
||||||
"description": self.description,
|
"description": self.description,
|
||||||
@@ -50,7 +48,6 @@ class CaptureTemplate:
|
|||||||
name=data["name"],
|
name=data["name"],
|
||||||
engine_type=data["engine_type"],
|
engine_type=data["engine_type"],
|
||||||
engine_config=data.get("engine_config", {}),
|
engine_config=data.get("engine_config", {}),
|
||||||
is_default=data.get("is_default", False),
|
|
||||||
created_at=datetime.fromisoformat(data["created_at"])
|
created_at=datetime.fromisoformat(data["created_at"])
|
||||||
if isinstance(data.get("created_at"), str)
|
if isinstance(data.get("created_at"), str)
|
||||||
else data.get("created_at", datetime.utcnow()),
|
else data.get("created_at", datetime.utcnow()),
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ logger = get_logger(__name__)
|
|||||||
class TemplateStore:
|
class TemplateStore:
|
||||||
"""Storage for capture templates.
|
"""Storage for capture templates.
|
||||||
|
|
||||||
Default templates for each available engine are created in memory at startup.
|
All templates are persisted to the JSON file.
|
||||||
Only user-created 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):
|
def __init__(self, file_path: str):
|
||||||
@@ -28,34 +29,40 @@ class TemplateStore:
|
|||||||
"""
|
"""
|
||||||
self.file_path = Path(file_path)
|
self.file_path = Path(file_path)
|
||||||
self._templates: Dict[str, CaptureTemplate] = {}
|
self._templates: Dict[str, CaptureTemplate] = {}
|
||||||
self._ensure_defaults()
|
|
||||||
self._load()
|
self._load()
|
||||||
|
self._ensure_initial_template()
|
||||||
|
|
||||||
def _ensure_defaults(self) -> None:
|
def _ensure_initial_template(self) -> None:
|
||||||
"""Create default templates in memory for all available engines."""
|
"""Auto-create a template if none exist, using the best available engine."""
|
||||||
available = EngineRegistry.get_available_engines()
|
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()
|
now = datetime.utcnow()
|
||||||
|
template_id = f"tpl_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
for engine_type in available:
|
template = CaptureTemplate(
|
||||||
template_id = f"tpl_{engine_type}_default"
|
id=template_id,
|
||||||
engine_class = EngineRegistry.get_engine(engine_type)
|
name=best_engine.upper(),
|
||||||
default_config = engine_class.get_default_config()
|
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(
|
self._templates[template_id] = template
|
||||||
id=template_id,
|
self._save()
|
||||||
name=engine_type.upper(),
|
logger.info(f"Auto-created initial template: {template.name} ({template_id}, engine={best_engine})")
|
||||||
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")
|
|
||||||
|
|
||||||
def _load(self) -> None:
|
def _load(self) -> None:
|
||||||
"""Load user-created templates from file."""
|
"""Load templates from file."""
|
||||||
if not self.file_path.exists():
|
if not self.file_path.exists():
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -66,9 +73,6 @@ class TemplateStore:
|
|||||||
templates_data = data.get("templates", {})
|
templates_data = data.get("templates", {})
|
||||||
loaded = 0
|
loaded = 0
|
||||||
for template_id, template_dict in templates_data.items():
|
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:
|
try:
|
||||||
template = CaptureTemplate.from_dict(template_dict)
|
template = CaptureTemplate.from_dict(template_dict)
|
||||||
self._templates[template_id] = template
|
self._templates[template_id] = template
|
||||||
@@ -80,26 +84,23 @@ class TemplateStore:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if loaded > 0:
|
if loaded > 0:
|
||||||
logger.info(f"Loaded {loaded} user templates from storage")
|
logger.info(f"Loaded {loaded} templates from storage")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load templates from {self.file_path}: {e}")
|
logger.error(f"Failed to load templates from {self.file_path}: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
total = len(self._templates)
|
logger.info(f"Template store initialized with {len(self._templates)} templates")
|
||||||
logger.info(f"Template store initialized with {total} templates")
|
|
||||||
|
|
||||||
def _save(self) -> None:
|
def _save(self) -> None:
|
||||||
"""Save only user-created templates to file."""
|
"""Save all templates to file."""
|
||||||
try:
|
try:
|
||||||
# Ensure directory exists
|
# Ensure directory exists
|
||||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Only persist non-default templates
|
|
||||||
templates_dict = {
|
templates_dict = {
|
||||||
template_id: template.to_dict()
|
template_id: template.to_dict()
|
||||||
for template_id, template in self._templates.items()
|
for template_id, template in self._templates.items()
|
||||||
if not template.is_default
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@@ -162,7 +163,7 @@ class TemplateStore:
|
|||||||
"""
|
"""
|
||||||
# Check for duplicate name
|
# Check for duplicate name
|
||||||
for template in self._templates.values():
|
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")
|
raise ValueError(f"Template with name '{name}' already exists")
|
||||||
|
|
||||||
# Generate new ID
|
# Generate new ID
|
||||||
@@ -175,7 +176,6 @@ class TemplateStore:
|
|||||||
name=name,
|
name=name,
|
||||||
engine_type=engine_type,
|
engine_type=engine_type,
|
||||||
engine_config=engine_config,
|
engine_config=engine_config,
|
||||||
is_default=False,
|
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
description=description,
|
description=description,
|
||||||
@@ -209,16 +209,13 @@ class TemplateStore:
|
|||||||
Updated template
|
Updated template
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If template not found or is a default template
|
ValueError: If template not found
|
||||||
"""
|
"""
|
||||||
if template_id not in self._templates:
|
if template_id not in self._templates:
|
||||||
raise ValueError(f"Template not found: {template_id}")
|
raise ValueError(f"Template not found: {template_id}")
|
||||||
|
|
||||||
template = self._templates[template_id]
|
template = self._templates[template_id]
|
||||||
|
|
||||||
if template.is_default:
|
|
||||||
raise ValueError("Cannot modify default templates")
|
|
||||||
|
|
||||||
# Update fields
|
# Update fields
|
||||||
if name is not None:
|
if name is not None:
|
||||||
template.name = name
|
template.name = name
|
||||||
@@ -244,16 +241,11 @@ class TemplateStore:
|
|||||||
template_id: Template ID
|
template_id: Template ID
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If template not found or is a default template
|
ValueError: If template not found
|
||||||
"""
|
"""
|
||||||
if template_id not in self._templates:
|
if template_id not in self._templates:
|
||||||
raise ValueError(f"Template not found: {template_id}")
|
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
|
# Remove and save
|
||||||
del self._templates[template_id]
|
del self._templates[template_id]
|
||||||
self._save()
|
self._save()
|
||||||
|
|||||||
Reference in New Issue
Block a user