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:
2026-02-11 19:57:43 +03:00
parent 4f9c30ef06
commit e0877a9b16
10 changed files with 566 additions and 181 deletions

View File

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

View File

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