Refactor capture engine architecture, rename PictureStream to PictureSource, and split API modules
- Separate CaptureEngine into stateless factory + stateful CaptureStream session - Add LiveStream/LiveStreamManager for shared capture with reference counting - Rename PictureStream to PictureSource across storage, API, and UI - Remove legacy migration logic and unused compatibility code - Split monolithic routes.py (1935 lines) into 5 focused route modules - Split schemas.py (480 lines) into 7 schema modules with re-exports - Extract dependency injection into dedicated dependencies.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""Storage layer for device and configuration persistence."""
|
||||
|
||||
from .device_store import DeviceStore
|
||||
from .picture_stream_store import PictureStreamStore
|
||||
from .picture_source_store import PictureSourceStore
|
||||
from .postprocessing_template_store import PostprocessingTemplateStore
|
||||
|
||||
__all__ = ["DeviceStore", "PictureStreamStore", "PostprocessingTemplateStore"]
|
||||
__all__ = ["DeviceStore", "PictureSourceStore", "PostprocessingTemplateStore"]
|
||||
|
||||
@@ -30,8 +30,7 @@ class Device:
|
||||
enabled: bool = True,
|
||||
settings: Optional[ProcessingSettings] = None,
|
||||
calibration: Optional[CalibrationConfig] = None,
|
||||
capture_template_id: str = "",
|
||||
picture_stream_id: str = "",
|
||||
picture_source_id: str = "",
|
||||
created_at: Optional[datetime] = None,
|
||||
updated_at: Optional[datetime] = None,
|
||||
):
|
||||
@@ -45,8 +44,7 @@ class Device:
|
||||
enabled: Whether device is enabled
|
||||
settings: Processing settings
|
||||
calibration: Calibration configuration
|
||||
capture_template_id: ID of assigned capture template (legacy, use picture_stream_id)
|
||||
picture_stream_id: ID of assigned picture stream
|
||||
picture_source_id: ID of assigned picture source
|
||||
created_at: Creation timestamp
|
||||
updated_at: Last update timestamp
|
||||
"""
|
||||
@@ -57,8 +55,7 @@ class Device:
|
||||
self.enabled = enabled
|
||||
self.settings = settings or ProcessingSettings()
|
||||
self.calibration = calibration or create_default_calibration(led_count)
|
||||
self.capture_template_id = capture_template_id
|
||||
self.picture_stream_id = picture_stream_id
|
||||
self.picture_source_id = picture_source_id
|
||||
self.created_at = created_at or datetime.utcnow()
|
||||
self.updated_at = updated_at or datetime.utcnow()
|
||||
|
||||
@@ -86,8 +83,7 @@ class Device:
|
||||
"state_check_interval": self.settings.state_check_interval,
|
||||
},
|
||||
"calibration": calibration_to_dict(self.calibration),
|
||||
"capture_template_id": self.capture_template_id,
|
||||
"picture_stream_id": self.picture_stream_id,
|
||||
"picture_source_id": self.picture_source_id,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
@@ -112,10 +108,7 @@ class Device:
|
||||
saturation=settings_data.get("saturation", 1.0),
|
||||
smoothing=settings_data.get("smoothing", 0.3),
|
||||
interpolation_mode=settings_data.get("interpolation_mode", "average"),
|
||||
state_check_interval=settings_data.get(
|
||||
"state_check_interval",
|
||||
settings_data.get("health_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
|
||||
),
|
||||
state_check_interval=settings_data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
|
||||
)
|
||||
|
||||
calibration_data = data.get("calibration")
|
||||
@@ -125,8 +118,7 @@ class Device:
|
||||
else create_default_calibration(data["led_count"])
|
||||
)
|
||||
|
||||
capture_template_id = data.get("capture_template_id", "")
|
||||
picture_stream_id = data.get("picture_stream_id", "")
|
||||
picture_source_id = data.get("picture_source_id", "")
|
||||
|
||||
return cls(
|
||||
device_id=data["id"],
|
||||
@@ -136,8 +128,7 @@ class Device:
|
||||
enabled=data.get("enabled", True),
|
||||
settings=settings,
|
||||
calibration=calibration,
|
||||
capture_template_id=capture_template_id,
|
||||
picture_stream_id=picture_stream_id,
|
||||
picture_source_id=picture_source_id,
|
||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
||||
)
|
||||
@@ -219,8 +210,7 @@ class DeviceStore:
|
||||
led_count: int,
|
||||
settings: Optional[ProcessingSettings] = None,
|
||||
calibration: Optional[CalibrationConfig] = None,
|
||||
capture_template_id: str = "",
|
||||
picture_stream_id: str = "",
|
||||
picture_source_id: str = "",
|
||||
) -> Device:
|
||||
"""Create a new device.
|
||||
|
||||
@@ -230,7 +220,7 @@ class DeviceStore:
|
||||
led_count: Number of LEDs
|
||||
settings: Processing settings
|
||||
calibration: Calibration configuration
|
||||
capture_template_id: ID of assigned capture template
|
||||
picture_source_id: ID of assigned picture source
|
||||
|
||||
Returns:
|
||||
Created device
|
||||
@@ -249,8 +239,7 @@ class DeviceStore:
|
||||
led_count=led_count,
|
||||
settings=settings,
|
||||
calibration=calibration,
|
||||
capture_template_id=capture_template_id,
|
||||
picture_stream_id=picture_stream_id,
|
||||
picture_source_id=picture_source_id,
|
||||
)
|
||||
|
||||
# Store
|
||||
@@ -288,8 +277,7 @@ class DeviceStore:
|
||||
enabled: Optional[bool] = None,
|
||||
settings: Optional[ProcessingSettings] = None,
|
||||
calibration: Optional[CalibrationConfig] = None,
|
||||
capture_template_id: Optional[str] = None,
|
||||
picture_stream_id: Optional[str] = None,
|
||||
picture_source_id: Optional[str] = None,
|
||||
) -> Device:
|
||||
"""Update device.
|
||||
|
||||
@@ -301,7 +289,7 @@ class DeviceStore:
|
||||
enabled: New enabled state (optional)
|
||||
settings: New settings (optional)
|
||||
calibration: New calibration (optional)
|
||||
capture_template_id: New capture template ID (optional)
|
||||
picture_source_id: New picture source ID (optional)
|
||||
|
||||
Returns:
|
||||
Updated device
|
||||
@@ -334,10 +322,8 @@ class DeviceStore:
|
||||
f"does not match device LED count ({device.led_count})"
|
||||
)
|
||||
device.calibration = calibration
|
||||
if capture_template_id is not None:
|
||||
device.capture_template_id = capture_template_id
|
||||
if picture_stream_id is not None:
|
||||
device.picture_stream_id = picture_stream_id
|
||||
if picture_source_id is not None:
|
||||
device.picture_source_id = picture_source_id
|
||||
|
||||
device.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
128
server/src/wled_controller/storage/picture_source.py
Normal file
128
server/src/wled_controller/storage/picture_source.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Picture source data model with inheritance-based stream types."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class PictureSource:
|
||||
"""Base class for picture source configurations.
|
||||
|
||||
A picture source is either:
|
||||
- "raw": captures from a display using a capture engine template at a target FPS
|
||||
- "processed": applies postprocessing to another picture source
|
||||
- "static_image": returns a static frame from a URL or local file path
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
stream_type: str # "raw", "processed", or "static_image"
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
description: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert stream to dictionary. Subclasses extend this."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"stream_type": self.stream_type,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"description": self.description,
|
||||
# Subclass fields default to None for backward compat
|
||||
"display_index": None,
|
||||
"capture_template_id": None,
|
||||
"target_fps": None,
|
||||
"source_stream_id": None,
|
||||
"postprocessing_template_id": None,
|
||||
"image_source": None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict) -> "PictureSource":
|
||||
"""Factory: dispatch to the correct subclass based on stream_type."""
|
||||
stream_type: str = data.get("stream_type", "raw") or "raw"
|
||||
sid: str = data["id"]
|
||||
name: str = data["name"]
|
||||
description: str | None = data.get("description")
|
||||
|
||||
raw_created = data.get("created_at")
|
||||
created_at: datetime = (
|
||||
datetime.fromisoformat(raw_created)
|
||||
if isinstance(raw_created, str)
|
||||
else raw_created if isinstance(raw_created, datetime)
|
||||
else datetime.utcnow()
|
||||
)
|
||||
raw_updated = data.get("updated_at")
|
||||
updated_at: datetime = (
|
||||
datetime.fromisoformat(raw_updated)
|
||||
if isinstance(raw_updated, str)
|
||||
else raw_updated if isinstance(raw_updated, datetime)
|
||||
else datetime.utcnow()
|
||||
)
|
||||
|
||||
if stream_type == "processed":
|
||||
return ProcessedPictureSource(
|
||||
id=sid, name=name, stream_type=stream_type,
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
source_stream_id=data.get("source_stream_id") or "",
|
||||
postprocessing_template_id=data.get("postprocessing_template_id") or "",
|
||||
)
|
||||
elif stream_type == "static_image":
|
||||
return StaticImagePictureSource(
|
||||
id=sid, name=name, stream_type=stream_type,
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
image_source=data.get("image_source") or "",
|
||||
)
|
||||
else:
|
||||
return ScreenCapturePictureSource(
|
||||
id=sid, name=name, stream_type=stream_type,
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
display_index=data.get("display_index") or 0,
|
||||
capture_template_id=data.get("capture_template_id") or "",
|
||||
target_fps=data.get("target_fps") or 30,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScreenCapturePictureSource(PictureSource):
|
||||
"""A raw capture stream from a display."""
|
||||
|
||||
display_index: int = 0
|
||||
capture_template_id: str = ""
|
||||
target_fps: int = 30
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["display_index"] = self.display_index
|
||||
d["capture_template_id"] = self.capture_template_id
|
||||
d["target_fps"] = self.target_fps
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessedPictureSource(PictureSource):
|
||||
"""A processed stream that applies postprocessing to another stream."""
|
||||
|
||||
source_stream_id: str = ""
|
||||
postprocessing_template_id: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["source_stream_id"] = self.source_stream_id
|
||||
d["postprocessing_template_id"] = self.postprocessing_template_id
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class StaticImagePictureSource(PictureSource):
|
||||
"""A static image stream from a URL or file path."""
|
||||
|
||||
image_source: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["image_source"] = self.image_source
|
||||
return d
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Picture stream storage using JSON files."""
|
||||
"""Picture source storage using JSON files."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
@@ -6,27 +6,32 @@ 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.storage.picture_source import (
|
||||
PictureSource,
|
||||
ScreenCapturePictureSource,
|
||||
ProcessedPictureSource,
|
||||
StaticImagePictureSource,
|
||||
)
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PictureStreamStore:
|
||||
"""Storage for picture streams.
|
||||
class PictureSourceStore:
|
||||
"""Storage for picture sources.
|
||||
|
||||
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.
|
||||
"""Initialize picture source store.
|
||||
|
||||
Args:
|
||||
file_path: Path to streams JSON file
|
||||
"""
|
||||
self.file_path = Path(file_path)
|
||||
self._streams: Dict[str, PictureStream] = {}
|
||||
self._streams: Dict[str, PictureSource] = {}
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
@@ -38,27 +43,27 @@ class PictureStreamStore:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
streams_data = data.get("picture_streams", {})
|
||||
streams_data = data.get("picture_sources", {})
|
||||
loaded = 0
|
||||
for stream_id, stream_dict in streams_data.items():
|
||||
try:
|
||||
stream = PictureStream.from_dict(stream_dict)
|
||||
stream = PictureSource.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}",
|
||||
f"Failed to load picture source {stream_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if loaded > 0:
|
||||
logger.info(f"Loaded {loaded} picture streams from storage")
|
||||
logger.info(f"Loaded {loaded} picture sources from storage")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load picture streams from {self.file_path}: {e}")
|
||||
logger.error(f"Failed to load picture sources from {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
logger.info(f"Picture stream store initialized with {len(self._streams)} streams")
|
||||
logger.info(f"Picture source store initialized with {len(self._streams)} streams")
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Save all streams to file."""
|
||||
@@ -72,14 +77,14 @@ class PictureStreamStore:
|
||||
|
||||
data = {
|
||||
"version": "1.0.0",
|
||||
"picture_streams": streams_dict,
|
||||
"picture_sources": 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}")
|
||||
logger.error(f"Failed to save picture sources to {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
def _detect_cycle(self, source_stream_id: str, exclude_stream_id: Optional[str] = None) -> bool:
|
||||
@@ -105,24 +110,24 @@ class PictureStreamStore:
|
||||
current_stream = self._streams.get(current_id)
|
||||
if not current_stream:
|
||||
break
|
||||
if current_stream.stream_type != "processed":
|
||||
if not isinstance(current_stream, ProcessedPictureSource):
|
||||
break
|
||||
current_id = current_stream.source_stream_id
|
||||
|
||||
return False
|
||||
|
||||
def get_all_streams(self) -> List[PictureStream]:
|
||||
"""Get all picture streams."""
|
||||
def get_all_streams(self) -> List[PictureSource]:
|
||||
"""Get all picture sources."""
|
||||
return list(self._streams.values())
|
||||
|
||||
def get_stream(self, stream_id: str) -> PictureStream:
|
||||
def get_stream(self, stream_id: str) -> PictureSource:
|
||||
"""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}")
|
||||
raise ValueError(f"Picture source not found: {stream_id}")
|
||||
return self._streams[stream_id]
|
||||
|
||||
def create_stream(
|
||||
@@ -136,8 +141,8 @@ class PictureStreamStore:
|
||||
postprocessing_template_id: Optional[str] = None,
|
||||
image_source: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> PictureStream:
|
||||
"""Create a new picture stream.
|
||||
) -> PictureSource:
|
||||
"""Create a new picture source.
|
||||
|
||||
Args:
|
||||
name: Stream name
|
||||
@@ -181,30 +186,40 @@ class PictureStreamStore:
|
||||
# Check for duplicate name
|
||||
for stream in self._streams.values():
|
||||
if stream.name == name:
|
||||
raise ValueError(f"Picture stream with name '{name}' already exists")
|
||||
raise ValueError(f"Picture source 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,
|
||||
image_source=image_source,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
common = dict(
|
||||
id=stream_id, name=name, stream_type=stream_type,
|
||||
created_at=now, updated_at=now, description=description,
|
||||
)
|
||||
|
||||
stream: PictureSource
|
||||
if stream_type == "raw":
|
||||
stream = ScreenCapturePictureSource(
|
||||
**common,
|
||||
display_index=display_index, # type: ignore[arg-type]
|
||||
capture_template_id=capture_template_id, # type: ignore[arg-type]
|
||||
target_fps=target_fps, # type: ignore[arg-type]
|
||||
)
|
||||
elif stream_type == "processed":
|
||||
stream = ProcessedPictureSource(
|
||||
**common,
|
||||
source_stream_id=source_stream_id, # type: ignore[arg-type]
|
||||
postprocessing_template_id=postprocessing_template_id, # type: ignore[arg-type]
|
||||
)
|
||||
else:
|
||||
stream = StaticImagePictureSource(
|
||||
**common,
|
||||
image_source=image_source, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
self._streams[stream_id] = stream
|
||||
self._save()
|
||||
|
||||
logger.info(f"Created picture stream: {name} ({stream_id}, type={stream_type})")
|
||||
logger.info(f"Created picture source: {name} ({stream_id}, type={stream_type})")
|
||||
return stream
|
||||
|
||||
def update_stream(
|
||||
@@ -218,19 +233,19 @@ class PictureStreamStore:
|
||||
postprocessing_template_id: Optional[str] = None,
|
||||
image_source: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> PictureStream:
|
||||
"""Update an existing picture stream.
|
||||
) -> PictureSource:
|
||||
"""Update an existing picture source.
|
||||
|
||||
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}")
|
||||
raise ValueError(f"Picture source 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 is not None and isinstance(stream, ProcessedPictureSource):
|
||||
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):
|
||||
@@ -238,40 +253,44 @@ class PictureStreamStore:
|
||||
|
||||
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 image_source is not None:
|
||||
stream.image_source = image_source
|
||||
if description is not None:
|
||||
stream.description = description
|
||||
|
||||
if isinstance(stream, ScreenCapturePictureSource):
|
||||
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
|
||||
elif isinstance(stream, ProcessedPictureSource):
|
||||
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
|
||||
elif isinstance(stream, StaticImagePictureSource):
|
||||
if image_source is not None:
|
||||
stream.image_source = image_source
|
||||
|
||||
stream.updated_at = datetime.utcnow()
|
||||
|
||||
self._save()
|
||||
|
||||
logger.info(f"Updated picture stream: {stream_id}")
|
||||
logger.info(f"Updated picture source: {stream_id}")
|
||||
return stream
|
||||
|
||||
def delete_stream(self, stream_id: str) -> None:
|
||||
"""Delete a picture stream.
|
||||
"""Delete a picture source.
|
||||
|
||||
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}")
|
||||
raise ValueError(f"Picture source 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:
|
||||
if isinstance(other_stream, ProcessedPictureSource) and 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}'"
|
||||
@@ -280,7 +299,7 @@ class PictureStreamStore:
|
||||
del self._streams[stream_id]
|
||||
self._save()
|
||||
|
||||
logger.info(f"Deleted picture stream: {stream_id}")
|
||||
logger.info(f"Deleted picture source: {stream_id}")
|
||||
|
||||
def is_referenced_by_device(self, stream_id: str, device_store) -> bool:
|
||||
"""Check if this stream is referenced by any device.
|
||||
@@ -293,7 +312,7 @@ class PictureStreamStore:
|
||||
True if any device references this stream
|
||||
"""
|
||||
for device in device_store.get_all_devices():
|
||||
if getattr(device, "picture_stream_id", None) == stream_id:
|
||||
if getattr(device, "picture_source_id", None) == stream_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -308,7 +327,7 @@ class PictureStreamStore:
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- raw_stream: The terminal PictureStream (raw or static_image)
|
||||
- raw_stream: The terminal PictureSource (raw or static_image)
|
||||
- postprocessing_template_ids: List of PP template IDs (in chain order)
|
||||
|
||||
Raises:
|
||||
@@ -325,7 +344,7 @@ class PictureStreamStore:
|
||||
|
||||
stream = self.get_stream(current_id)
|
||||
|
||||
if stream.stream_type != "processed":
|
||||
if not isinstance(stream, ProcessedPictureSource):
|
||||
return {
|
||||
"raw_stream": stream,
|
||||
"postprocessing_template_ids": postprocessing_template_ids,
|
||||
@@ -1,75 +0,0 @@
|
||||
"""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
|
||||
- "static_image": returns a static frame from a URL or local file path
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
stream_type: str # "raw", "processed", or "static_image"
|
||||
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
|
||||
|
||||
# Static image fields (used when stream_type == "static_image")
|
||||
image_source: 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,
|
||||
"image_source": self.image_source,
|
||||
"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"),
|
||||
image_source=data.get("image_source"),
|
||||
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"),
|
||||
)
|
||||
@@ -31,25 +31,8 @@ class PostprocessingTemplate:
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "PostprocessingTemplate":
|
||||
"""Create template from dictionary.
|
||||
|
||||
Supports migration from legacy flat-field format (gamma/saturation/brightness)
|
||||
to the new filters list format.
|
||||
"""
|
||||
if "filters" in data:
|
||||
filters = [FilterInstance.from_dict(f) for f in data["filters"]]
|
||||
else:
|
||||
# Legacy migration: construct filters from flat fields
|
||||
filters = []
|
||||
brightness = data.get("brightness", 1.0)
|
||||
if brightness != 1.0:
|
||||
filters.append(FilterInstance("brightness", {"value": brightness}))
|
||||
saturation = data.get("saturation", 1.0)
|
||||
if saturation != 1.0:
|
||||
filters.append(FilterInstance("saturation", {"value": saturation}))
|
||||
gamma = data.get("gamma", 2.2)
|
||||
if gamma != 2.2:
|
||||
filters.append(FilterInstance("gamma", {"value": gamma}))
|
||||
"""Create template from dictionary."""
|
||||
filters = [FilterInstance.from_dict(f) for f in data.get("filters", [])]
|
||||
|
||||
return cls(
|
||||
id=data["id"],
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Dict, List, Optional
|
||||
|
||||
from wled_controller.core.filters.filter_instance import FilterInstance
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
from wled_controller.storage.picture_source import ProcessedPictureSource
|
||||
from wled_controller.storage.postprocessing_template import PostprocessingTemplate
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
@@ -209,7 +210,7 @@ class PostprocessingTemplateStore:
|
||||
"""Delete a postprocessing template.
|
||||
|
||||
Raises:
|
||||
ValueError: If template not found or is referenced by a picture stream
|
||||
ValueError: If template not found or is referenced by a picture source
|
||||
"""
|
||||
if template_id not in self._templates:
|
||||
raise ValueError(f"Postprocessing template not found: {template_id}")
|
||||
@@ -219,17 +220,17 @@ class PostprocessingTemplateStore:
|
||||
|
||||
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.
|
||||
def is_referenced_by(self, template_id: str, picture_source_store) -> bool:
|
||||
"""Check if this template is referenced by any picture source.
|
||||
|
||||
Args:
|
||||
template_id: Template ID to check
|
||||
picture_stream_store: PictureStreamStore instance
|
||||
picture_source_store: PictureSourceStore instance
|
||||
|
||||
Returns:
|
||||
True if any picture stream references this template
|
||||
True if any picture source references this template
|
||||
"""
|
||||
for stream in picture_stream_store.get_all_streams():
|
||||
if stream.postprocessing_template_id == template_id:
|
||||
for stream in picture_source_store.get_all_streams():
|
||||
if isinstance(stream, ProcessedPictureSource) and stream.postprocessing_template_id == template_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user