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,
|
PictureStreamListResponse,
|
||||||
PictureStreamTestRequest,
|
PictureStreamTestRequest,
|
||||||
PPTemplateTestRequest,
|
PPTemplateTestRequest,
|
||||||
|
ImageValidateRequest,
|
||||||
|
ImageValidateResponse,
|
||||||
)
|
)
|
||||||
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
|
||||||
@@ -1284,69 +1286,87 @@ async def test_pp_template(
|
|||||||
|
|
||||||
raw_stream = chain["raw_stream"]
|
raw_stream = chain["raw_stream"]
|
||||||
|
|
||||||
# Get capture template from raw stream
|
if raw_stream.stream_type == "static_image":
|
||||||
try:
|
# Static image: load directly
|
||||||
capture_template = template_store.get_template(raw_stream.capture_template_id)
|
from pathlib import Path
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Capture template not found: {raw_stream.capture_template_id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
display_index = raw_stream.display_index
|
source = raw_stream.image_source
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
|
||||||
# Validate engine
|
if source.startswith(("http://", "https://")):
|
||||||
if capture_template.engine_type not in EngineRegistry.get_available_engines():
|
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||||
raise HTTPException(
|
resp = await client.get(source)
|
||||||
status_code=400,
|
resp.raise_for_status()
|
||||||
detail=f"Engine '{capture_template.engine_type}' is not available on this system",
|
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
|
actual_duration = time.perf_counter() - start_time
|
||||||
locked_device_id = processor_manager.get_display_lock_info(display_index)
|
frame_count = 1
|
||||||
if locked_device_id:
|
total_capture_time = actual_duration
|
||||||
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)
|
|
||||||
else:
|
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
|
# Create thumbnail
|
||||||
thumbnail_width = 640
|
thumbnail_width = 640
|
||||||
@@ -1435,6 +1455,7 @@ def _stream_to_response(s) -> PictureStreamResponse:
|
|||||||
target_fps=s.target_fps,
|
target_fps=s.target_fps,
|
||||||
source_stream_id=s.source_stream_id,
|
source_stream_id=s.source_stream_id,
|
||||||
postprocessing_template_id=s.postprocessing_template_id,
|
postprocessing_template_id=s.postprocessing_template_id,
|
||||||
|
image_source=s.image_source,
|
||||||
created_at=s.created_at,
|
created_at=s.created_at,
|
||||||
updated_at=s.updated_at,
|
updated_at=s.updated_at,
|
||||||
description=s.description,
|
description=s.description,
|
||||||
@@ -1456,6 +1477,53 @@ async def list_picture_streams(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
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)
|
@router.post("/api/v1/picture-streams", response_model=PictureStreamResponse, tags=["Picture Streams"], status_code=201)
|
||||||
async def create_picture_stream(
|
async def create_picture_stream(
|
||||||
data: PictureStreamCreate,
|
data: PictureStreamCreate,
|
||||||
@@ -1493,6 +1561,7 @@ async def create_picture_stream(
|
|||||||
target_fps=data.target_fps,
|
target_fps=data.target_fps,
|
||||||
source_stream_id=data.source_stream_id,
|
source_stream_id=data.source_stream_id,
|
||||||
postprocessing_template_id=data.postprocessing_template_id,
|
postprocessing_template_id=data.postprocessing_template_id,
|
||||||
|
image_source=data.image_source,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
)
|
)
|
||||||
return _stream_to_response(stream)
|
return _stream_to_response(stream)
|
||||||
@@ -1536,6 +1605,7 @@ async def update_picture_stream(
|
|||||||
target_fps=data.target_fps,
|
target_fps=data.target_fps,
|
||||||
source_stream_id=data.source_stream_id,
|
source_stream_id=data.source_stream_id,
|
||||||
postprocessing_template_id=data.postprocessing_template_id,
|
postprocessing_template_id=data.postprocessing_template_id,
|
||||||
|
image_source=data.image_source,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
)
|
)
|
||||||
return _stream_to_response(stream)
|
return _stream_to_response(stream)
|
||||||
@@ -1600,69 +1670,88 @@ async def test_picture_stream(
|
|||||||
|
|
||||||
raw_stream = chain["raw_stream"]
|
raw_stream = chain["raw_stream"]
|
||||||
|
|
||||||
# Get capture template from raw stream
|
if raw_stream.stream_type == "static_image":
|
||||||
try:
|
# Static image stream: load image directly, no engine needed
|
||||||
capture_template = template_store.get_template(raw_stream.capture_template_id)
|
from pathlib import Path
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Capture template not found: {raw_stream.capture_template_id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
display_index = raw_stream.display_index
|
source = raw_stream.image_source
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
|
||||||
# Validate engine
|
if source.startswith(("http://", "https://")):
|
||||||
if capture_template.engine_type not in EngineRegistry.get_available_engines():
|
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||||
raise HTTPException(
|
resp = await client.get(source)
|
||||||
status_code=400,
|
resp.raise_for_status()
|
||||||
detail=f"Engine '{capture_template.engine_type}' is not available on this system",
|
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
|
actual_duration = time.perf_counter() - start_time
|
||||||
locked_device_id = processor_manager.get_display_lock_info(display_index)
|
frame_count = 1
|
||||||
if locked_device_id:
|
total_capture_time = actual_duration
|
||||||
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:
|
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
|
# Create thumbnail
|
||||||
thumbnail_width = 640
|
thumbnail_width = 640
|
||||||
|
|||||||
@@ -405,12 +405,13 @@ class PictureStreamCreate(BaseModel):
|
|||||||
"""Request to create a picture stream."""
|
"""Request to create a picture stream."""
|
||||||
|
|
||||||
name: str = Field(description="Stream name", min_length=1, max_length=100)
|
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)
|
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)")
|
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)
|
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)")
|
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)")
|
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)
|
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)
|
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)")
|
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)")
|
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)
|
description: Optional[str] = Field(None, description="Stream description", max_length=500)
|
||||||
|
|
||||||
|
|
||||||
@@ -431,12 +433,13 @@ class PictureStreamResponse(BaseModel):
|
|||||||
|
|
||||||
id: str = Field(description="Stream ID")
|
id: str = Field(description="Stream ID")
|
||||||
name: str = Field(description="Stream name")
|
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")
|
display_index: Optional[int] = Field(None, description="Display index")
|
||||||
capture_template_id: Optional[str] = Field(None, description="Capture template ID")
|
capture_template_id: Optional[str] = Field(None, description="Capture template ID")
|
||||||
target_fps: Optional[int] = Field(None, description="Target FPS")
|
target_fps: Optional[int] = Field(None, description="Target FPS")
|
||||||
source_stream_id: Optional[str] = Field(None, description="Source stream ID")
|
source_stream_id: Optional[str] = Field(None, description="Source stream ID")
|
||||||
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template 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")
|
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="Stream description")
|
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")
|
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")
|
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,
|
PixelMapper,
|
||||||
create_default_calibration,
|
create_default_calibration,
|
||||||
)
|
)
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
from wled_controller.core.capture_engines import CaptureEngine, EngineRegistry
|
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.filters import FilterInstance, FilterRegistry, ImagePool, PostprocessingFilter
|
||||||
from wled_controller.core.pixel_processor import smooth_colors
|
from wled_controller.core.pixel_processor import smooth_colors
|
||||||
from wled_controller.core.screen_capture import extract_border_pixels
|
from wled_controller.core.screen_capture import extract_border_pixels
|
||||||
@@ -108,6 +111,8 @@ class ProcessorState:
|
|||||||
resolved_engine_type: Optional[str] = None
|
resolved_engine_type: Optional[str] = None
|
||||||
resolved_engine_config: Optional[dict] = None
|
resolved_engine_config: Optional[dict] = None
|
||||||
resolved_filters: Optional[List[FilterInstance]] = 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
|
image_pool: Optional[ImagePool] = None
|
||||||
filter_instances: Optional[List[PostprocessingFilter]] = None
|
filter_instances: Optional[List[PostprocessingFilter]] = None
|
||||||
|
|
||||||
@@ -280,19 +285,28 @@ class ProcessorManager:
|
|||||||
raw_stream = chain["raw_stream"]
|
raw_stream = chain["raw_stream"]
|
||||||
pp_template_ids = chain["postprocessing_template_ids"]
|
pp_template_ids = chain["postprocessing_template_ids"]
|
||||||
|
|
||||||
state.resolved_display_index = raw_stream.display_index
|
if raw_stream.stream_type == "static_image":
|
||||||
state.resolved_target_fps = raw_stream.target_fps
|
# 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
|
# Resolve capture engine from raw stream's capture template
|
||||||
if raw_stream.capture_template_id and self._capture_template_store:
|
if raw_stream.capture_template_id and self._capture_template_store:
|
||||||
try:
|
try:
|
||||||
tpl = self._capture_template_store.get_template(raw_stream.capture_template_id)
|
tpl = self._capture_template_store.get_template(raw_stream.capture_template_id)
|
||||||
state.resolved_engine_type = tpl.engine_type
|
state.resolved_engine_type = tpl.engine_type
|
||||||
state.resolved_engine_config = tpl.engine_config
|
state.resolved_engine_config = tpl.engine_config
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"Capture template {raw_stream.capture_template_id} not found, using MSS fallback")
|
logger.warning(f"Capture template {raw_stream.capture_template_id} not found, using MSS fallback")
|
||||||
state.resolved_engine_type = "mss"
|
state.resolved_engine_type = "mss"
|
||||||
state.resolved_engine_config = {}
|
state.resolved_engine_config = {}
|
||||||
|
|
||||||
# Resolve postprocessing: use first PP template in chain
|
# Resolve postprocessing: use first PP template in chain
|
||||||
if pp_template_ids and self._pp_template_store:
|
if pp_template_ids and self._pp_template_store:
|
||||||
@@ -337,6 +351,27 @@ class ProcessorManager:
|
|||||||
state.resolved_engine_type = "mss"
|
state.resolved_engine_type = "mss"
|
||||||
state.resolved_engine_config = {}
|
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):
|
async def start_processing(self, device_id: str):
|
||||||
"""Start screen processing for a device.
|
"""Start screen processing for a device.
|
||||||
|
|
||||||
@@ -373,19 +408,22 @@ 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 from resolved settings
|
# Initialize capture engine from resolved settings (skip for static_image)
|
||||||
try:
|
if state.static_image is not None:
|
||||||
engine_type = state.resolved_engine_type or "mss"
|
logger.info(f"Using static image for device {device_id} ({state.static_image.shape[1]}x{state.static_image.shape[0]})")
|
||||||
engine_config = state.resolved_engine_config or {}
|
else:
|
||||||
engine = EngineRegistry.create_engine(engine_type, engine_config)
|
try:
|
||||||
engine.initialize()
|
engine_type = state.resolved_engine_type or "mss"
|
||||||
state.capture_engine = engine
|
engine_config = state.resolved_engine_config or {}
|
||||||
logger.info(f"Initialized capture engine for device {device_id}: {engine_type}")
|
engine = EngineRegistry.create_engine(engine_type, engine_config)
|
||||||
except Exception as e:
|
engine.initialize()
|
||||||
logger.error(f"Failed to initialize capture engine for device {device_id}: {e}")
|
state.capture_engine = engine
|
||||||
if state.wled_client:
|
logger.info(f"Initialized capture engine for device {device_id}: {engine_type}")
|
||||||
await state.wled_client.disconnect()
|
except Exception as e:
|
||||||
raise RuntimeError(f"Failed to initialize capture engine: {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
|
# Initialize pixel mapper
|
||||||
state.pixel_mapper = PixelMapper(
|
state.pixel_mapper = PixelMapper(
|
||||||
@@ -443,6 +481,9 @@ class ProcessorManager:
|
|||||||
state.capture_engine.cleanup()
|
state.capture_engine.cleanup()
|
||||||
state.capture_engine = None
|
state.capture_engine = None
|
||||||
|
|
||||||
|
# Release cached static image
|
||||||
|
state.static_image = None
|
||||||
|
|
||||||
logger.info(f"Stopped processing for device {device_id}")
|
logger.info(f"Stopped processing for device {device_id}")
|
||||||
|
|
||||||
async def _processing_loop(self, device_id: str):
|
async def _processing_loop(self, device_id: str):
|
||||||
@@ -502,11 +543,17 @@ class ProcessorManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Capture screen using engine
|
# Get frame: static image or live capture
|
||||||
capture = await asyncio.to_thread(
|
if state.static_image is not None:
|
||||||
state.capture_engine.capture_display,
|
h, w = state.static_image.shape[:2]
|
||||||
display_index
|
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
|
# Apply postprocessing filters to the full captured image
|
||||||
if filter_objects:
|
if filter_objects:
|
||||||
|
|||||||
@@ -2953,13 +2953,17 @@ async function runTemplateTest() {
|
|||||||
function buildTestStatsHtml(result) {
|
function buildTestStatsHtml(result) {
|
||||||
const p = result.performance;
|
const p = result.performance;
|
||||||
const res = `${result.full_capture.width}x${result.full_capture.height}`;
|
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.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.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>${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>
|
}
|
||||||
`;
|
html += `
|
||||||
|
<div class="stat-item"><span>Resolution:</span> <strong>${res}</strong></div>`;
|
||||||
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display test results — opens lightbox with stats overlay
|
// Display test results — opens lightbox with stats overlay
|
||||||
@@ -3046,20 +3050,27 @@ async function deleteTemplate(templateId) {
|
|||||||
|
|
||||||
let _cachedStreams = [];
|
let _cachedStreams = [];
|
||||||
let _cachedPPTemplates = [];
|
let _cachedPPTemplates = [];
|
||||||
|
let _cachedCaptureTemplates = [];
|
||||||
let _availableFilters = []; // Loaded from GET /filters
|
let _availableFilters = []; // Loaded from GET /filters
|
||||||
|
|
||||||
async function loadPictureStreams() {
|
async function loadPictureStreams() {
|
||||||
try {
|
try {
|
||||||
// Ensure PP templates are cached so processed stream cards can show filter info
|
// Ensure PP templates and capture templates are cached for stream card display
|
||||||
if (_cachedPPTemplates.length === 0) {
|
if (_cachedPPTemplates.length === 0 || _cachedCaptureTemplates.length === 0) {
|
||||||
try {
|
try {
|
||||||
if (_availableFilters.length === 0) {
|
if (_availableFilters.length === 0) {
|
||||||
const fr = await fetchWithAuth('/filters');
|
const fr = await fetchWithAuth('/filters');
|
||||||
if (fr.ok) { const fd = await fr.json(); _availableFilters = fd.filters || []; }
|
if (fr.ok) { const fd = await fr.json(); _availableFilters = fd.filters || []; }
|
||||||
}
|
}
|
||||||
const pr = await fetchWithAuth('/postprocessing-templates');
|
if (_cachedPPTemplates.length === 0) {
|
||||||
if (pr.ok) { const pd = await pr.json(); _cachedPPTemplates = pd.templates || []; }
|
const pr = await fetchWithAuth('/postprocessing-templates');
|
||||||
} catch (e) { console.warn('Could not pre-load PP templates for streams:', e); }
|
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');
|
const response = await fetchWithAuth('/picture-streams');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -3094,6 +3105,19 @@ function renderPictureStreamsList(streams) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
||||||
<div class="stream-group-header">
|
<div class="stream-group-header">
|
||||||
<span class="stream-group-icon">🎨</span>
|
<span class="stream-group-icon">🎨</span>
|
||||||
@@ -3111,13 +3135,24 @@ function renderPictureStreamsList(streams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderCard = (stream) => {
|
const renderCard = (stream) => {
|
||||||
const typeIcon = stream.stream_type === 'raw' ? '🖥️' : '🎨';
|
const typeIcons = { raw: '🖥️', processed: '🎨', static_image: '🖼️' };
|
||||||
const typeBadge = stream.stream_type === 'raw'
|
const typeIcon = typeIcons[stream.stream_type] || '📺';
|
||||||
? `<span class="badge badge-raw">${t('streams.type.raw')}</span>`
|
const typeBadges = {
|
||||||
: `<span class="badge badge-processed">${t('streams.type.processed')}</span>`;
|
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 = '';
|
let detailsHtml = '';
|
||||||
if (stream.stream_type === 'raw') {
|
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 = `
|
detailsHtml = `
|
||||||
<div class="template-config">
|
<div class="template-config">
|
||||||
<strong>${t('streams.display')}</strong> ${stream.display_index ?? 0}
|
<strong>${t('streams.display')}</strong> ${stream.display_index ?? 0}
|
||||||
@@ -3125,8 +3160,9 @@ function renderPictureStreamsList(streams) {
|
|||||||
<div class="template-config">
|
<div class="template-config">
|
||||||
<strong>${t('streams.target_fps')}</strong> ${stream.target_fps ?? 30}
|
<strong>${t('streams.target_fps')}</strong> ${stream.target_fps ?? 30}
|
||||||
</div>
|
</div>
|
||||||
|
${captureTemplateHtml}
|
||||||
`;
|
`;
|
||||||
} else {
|
} else if (stream.stream_type === 'processed') {
|
||||||
// Find source stream name and PP template name
|
// Find source stream name and PP template name
|
||||||
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
|
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
|
||||||
const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-');
|
const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-');
|
||||||
@@ -3144,6 +3180,15 @@ function renderPictureStreamsList(streams) {
|
|||||||
</div>
|
</div>
|
||||||
${ppTemplateHtml}
|
${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 `
|
return `
|
||||||
@@ -3171,6 +3216,7 @@ function renderPictureStreamsList(streams) {
|
|||||||
|
|
||||||
const rawStreams = streams.filter(s => s.stream_type === 'raw');
|
const rawStreams = streams.filter(s => s.stream_type === 'raw');
|
||||||
const processedStreams = streams.filter(s => s.stream_type === 'processed');
|
const processedStreams = streams.filter(s => s.stream_type === 'processed');
|
||||||
|
const staticImageStreams = streams.filter(s => s.stream_type === 'static_image');
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
@@ -3190,6 +3236,22 @@ function renderPictureStreamsList(streams) {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
// Processed streams section
|
||||||
html += `<div class="stream-group">
|
html += `<div class="stream-group">
|
||||||
<div class="stream-group-header">
|
<div class="stream-group-header">
|
||||||
@@ -3213,18 +3275,28 @@ function onStreamTypeChange() {
|
|||||||
const streamType = document.getElementById('stream-type').value;
|
const streamType = document.getElementById('stream-type').value;
|
||||||
document.getElementById('stream-raw-fields').style.display = streamType === 'raw' ? '' : 'none';
|
document.getElementById('stream-raw-fields').style.display = streamType === 'raw' ? '' : 'none';
|
||||||
document.getElementById('stream-processed-fields').style.display = streamType === 'processed' ? '' : '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) {
|
async function showAddStreamModal(presetType) {
|
||||||
const streamType = presetType || 'raw';
|
const streamType = presetType || 'raw';
|
||||||
const titleKey = streamType === 'raw' ? 'streams.add.raw' : 'streams.add.processed';
|
const titleKeys = { raw: 'streams.add.raw', processed: 'streams.add.processed', static_image: 'streams.add.static_image' };
|
||||||
document.getElementById('stream-modal-title').textContent = t(titleKey);
|
document.getElementById('stream-modal-title').textContent = t(titleKeys[streamType] || 'streams.add');
|
||||||
document.getElementById('stream-form').reset();
|
document.getElementById('stream-form').reset();
|
||||||
document.getElementById('stream-id').value = '';
|
document.getElementById('stream-id').value = '';
|
||||||
document.getElementById('stream-display-index').value = '';
|
document.getElementById('stream-display-index').value = '';
|
||||||
document.getElementById('stream-display-picker-label').textContent = t('displays.picker.select');
|
document.getElementById('stream-display-picker-label').textContent = t('displays.picker.select');
|
||||||
document.getElementById('stream-error').style.display = 'none';
|
document.getElementById('stream-error').style.display = 'none';
|
||||||
document.getElementById('stream-type').value = streamType;
|
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();
|
onStreamTypeChange();
|
||||||
|
|
||||||
// Populate dropdowns
|
// Populate dropdowns
|
||||||
@@ -3242,8 +3314,8 @@ async function editStream(streamId) {
|
|||||||
if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`);
|
if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`);
|
||||||
const stream = await response.json();
|
const stream = await response.json();
|
||||||
|
|
||||||
const editTitleKey = stream.stream_type === 'raw' ? 'streams.edit.raw' : 'streams.edit.processed';
|
const editTitleKeys = { raw: 'streams.edit.raw', processed: 'streams.edit.processed', static_image: 'streams.edit.static_image' };
|
||||||
document.getElementById('stream-modal-title').textContent = t(editTitleKey);
|
document.getElementById('stream-modal-title').textContent = t(editTitleKeys[stream.stream_type] || 'streams.edit');
|
||||||
document.getElementById('stream-id').value = streamId;
|
document.getElementById('stream-id').value = streamId;
|
||||||
document.getElementById('stream-name').value = stream.name;
|
document.getElementById('stream-name').value = stream.name;
|
||||||
document.getElementById('stream-description').value = stream.description || '';
|
document.getElementById('stream-description').value = stream.description || '';
|
||||||
@@ -3251,6 +3323,9 @@ async function editStream(streamId) {
|
|||||||
|
|
||||||
// Set type (hidden input)
|
// Set type (hidden input)
|
||||||
document.getElementById('stream-type').value = stream.stream_type;
|
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();
|
onStreamTypeChange();
|
||||||
|
|
||||||
// Populate dropdowns before setting values
|
// Populate dropdowns before setting values
|
||||||
@@ -3264,9 +3339,15 @@ async function editStream(streamId) {
|
|||||||
const fps = stream.target_fps ?? 30;
|
const fps = stream.target_fps ?? 30;
|
||||||
document.getElementById('stream-target-fps').value = fps;
|
document.getElementById('stream-target-fps').value = fps;
|
||||||
document.getElementById('stream-target-fps-value').textContent = 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-source').value = stream.source_stream_id || '';
|
||||||
document.getElementById('stream-pp-template').value = stream.postprocessing_template_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');
|
const modal = document.getElementById('stream-modal');
|
||||||
@@ -3324,7 +3405,8 @@ async function populateStreamModalDropdowns() {
|
|||||||
if (s.id === editingId) return;
|
if (s.id === editingId) return;
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = s.id;
|
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}`;
|
opt.textContent = `${typeLabel} ${s.name}`;
|
||||||
sourceSelect.appendChild(opt);
|
sourceSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
@@ -3367,9 +3449,16 @@ async function saveStream() {
|
|||||||
payload.display_index = parseInt(document.getElementById('stream-display-index').value) || 0;
|
payload.display_index = parseInt(document.getElementById('stream-display-index').value) || 0;
|
||||||
payload.capture_template_id = document.getElementById('stream-capture-template').value;
|
payload.capture_template_id = document.getElementById('stream-capture-template').value;
|
||||||
payload.target_fps = parseInt(document.getElementById('stream-target-fps').value) || 30;
|
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.source_stream_id = document.getElementById('stream-source').value;
|
||||||
payload.postprocessing_template_id = document.getElementById('stream-pp-template').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 {
|
try {
|
||||||
@@ -3429,6 +3518,56 @@ function closeStreamModal() {
|
|||||||
unlockBody();
|
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 =====
|
// ===== Picture Stream Test =====
|
||||||
|
|
||||||
let _currentTestStreamId = null;
|
let _currentTestStreamId = null;
|
||||||
|
|||||||
@@ -569,6 +569,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="form-group">
|
||||||
<label for="stream-description" data-i18n="streams.description_label">Description (optional):</label>
|
<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...">
|
<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.interpolation_hint": "How to calculate LED color from sampled pixels",
|
||||||
"device.stream_settings.smoothing": "Smoothing:",
|
"device.stream_settings.smoothing": "Smoothing:",
|
||||||
"device.stream_settings.smoothing_hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
|
"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.interpolation_hint": "Как вычислять цвет LED из выбранных пикселей",
|
||||||
"device.stream_settings.smoothing": "Сглаживание:",
|
"device.stream_settings.smoothing": "Сглаживание:",
|
||||||
"device.stream_settings.smoothing_hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.",
|
"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:
|
A picture stream is either:
|
||||||
- "raw": captures from a display using a capture engine template at a target FPS
|
- "raw": captures from a display using a capture engine template at a target FPS
|
||||||
- "processed": applies postprocessing to another picture stream
|
- "processed": applies postprocessing to another picture stream
|
||||||
|
- "static_image": returns a static frame from a URL or local file path
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
stream_type: str # "raw" or "processed"
|
stream_type: str # "raw", "processed", or "static_image"
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -29,6 +30,9 @@ class PictureStream:
|
|||||||
source_stream_id: Optional[str] = None
|
source_stream_id: Optional[str] = None
|
||||||
postprocessing_template_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
|
description: Optional[str] = None
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
@@ -42,6 +46,7 @@ class PictureStream:
|
|||||||
"target_fps": self.target_fps,
|
"target_fps": self.target_fps,
|
||||||
"source_stream_id": self.source_stream_id,
|
"source_stream_id": self.source_stream_id,
|
||||||
"postprocessing_template_id": self.postprocessing_template_id,
|
"postprocessing_template_id": self.postprocessing_template_id,
|
||||||
|
"image_source": self.image_source,
|
||||||
"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,
|
||||||
@@ -59,6 +64,7 @@ class PictureStream:
|
|||||||
target_fps=data.get("target_fps"),
|
target_fps=data.get("target_fps"),
|
||||||
source_stream_id=data.get("source_stream_id"),
|
source_stream_id=data.get("source_stream_id"),
|
||||||
postprocessing_template_id=data.get("postprocessing_template_id"),
|
postprocessing_template_id=data.get("postprocessing_template_id"),
|
||||||
|
image_source=data.get("image_source"),
|
||||||
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()),
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class PictureStreamStore:
|
|||||||
current_stream = self._streams.get(current_id)
|
current_stream = self._streams.get(current_id)
|
||||||
if not current_stream:
|
if not current_stream:
|
||||||
break
|
break
|
||||||
if current_stream.stream_type == "raw":
|
if current_stream.stream_type != "processed":
|
||||||
break
|
break
|
||||||
current_id = current_stream.source_stream_id
|
current_id = current_stream.source_stream_id
|
||||||
|
|
||||||
@@ -134,24 +134,26 @@ class PictureStreamStore:
|
|||||||
target_fps: Optional[int] = None,
|
target_fps: Optional[int] = None,
|
||||||
source_stream_id: Optional[str] = None,
|
source_stream_id: Optional[str] = None,
|
||||||
postprocessing_template_id: Optional[str] = None,
|
postprocessing_template_id: Optional[str] = None,
|
||||||
|
image_source: Optional[str] = None,
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
) -> PictureStream:
|
) -> PictureStream:
|
||||||
"""Create a new picture stream.
|
"""Create a new picture stream.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Stream name
|
name: Stream name
|
||||||
stream_type: "raw" or "processed"
|
stream_type: "raw", "processed", or "static_image"
|
||||||
display_index: Display index (raw streams)
|
display_index: Display index (raw streams)
|
||||||
capture_template_id: Capture template ID (raw streams)
|
capture_template_id: Capture template ID (raw streams)
|
||||||
target_fps: Target FPS (raw streams)
|
target_fps: Target FPS (raw streams)
|
||||||
source_stream_id: Source stream ID (processed streams)
|
source_stream_id: Source stream ID (processed streams)
|
||||||
postprocessing_template_id: Postprocessing template ID (processed streams)
|
postprocessing_template_id: Postprocessing template ID (processed streams)
|
||||||
|
image_source: URL or file path (static_image streams)
|
||||||
description: Optional description
|
description: Optional description
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If validation fails or cycle detected
|
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}")
|
raise ValueError(f"Invalid stream type: {stream_type}")
|
||||||
|
|
||||||
if stream_type == "raw":
|
if stream_type == "raw":
|
||||||
@@ -172,6 +174,9 @@ class PictureStreamStore:
|
|||||||
# Check for cycles
|
# Check for cycles
|
||||||
if self._detect_cycle(source_stream_id):
|
if self._detect_cycle(source_stream_id):
|
||||||
raise ValueError("Cycle detected in stream chain")
|
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
|
# Check for duplicate name
|
||||||
for stream in self._streams.values():
|
for stream in self._streams.values():
|
||||||
@@ -190,6 +195,7 @@ class PictureStreamStore:
|
|||||||
target_fps=target_fps,
|
target_fps=target_fps,
|
||||||
source_stream_id=source_stream_id,
|
source_stream_id=source_stream_id,
|
||||||
postprocessing_template_id=postprocessing_template_id,
|
postprocessing_template_id=postprocessing_template_id,
|
||||||
|
image_source=image_source,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
description=description,
|
description=description,
|
||||||
@@ -210,6 +216,7 @@ class PictureStreamStore:
|
|||||||
target_fps: Optional[int] = None,
|
target_fps: Optional[int] = None,
|
||||||
source_stream_id: Optional[str] = None,
|
source_stream_id: Optional[str] = None,
|
||||||
postprocessing_template_id: Optional[str] = None,
|
postprocessing_template_id: Optional[str] = None,
|
||||||
|
image_source: Optional[str] = None,
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
) -> PictureStream:
|
) -> PictureStream:
|
||||||
"""Update an existing picture stream.
|
"""Update an existing picture stream.
|
||||||
@@ -241,6 +248,8 @@ class PictureStreamStore:
|
|||||||
stream.source_stream_id = source_stream_id
|
stream.source_stream_id = source_stream_id
|
||||||
if postprocessing_template_id is not None:
|
if postprocessing_template_id is not None:
|
||||||
stream.postprocessing_template_id = postprocessing_template_id
|
stream.postprocessing_template_id = postprocessing_template_id
|
||||||
|
if image_source is not None:
|
||||||
|
stream.image_source = image_source
|
||||||
if description is not None:
|
if description is not None:
|
||||||
stream.description = description
|
stream.description = description
|
||||||
|
|
||||||
@@ -289,9 +298,9 @@ class PictureStreamStore:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def resolve_stream_chain(self, stream_id: str) -> dict:
|
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.
|
collecting postprocessing template IDs along the way.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -299,7 +308,7 @@ class PictureStreamStore:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with:
|
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)
|
- postprocessing_template_ids: List of PP template IDs (in chain order)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
@@ -316,7 +325,7 @@ class PictureStreamStore:
|
|||||||
|
|
||||||
stream = self.get_stream(current_id)
|
stream = self.get_stream(current_id)
|
||||||
|
|
||||||
if stream.stream_type == "raw":
|
if stream.stream_type != "processed":
|
||||||
return {
|
return {
|
||||||
"raw_stream": stream,
|
"raw_stream": stream,
|
||||||
"postprocessing_template_ids": postprocessing_template_ids,
|
"postprocessing_template_ids": postprocessing_template_ids,
|
||||||
|
|||||||
Reference in New Issue
Block a user