Add static image picture stream type with auto-validating UI
Introduces a new "static_image" stream type that loads a frame from a URL or local file path, enabling LED testing with known images or displaying static content. Includes validate-image API endpoint, auto-validation on blur/enter/paste with caching, capture template names on stream cards, and conditional test stats display for single-frame results. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -54,6 +54,8 @@ from wled_controller.api.schemas import (
|
||||
PictureStreamListResponse,
|
||||
PictureStreamTestRequest,
|
||||
PPTemplateTestRequest,
|
||||
ImageValidateRequest,
|
||||
ImageValidateResponse,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings
|
||||
@@ -1284,69 +1286,87 @@ async def test_pp_template(
|
||||
|
||||
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}",
|
||||
)
|
||||
if raw_stream.stream_type == "static_image":
|
||||
# Static image: load directly
|
||||
from pathlib import Path
|
||||
|
||||
display_index = raw_stream.display_index
|
||||
source = raw_stream.image_source
|
||||
start_time = time.perf_counter()
|
||||
|
||||
# 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",
|
||||
)
|
||||
if source.startswith(("http://", "https://")):
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
resp = await client.get(source)
|
||||
resp.raise_for_status()
|
||||
pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB")
|
||||
else:
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
|
||||
pil_image = Image.open(path).convert("RGB")
|
||||
|
||||
# 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 PP template test for {template_id} using stream {test_request.source_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)
|
||||
actual_duration = time.perf_counter() - start_time
|
||||
frame_count = 1
|
||||
total_capture_time = actual_duration
|
||||
else:
|
||||
raise ValueError("Unexpected image format from engine")
|
||||
# Raw capture stream: use engine
|
||||
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
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
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.",
|
||||
)
|
||||
|
||||
engine = EngineRegistry.create_engine(capture_template.engine_type, capture_template.engine_config)
|
||||
|
||||
logger.info(f"Starting {test_request.capture_duration}s PP template test for {template_id} using stream {test_request.source_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")
|
||||
|
||||
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
|
||||
@@ -1435,6 +1455,7 @@ def _stream_to_response(s) -> PictureStreamResponse:
|
||||
target_fps=s.target_fps,
|
||||
source_stream_id=s.source_stream_id,
|
||||
postprocessing_template_id=s.postprocessing_template_id,
|
||||
image_source=s.image_source,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
description=s.description,
|
||||
@@ -1456,6 +1477,53 @@ async def list_picture_streams(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/v1/picture-streams/validate-image", response_model=ImageValidateResponse, tags=["Picture Streams"])
|
||||
async def validate_image(
|
||||
data: ImageValidateRequest,
|
||||
_auth: AuthRequired,
|
||||
):
|
||||
"""Validate an image source (URL or file path) and return a preview thumbnail."""
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
source = data.image_source.strip()
|
||||
if not source:
|
||||
return ImageValidateResponse(valid=False, error="Image source is empty")
|
||||
|
||||
if source.startswith(("http://", "https://")):
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
response = await client.get(source)
|
||||
response.raise_for_status()
|
||||
pil_image = Image.open(io.BytesIO(response.content))
|
||||
else:
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
return ImageValidateResponse(valid=False, error=f"File not found: {source}")
|
||||
pil_image = Image.open(path)
|
||||
|
||||
pil_image = pil_image.convert("RGB")
|
||||
width, height = pil_image.size
|
||||
|
||||
# Create thumbnail preview (max 320px wide)
|
||||
thumb = pil_image.copy()
|
||||
thumb.thumbnail((320, 320), Image.Resampling.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
thumb.save(buf, format="JPEG", quality=80)
|
||||
buf.seek(0)
|
||||
preview = f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}"
|
||||
|
||||
return ImageValidateResponse(
|
||||
valid=True, width=width, height=height, preview=preview
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
return ImageValidateResponse(valid=False, error=f"HTTP {e.response.status_code}: {e.response.reason_phrase}")
|
||||
except httpx.RequestError as e:
|
||||
return ImageValidateResponse(valid=False, error=f"Request failed: {e}")
|
||||
except Exception as e:
|
||||
return ImageValidateResponse(valid=False, error=str(e))
|
||||
|
||||
|
||||
@router.post("/api/v1/picture-streams", response_model=PictureStreamResponse, tags=["Picture Streams"], status_code=201)
|
||||
async def create_picture_stream(
|
||||
data: PictureStreamCreate,
|
||||
@@ -1493,6 +1561,7 @@ async def create_picture_stream(
|
||||
target_fps=data.target_fps,
|
||||
source_stream_id=data.source_stream_id,
|
||||
postprocessing_template_id=data.postprocessing_template_id,
|
||||
image_source=data.image_source,
|
||||
description=data.description,
|
||||
)
|
||||
return _stream_to_response(stream)
|
||||
@@ -1536,6 +1605,7 @@ async def update_picture_stream(
|
||||
target_fps=data.target_fps,
|
||||
source_stream_id=data.source_stream_id,
|
||||
postprocessing_template_id=data.postprocessing_template_id,
|
||||
image_source=data.image_source,
|
||||
description=data.description,
|
||||
)
|
||||
return _stream_to_response(stream)
|
||||
@@ -1600,69 +1670,88 @@ async def test_picture_stream(
|
||||
|
||||
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}",
|
||||
)
|
||||
if raw_stream.stream_type == "static_image":
|
||||
# Static image stream: load image directly, no engine needed
|
||||
from pathlib import Path
|
||||
|
||||
display_index = raw_stream.display_index
|
||||
source = raw_stream.image_source
|
||||
start_time = time.perf_counter()
|
||||
|
||||
# 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",
|
||||
)
|
||||
if source.startswith(("http://", "https://")):
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
resp = await client.get(source)
|
||||
resp.raise_for_status()
|
||||
pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB")
|
||||
else:
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
|
||||
pil_image = Image.open(path).convert("RGB")
|
||||
|
||||
# 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.",
|
||||
)
|
||||
actual_duration = time.perf_counter() - start_time
|
||||
frame_count = 1
|
||||
total_capture_time = actual_duration
|
||||
|
||||
# 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")
|
||||
# Raw capture stream: use engine
|
||||
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
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
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.",
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
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
|
||||
|
||||
@@ -405,12 +405,13 @@ 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")
|
||||
stream_type: Literal["raw", "processed", "static_image"] = 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)")
|
||||
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)")
|
||||
description: Optional[str] = Field(None, description="Stream description", max_length=500)
|
||||
|
||||
|
||||
@@ -423,6 +424,7 @@ class PictureStreamUpdate(BaseModel):
|
||||
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)")
|
||||
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)")
|
||||
description: Optional[str] = Field(None, description="Stream description", max_length=500)
|
||||
|
||||
|
||||
@@ -431,12 +433,13 @@ class PictureStreamResponse(BaseModel):
|
||||
|
||||
id: str = Field(description="Stream ID")
|
||||
name: str = Field(description="Stream name")
|
||||
stream_type: str = Field(description="Stream type (raw or processed)")
|
||||
stream_type: str = Field(description="Stream type (raw, processed, or static_image)")
|
||||
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")
|
||||
image_source: Optional[str] = Field(None, description="Image URL or file path")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
description: Optional[str] = Field(None, description="Stream description")
|
||||
@@ -461,3 +464,19 @@ class PPTemplateTestRequest(BaseModel):
|
||||
|
||||
source_stream_id: str = Field(description="ID of the source picture stream to capture from")
|
||||
capture_duration: float = Field(default=5.0, ge=1.0, le=30.0, description="Duration to capture in seconds")
|
||||
|
||||
|
||||
class ImageValidateRequest(BaseModel):
|
||||
"""Request to validate an image source (URL or file path)."""
|
||||
|
||||
image_source: str = Field(description="Image URL or local file path")
|
||||
|
||||
|
||||
class ImageValidateResponse(BaseModel):
|
||||
"""Response from image validation."""
|
||||
|
||||
valid: bool = Field(description="Whether the image source is accessible and valid")
|
||||
width: Optional[int] = Field(None, description="Image width in pixels")
|
||||
height: Optional[int] = Field(None, description="Image height in pixels")
|
||||
preview: Optional[str] = Field(None, description="Base64-encoded JPEG thumbnail")
|
||||
error: Optional[str] = Field(None, description="Error message if invalid")
|
||||
|
||||
@@ -13,7 +13,10 @@ from wled_controller.core.calibration import (
|
||||
PixelMapper,
|
||||
create_default_calibration,
|
||||
)
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.capture_engines import CaptureEngine, EngineRegistry
|
||||
from wled_controller.core.capture_engines.base import ScreenCapture
|
||||
from wled_controller.core.filters import FilterInstance, FilterRegistry, ImagePool, PostprocessingFilter
|
||||
from wled_controller.core.pixel_processor import smooth_colors
|
||||
from wled_controller.core.screen_capture import extract_border_pixels
|
||||
@@ -108,6 +111,8 @@ class ProcessorState:
|
||||
resolved_engine_type: Optional[str] = None
|
||||
resolved_engine_config: Optional[dict] = None
|
||||
resolved_filters: Optional[List[FilterInstance]] = None
|
||||
# Static image: cached frame for static_image streams (no engine needed)
|
||||
static_image: Optional[np.ndarray] = None
|
||||
image_pool: Optional[ImagePool] = None
|
||||
filter_instances: Optional[List[PostprocessingFilter]] = None
|
||||
|
||||
@@ -280,19 +285,28 @@ class ProcessorManager:
|
||||
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
|
||||
if raw_stream.stream_type == "static_image":
|
||||
# Static image stream: load image once, no engine needed
|
||||
state.resolved_display_index = -1
|
||||
state.resolved_target_fps = 1
|
||||
state.resolved_engine_type = None
|
||||
state.resolved_engine_config = None
|
||||
state.static_image = self._load_static_image(raw_stream.image_source)
|
||||
else:
|
||||
# Raw capture stream
|
||||
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 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:
|
||||
@@ -337,6 +351,27 @@ class ProcessorManager:
|
||||
state.resolved_engine_type = "mss"
|
||||
state.resolved_engine_config = {}
|
||||
|
||||
@staticmethod
|
||||
def _load_static_image(image_source: str) -> np.ndarray:
|
||||
"""Load a static image from URL or file path, return as RGB numpy array."""
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
if image_source.startswith(("http://", "https://")):
|
||||
response = httpx.get(image_source, timeout=15.0, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
pil_image = Image.open(BytesIO(response.content))
|
||||
else:
|
||||
path = Path(image_source)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Image file not found: {image_source}")
|
||||
pil_image = Image.open(path)
|
||||
|
||||
pil_image = pil_image.convert("RGB")
|
||||
return np.array(pil_image)
|
||||
|
||||
async def start_processing(self, device_id: str):
|
||||
"""Start screen processing for a device.
|
||||
|
||||
@@ -373,19 +408,22 @@ class ProcessorManager:
|
||||
logger.error(f"Failed to connect to WLED device {device_id}: {e}")
|
||||
raise RuntimeError(f"Failed to connect to WLED device: {e}")
|
||||
|
||||
# Initialize capture engine from resolved settings
|
||||
try:
|
||||
engine_type = state.resolved_engine_type or "mss"
|
||||
engine_config = state.resolved_engine_config or {}
|
||||
engine = EngineRegistry.create_engine(engine_type, engine_config)
|
||||
engine.initialize()
|
||||
state.capture_engine = engine
|
||||
logger.info(f"Initialized capture engine for device {device_id}: {engine_type}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize capture engine for device {device_id}: {e}")
|
||||
if state.wled_client:
|
||||
await state.wled_client.disconnect()
|
||||
raise RuntimeError(f"Failed to initialize capture engine: {e}")
|
||||
# Initialize capture engine from resolved settings (skip for static_image)
|
||||
if state.static_image is not None:
|
||||
logger.info(f"Using static image for device {device_id} ({state.static_image.shape[1]}x{state.static_image.shape[0]})")
|
||||
else:
|
||||
try:
|
||||
engine_type = state.resolved_engine_type or "mss"
|
||||
engine_config = state.resolved_engine_config or {}
|
||||
engine = EngineRegistry.create_engine(engine_type, engine_config)
|
||||
engine.initialize()
|
||||
state.capture_engine = engine
|
||||
logger.info(f"Initialized capture engine for device {device_id}: {engine_type}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize capture engine for device {device_id}: {e}")
|
||||
if state.wled_client:
|
||||
await state.wled_client.disconnect()
|
||||
raise RuntimeError(f"Failed to initialize capture engine: {e}")
|
||||
|
||||
# Initialize pixel mapper
|
||||
state.pixel_mapper = PixelMapper(
|
||||
@@ -443,6 +481,9 @@ class ProcessorManager:
|
||||
state.capture_engine.cleanup()
|
||||
state.capture_engine = None
|
||||
|
||||
# Release cached static image
|
||||
state.static_image = None
|
||||
|
||||
logger.info(f"Stopped processing for device {device_id}")
|
||||
|
||||
async def _processing_loop(self, device_id: str):
|
||||
@@ -502,11 +543,17 @@ class ProcessorManager:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Capture screen using engine
|
||||
capture = await asyncio.to_thread(
|
||||
state.capture_engine.capture_display,
|
||||
display_index
|
||||
)
|
||||
# Get frame: static image or live capture
|
||||
if state.static_image is not None:
|
||||
h, w = state.static_image.shape[:2]
|
||||
capture = ScreenCapture(
|
||||
image=state.static_image.copy(), width=w, height=h, display_index=-1
|
||||
)
|
||||
else:
|
||||
capture = await asyncio.to_thread(
|
||||
state.capture_engine.capture_display,
|
||||
display_index
|
||||
)
|
||||
|
||||
# Apply postprocessing filters to the full captured image
|
||||
if filter_objects:
|
||||
|
||||
@@ -2953,13 +2953,17 @@ async function runTemplateTest() {
|
||||
function buildTestStatsHtml(result) {
|
||||
const p = result.performance;
|
||||
const res = `${result.full_capture.width}x${result.full_capture.height}`;
|
||||
return `
|
||||
let html = `
|
||||
<div class="stat-item"><span>${t('templates.test.results.duration')}:</span> <strong>${p.capture_duration_s.toFixed(2)}s</strong></div>
|
||||
<div class="stat-item"><span>${t('templates.test.results.frame_count')}:</span> <strong>${p.frame_count}</strong></div>
|
||||
<div class="stat-item"><span>${t('templates.test.results.frame_count')}:</span> <strong>${p.frame_count}</strong></div>`;
|
||||
if (p.frame_count > 1) {
|
||||
html += `
|
||||
<div class="stat-item"><span>${t('templates.test.results.actual_fps')}:</span> <strong>${p.actual_fps.toFixed(1)}</strong></div>
|
||||
<div class="stat-item"><span>${t('templates.test.results.avg_capture_time')}:</span> <strong>${p.avg_capture_time_ms.toFixed(1)}ms</strong></div>
|
||||
<div class="stat-item"><span>Resolution:</span> <strong>${res}</strong></div>
|
||||
`;
|
||||
<div class="stat-item"><span>${t('templates.test.results.avg_capture_time')}:</span> <strong>${p.avg_capture_time_ms.toFixed(1)}ms</strong></div>`;
|
||||
}
|
||||
html += `
|
||||
<div class="stat-item"><span>Resolution:</span> <strong>${res}</strong></div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// Display test results — opens lightbox with stats overlay
|
||||
@@ -3046,20 +3050,27 @@ async function deleteTemplate(templateId) {
|
||||
|
||||
let _cachedStreams = [];
|
||||
let _cachedPPTemplates = [];
|
||||
let _cachedCaptureTemplates = [];
|
||||
let _availableFilters = []; // Loaded from GET /filters
|
||||
|
||||
async function loadPictureStreams() {
|
||||
try {
|
||||
// Ensure PP templates are cached so processed stream cards can show filter info
|
||||
if (_cachedPPTemplates.length === 0) {
|
||||
// Ensure PP templates and capture templates are cached for stream card display
|
||||
if (_cachedPPTemplates.length === 0 || _cachedCaptureTemplates.length === 0) {
|
||||
try {
|
||||
if (_availableFilters.length === 0) {
|
||||
const fr = await fetchWithAuth('/filters');
|
||||
if (fr.ok) { const fd = await fr.json(); _availableFilters = fd.filters || []; }
|
||||
}
|
||||
const pr = await fetchWithAuth('/postprocessing-templates');
|
||||
if (pr.ok) { const pd = await pr.json(); _cachedPPTemplates = pd.templates || []; }
|
||||
} catch (e) { console.warn('Could not pre-load PP templates for streams:', e); }
|
||||
if (_cachedPPTemplates.length === 0) {
|
||||
const pr = await fetchWithAuth('/postprocessing-templates');
|
||||
if (pr.ok) { const pd = await pr.json(); _cachedPPTemplates = pd.templates || []; }
|
||||
}
|
||||
if (_cachedCaptureTemplates.length === 0) {
|
||||
const cr = await fetchWithAuth('/capture-templates');
|
||||
if (cr.ok) { const cd = await cr.json(); _cachedCaptureTemplates = cd.templates || []; }
|
||||
}
|
||||
} catch (e) { console.warn('Could not pre-load templates for streams:', e); }
|
||||
}
|
||||
const response = await fetchWithAuth('/picture-streams');
|
||||
if (!response.ok) {
|
||||
@@ -3094,6 +3105,19 @@ function renderPictureStreamsList(streams) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stream-group">
|
||||
<div class="stream-group-header">
|
||||
<span class="stream-group-icon">🖼️</span>
|
||||
<span class="stream-group-title">${t('streams.group.static_image')}</span>
|
||||
<span class="stream-group-count">0</span>
|
||||
</div>
|
||||
<div class="templates-grid">
|
||||
<div class="template-card add-template-card" onclick="showAddStreamModal('static_image')">
|
||||
<div class="add-template-icon">+</div>
|
||||
<div class="add-template-label">${t('streams.add.static_image')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stream-group">
|
||||
<div class="stream-group-header">
|
||||
<span class="stream-group-icon">🎨</span>
|
||||
@@ -3111,13 +3135,24 @@ function renderPictureStreamsList(streams) {
|
||||
}
|
||||
|
||||
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>`;
|
||||
const typeIcons = { raw: '🖥️', processed: '🎨', static_image: '🖼️' };
|
||||
const typeIcon = typeIcons[stream.stream_type] || '📺';
|
||||
const typeBadges = {
|
||||
raw: `<span class="badge badge-raw">${t('streams.type.raw')}</span>`,
|
||||
processed: `<span class="badge badge-processed">${t('streams.type.processed')}</span>`,
|
||||
static_image: `<span class="badge badge-processed">${t('streams.type.static_image')}</span>`,
|
||||
};
|
||||
const typeBadge = typeBadges[stream.stream_type] || '';
|
||||
|
||||
let detailsHtml = '';
|
||||
if (stream.stream_type === 'raw') {
|
||||
let captureTemplateHtml = '';
|
||||
if (stream.capture_template_id) {
|
||||
const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id);
|
||||
if (capTmpl) {
|
||||
captureTemplateHtml = `<div class="template-config"><strong>${t('streams.capture_template')}</strong> ${escapeHtml(capTmpl.name)}</div>`;
|
||||
}
|
||||
}
|
||||
detailsHtml = `
|
||||
<div class="template-config">
|
||||
<strong>${t('streams.display')}</strong> ${stream.display_index ?? 0}
|
||||
@@ -3125,8 +3160,9 @@ function renderPictureStreamsList(streams) {
|
||||
<div class="template-config">
|
||||
<strong>${t('streams.target_fps')}</strong> ${stream.target_fps ?? 30}
|
||||
</div>
|
||||
${captureTemplateHtml}
|
||||
`;
|
||||
} else {
|
||||
} else if (stream.stream_type === 'processed') {
|
||||
// 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 || '-');
|
||||
@@ -3144,6 +3180,15 @@ function renderPictureStreamsList(streams) {
|
||||
</div>
|
||||
${ppTemplateHtml}
|
||||
`;
|
||||
} else if (stream.stream_type === 'static_image') {
|
||||
const src = stream.image_source || '';
|
||||
const truncated = src.length > 50 ? src.substring(0, 47) + '...' : src;
|
||||
detailsHtml = `
|
||||
<div class="template-config">
|
||||
<strong>${t('streams.image_source')}</strong>
|
||||
</div>
|
||||
<div class="stream-card-image-source" title="${escapeHtml(src)}">${escapeHtml(truncated)}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
@@ -3171,6 +3216,7 @@ function renderPictureStreamsList(streams) {
|
||||
|
||||
const rawStreams = streams.filter(s => s.stream_type === 'raw');
|
||||
const processedStreams = streams.filter(s => s.stream_type === 'processed');
|
||||
const staticImageStreams = streams.filter(s => s.stream_type === 'static_image');
|
||||
|
||||
let html = '';
|
||||
|
||||
@@ -3190,6 +3236,22 @@ function renderPictureStreamsList(streams) {
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Static Image streams section
|
||||
html += `<div class="stream-group">
|
||||
<div class="stream-group-header">
|
||||
<span class="stream-group-icon">🖼️</span>
|
||||
<span class="stream-group-title">${t('streams.group.static_image')}</span>
|
||||
<span class="stream-group-count">${staticImageStreams.length}</span>
|
||||
</div>
|
||||
<div class="templates-grid">
|
||||
${staticImageStreams.map(renderCard).join('')}
|
||||
<div class="template-card add-template-card" onclick="showAddStreamModal('static_image')">
|
||||
<div class="add-template-icon">+</div>
|
||||
<div class="add-template-label">${t('streams.add.static_image')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Processed streams section
|
||||
html += `<div class="stream-group">
|
||||
<div class="stream-group-header">
|
||||
@@ -3213,18 +3275,28 @@ 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';
|
||||
document.getElementById('stream-static-image-fields').style.display = streamType === 'static_image' ? '' : 'none';
|
||||
}
|
||||
|
||||
async function showAddStreamModal(presetType) {
|
||||
const streamType = presetType || 'raw';
|
||||
const titleKey = streamType === 'raw' ? 'streams.add.raw' : 'streams.add.processed';
|
||||
document.getElementById('stream-modal-title').textContent = t(titleKey);
|
||||
const titleKeys = { raw: 'streams.add.raw', processed: 'streams.add.processed', static_image: 'streams.add.static_image' };
|
||||
document.getElementById('stream-modal-title').textContent = t(titleKeys[streamType] || 'streams.add');
|
||||
document.getElementById('stream-form').reset();
|
||||
document.getElementById('stream-id').value = '';
|
||||
document.getElementById('stream-display-index').value = '';
|
||||
document.getElementById('stream-display-picker-label').textContent = t('displays.picker.select');
|
||||
document.getElementById('stream-error').style.display = 'none';
|
||||
document.getElementById('stream-type').value = streamType;
|
||||
// Clear static image preview and wire up auto-validation
|
||||
_lastValidatedImageSource = '';
|
||||
const imgSrcInput = document.getElementById('stream-image-source');
|
||||
imgSrcInput.value = '';
|
||||
document.getElementById('stream-image-preview-container').style.display = 'none';
|
||||
document.getElementById('stream-image-validation-status').style.display = 'none';
|
||||
imgSrcInput.onblur = () => validateStaticImage();
|
||||
imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
|
||||
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
|
||||
onStreamTypeChange();
|
||||
|
||||
// Populate dropdowns
|
||||
@@ -3242,8 +3314,8 @@ async function editStream(streamId) {
|
||||
if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`);
|
||||
const stream = await response.json();
|
||||
|
||||
const editTitleKey = stream.stream_type === 'raw' ? 'streams.edit.raw' : 'streams.edit.processed';
|
||||
document.getElementById('stream-modal-title').textContent = t(editTitleKey);
|
||||
const editTitleKeys = { raw: 'streams.edit.raw', processed: 'streams.edit.processed', static_image: 'streams.edit.static_image' };
|
||||
document.getElementById('stream-modal-title').textContent = t(editTitleKeys[stream.stream_type] || 'streams.edit');
|
||||
document.getElementById('stream-id').value = streamId;
|
||||
document.getElementById('stream-name').value = stream.name;
|
||||
document.getElementById('stream-description').value = stream.description || '';
|
||||
@@ -3251,6 +3323,9 @@ async function editStream(streamId) {
|
||||
|
||||
// Set type (hidden input)
|
||||
document.getElementById('stream-type').value = stream.stream_type;
|
||||
// Clear static image preview
|
||||
document.getElementById('stream-image-preview-container').style.display = 'none';
|
||||
document.getElementById('stream-image-validation-status').style.display = 'none';
|
||||
onStreamTypeChange();
|
||||
|
||||
// Populate dropdowns before setting values
|
||||
@@ -3264,9 +3339,15 @@ async function editStream(streamId) {
|
||||
const fps = stream.target_fps ?? 30;
|
||||
document.getElementById('stream-target-fps').value = fps;
|
||||
document.getElementById('stream-target-fps-value').textContent = fps;
|
||||
} else {
|
||||
} else if (stream.stream_type === 'processed') {
|
||||
document.getElementById('stream-source').value = stream.source_stream_id || '';
|
||||
document.getElementById('stream-pp-template').value = stream.postprocessing_template_id || '';
|
||||
} else if (stream.stream_type === 'static_image') {
|
||||
document.getElementById('stream-image-source').value = stream.image_source || '';
|
||||
// Auto-validate to show preview
|
||||
if (stream.image_source) {
|
||||
validateStaticImage();
|
||||
}
|
||||
}
|
||||
|
||||
const modal = document.getElementById('stream-modal');
|
||||
@@ -3324,7 +3405,8 @@ async function populateStreamModalDropdowns() {
|
||||
if (s.id === editingId) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
const typeLabel = s.stream_type === 'raw' ? '🖥️' : '🎨';
|
||||
const typeLabels = { raw: '🖥️', processed: '🎨', static_image: '🖼️' };
|
||||
const typeLabel = typeLabels[s.stream_type] || '📺';
|
||||
opt.textContent = `${typeLabel} ${s.name}`;
|
||||
sourceSelect.appendChild(opt);
|
||||
});
|
||||
@@ -3367,9 +3449,16 @@ async function saveStream() {
|
||||
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 {
|
||||
} else if (streamType === 'processed') {
|
||||
payload.source_stream_id = document.getElementById('stream-source').value;
|
||||
payload.postprocessing_template_id = document.getElementById('stream-pp-template').value;
|
||||
} else if (streamType === 'static_image') {
|
||||
const imageSource = document.getElementById('stream-image-source').value.trim();
|
||||
if (!imageSource) {
|
||||
showToast(t('streams.error.required'), 'error');
|
||||
return;
|
||||
}
|
||||
payload.image_source = imageSource;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -3429,6 +3518,56 @@ function closeStreamModal() {
|
||||
unlockBody();
|
||||
}
|
||||
|
||||
let _lastValidatedImageSource = '';
|
||||
|
||||
async function validateStaticImage() {
|
||||
const source = document.getElementById('stream-image-source').value.trim();
|
||||
const previewContainer = document.getElementById('stream-image-preview-container');
|
||||
const previewImg = document.getElementById('stream-image-preview');
|
||||
const infoEl = document.getElementById('stream-image-info');
|
||||
const statusEl = document.getElementById('stream-image-validation-status');
|
||||
|
||||
if (!source) {
|
||||
_lastValidatedImageSource = '';
|
||||
previewContainer.style.display = 'none';
|
||||
statusEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
if (source === _lastValidatedImageSource) return;
|
||||
|
||||
// Show loading state
|
||||
statusEl.textContent = t('streams.validate_image.validating');
|
||||
statusEl.className = 'validation-status loading';
|
||||
statusEl.style.display = 'block';
|
||||
previewContainer.style.display = 'none';
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/picture-streams/validate-image', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ image_source: source }),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
_lastValidatedImageSource = source;
|
||||
if (data.valid) {
|
||||
previewImg.src = data.preview;
|
||||
infoEl.textContent = `${data.width} × ${data.height} px`;
|
||||
previewContainer.style.display = '';
|
||||
statusEl.textContent = t('streams.validate_image.valid');
|
||||
statusEl.className = 'validation-status success';
|
||||
} else {
|
||||
previewContainer.style.display = 'none';
|
||||
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${data.error}`;
|
||||
statusEl.className = 'validation-status error';
|
||||
}
|
||||
} catch (err) {
|
||||
previewContainer.style.display = 'none';
|
||||
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${err.message}`;
|
||||
statusEl.className = 'validation-status error';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Picture Stream Test =====
|
||||
|
||||
let _currentTestStreamId = null;
|
||||
|
||||
@@ -569,6 +569,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Static image fields -->
|
||||
<div id="stream-static-image-fields" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label for="stream-image-source" data-i18n="streams.image_source">Image Source:</label>
|
||||
<input type="text" id="stream-image-source" data-i18n-placeholder="streams.image_source.placeholder" placeholder="https://example.com/image.jpg or C:\path\to\image.png">
|
||||
<small class="form-hint" data-i18n="streams.image_source.hint">Enter a URL (http/https) or local file path to an image</small>
|
||||
</div>
|
||||
<div id="stream-image-preview-container" class="image-preview-container" style="display: none;">
|
||||
<img id="stream-image-preview" class="stream-image-preview" src="" alt="Preview">
|
||||
<div id="stream-image-info" class="stream-image-info"></div>
|
||||
</div>
|
||||
<div id="stream-image-validation-status" class="validation-status" style="display: none;"></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...">
|
||||
|
||||
@@ -280,5 +280,15 @@
|
||||
"device.stream_settings.interpolation_hint": "How to calculate LED color from sampled pixels",
|
||||
"device.stream_settings.smoothing": "Smoothing:",
|
||||
"device.stream_settings.smoothing_hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
|
||||
"device.tip.stream_selector": "Configure picture stream and LED projection settings for this device"
|
||||
"device.tip.stream_selector": "Configure picture stream and LED projection settings for this device",
|
||||
"streams.group.static_image": "Static Image Streams",
|
||||
"streams.add.static_image": "Add Static Image",
|
||||
"streams.edit.static_image": "Edit Static Image",
|
||||
"streams.type.static_image": "Static Image",
|
||||
"streams.image_source": "Image Source:",
|
||||
"streams.image_source.placeholder": "https://example.com/image.jpg or C:\\path\\to\\image.png",
|
||||
"streams.image_source.hint": "Enter a URL (http/https) or local file path to an image",
|
||||
"streams.validate_image.validating": "Validating...",
|
||||
"streams.validate_image.valid": "Image accessible",
|
||||
"streams.validate_image.invalid": "Image not accessible"
|
||||
}
|
||||
|
||||
@@ -280,5 +280,15 @@
|
||||
"device.stream_settings.interpolation_hint": "Как вычислять цвет LED из выбранных пикселей",
|
||||
"device.stream_settings.smoothing": "Сглаживание:",
|
||||
"device.stream_settings.smoothing_hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.",
|
||||
"device.tip.stream_selector": "Настройки видеопотока и проекции LED для этого устройства"
|
||||
"device.tip.stream_selector": "Настройки видеопотока и проекции LED для этого устройства",
|
||||
"streams.group.static_image": "Статические изображения",
|
||||
"streams.add.static_image": "Добавить статическое изображение",
|
||||
"streams.edit.static_image": "Редактировать статическое изображение",
|
||||
"streams.type.static_image": "Статическое изображение",
|
||||
"streams.image_source": "Источник изображения:",
|
||||
"streams.image_source.placeholder": "https://example.com/image.jpg или C:\\path\\to\\image.png",
|
||||
"streams.image_source.hint": "Введите URL (http/https) или локальный путь к изображению",
|
||||
"streams.validate_image.validating": "Проверка...",
|
||||
"streams.validate_image.valid": "Изображение доступно",
|
||||
"streams.validate_image.invalid": "Изображение недоступно"
|
||||
}
|
||||
|
||||
@@ -2360,3 +2360,45 @@ input:-webkit-autofill:focus {
|
||||
}
|
||||
}
|
||||
|
||||
/* Static image stream styles */
|
||||
.image-preview-container {
|
||||
text-align: center;
|
||||
margin: 12px 0;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.stream-image-preview {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.stream-image-info {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.validation-status {
|
||||
font-size: 0.8rem;
|
||||
margin-top: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.validation-status.success {
|
||||
color: #4caf50;
|
||||
}
|
||||
.validation-status.error {
|
||||
color: #f44336;
|
||||
}
|
||||
.validation-status.loading {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stream-card-image-source {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
word-break: break-all;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,12 @@ class PictureStream:
|
||||
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" or "processed"
|
||||
stream_type: str # "raw", "processed", or "static_image"
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -29,6 +30,9 @@ class PictureStream:
|
||||
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:
|
||||
@@ -42,6 +46,7 @@ class PictureStream:
|
||||
"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,
|
||||
@@ -59,6 +64,7 @@ class PictureStream:
|
||||
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()),
|
||||
|
||||
@@ -105,7 +105,7 @@ class PictureStreamStore:
|
||||
current_stream = self._streams.get(current_id)
|
||||
if not current_stream:
|
||||
break
|
||||
if current_stream.stream_type == "raw":
|
||||
if current_stream.stream_type != "processed":
|
||||
break
|
||||
current_id = current_stream.source_stream_id
|
||||
|
||||
@@ -134,24 +134,26 @@ class PictureStreamStore:
|
||||
target_fps: Optional[int] = None,
|
||||
source_stream_id: Optional[str] = None,
|
||||
postprocessing_template_id: Optional[str] = None,
|
||||
image_source: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> PictureStream:
|
||||
"""Create a new picture stream.
|
||||
|
||||
Args:
|
||||
name: Stream name
|
||||
stream_type: "raw" or "processed"
|
||||
stream_type: "raw", "processed", or "static_image"
|
||||
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)
|
||||
image_source: URL or file path (static_image streams)
|
||||
description: Optional description
|
||||
|
||||
Raises:
|
||||
ValueError: If validation fails or cycle detected
|
||||
"""
|
||||
if stream_type not in ("raw", "processed"):
|
||||
if stream_type not in ("raw", "processed", "static_image"):
|
||||
raise ValueError(f"Invalid stream type: {stream_type}")
|
||||
|
||||
if stream_type == "raw":
|
||||
@@ -172,6 +174,9 @@ class PictureStreamStore:
|
||||
# Check for cycles
|
||||
if self._detect_cycle(source_stream_id):
|
||||
raise ValueError("Cycle detected in stream chain")
|
||||
elif stream_type == "static_image":
|
||||
if not image_source:
|
||||
raise ValueError("Static image streams require image_source")
|
||||
|
||||
# Check for duplicate name
|
||||
for stream in self._streams.values():
|
||||
@@ -190,6 +195,7 @@ class PictureStreamStore:
|
||||
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,
|
||||
@@ -210,6 +216,7 @@ class PictureStreamStore:
|
||||
target_fps: Optional[int] = None,
|
||||
source_stream_id: Optional[str] = None,
|
||||
postprocessing_template_id: Optional[str] = None,
|
||||
image_source: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> PictureStream:
|
||||
"""Update an existing picture stream.
|
||||
@@ -241,6 +248,8 @@ class PictureStreamStore:
|
||||
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
|
||||
|
||||
@@ -289,9 +298,9 @@ class PictureStreamStore:
|
||||
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.
|
||||
"""Resolve a stream chain to get the terminal stream and collected postprocessing templates.
|
||||
|
||||
Walks the chain from the given stream to the root raw stream,
|
||||
Walks the chain from the given stream to a terminal stream (raw or static_image),
|
||||
collecting postprocessing template IDs along the way.
|
||||
|
||||
Args:
|
||||
@@ -299,7 +308,7 @@ class PictureStreamStore:
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- raw_stream: The root raw PictureStream
|
||||
- raw_stream: The terminal PictureStream (raw or static_image)
|
||||
- postprocessing_template_ids: List of PP template IDs (in chain order)
|
||||
|
||||
Raises:
|
||||
@@ -316,7 +325,7 @@ class PictureStreamStore:
|
||||
|
||||
stream = self.get_stream(current_id)
|
||||
|
||||
if stream.stream_type == "raw":
|
||||
if stream.stream_type != "processed":
|
||||
return {
|
||||
"raw_stream": stream,
|
||||
"postprocessing_template_ids": postprocessing_template_ids,
|
||||
|
||||
Reference in New Issue
Block a user