Add CSPT entity, processed CSS source type, reverse filter, and UI improvements
- Add Color Strip Processing Template (CSPT) entity: reusable filter chains for 1D LED strip postprocessing (backend, storage, API, frontend CRUD) - Add "processed" color strip source type that wraps another CSS source and applies a CSPT filter chain (dataclass, stream, schema, modal, cards) - Add Reverse filter for strip LED order reversal - Add CSPT and processed CSS nodes/edges to visual graph editor - Add CSPT test preview WS endpoint with input source selection - Add device settings CSPT template selector (add + edit modals with hints) - Use icon grids for palette quantization preset selector in filter lists - Use EntitySelect for template references and test modal source selectors - Fix filters.css_filter_template.desc missing localization - Fix icon grid cell height inequality (grid-auto-rows: 1fr) - Rename "Processed" subtab to "Processing Templates" - Localize all new strings (en/ru/zh) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"""Picture source routes."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import time
|
||||
@@ -97,23 +98,26 @@ async def validate_image(
|
||||
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))
|
||||
img_bytes = 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)
|
||||
img_bytes = path
|
||||
|
||||
pil_image = pil_image.convert("RGB")
|
||||
width, height = pil_image.size
|
||||
def _process_image(src):
|
||||
pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src)
|
||||
pil_image = pil_image.convert("RGB")
|
||||
width, height = pil_image.size
|
||||
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 width, height, preview
|
||||
|
||||
# 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()}"
|
||||
width, height, preview = await asyncio.to_thread(_process_image, img_bytes)
|
||||
|
||||
return ImageValidateResponse(
|
||||
valid=True, width=width, height=height, preview=preview
|
||||
@@ -140,18 +144,22 @@ async def get_full_image(
|
||||
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))
|
||||
img_bytes = response.content
|
||||
else:
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
pil_image = Image.open(path)
|
||||
img_bytes = path
|
||||
|
||||
pil_image = pil_image.convert("RGB")
|
||||
buf = io.BytesIO()
|
||||
pil_image.save(buf, format="JPEG", quality=90)
|
||||
buf.seek(0)
|
||||
return Response(content=buf.getvalue(), media_type="image/jpeg")
|
||||
def _encode_full(src):
|
||||
pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src)
|
||||
pil_image = pil_image.convert("RGB")
|
||||
buf = io.BytesIO()
|
||||
pil_image.save(buf, format="JPEG", quality=90)
|
||||
return buf.getvalue()
|
||||
|
||||
jpeg_bytes = await asyncio.to_thread(_encode_full, img_bytes)
|
||||
return Response(content=jpeg_bytes, media_type="image/jpeg")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -326,7 +334,7 @@ async def test_picture_source(
|
||||
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")
|
||||
pil_image = await asyncio.to_thread(lambda: Image.open(path).convert("RGB"))
|
||||
|
||||
actual_duration = time.perf_counter() - start_time
|
||||
frame_count = 1
|
||||
@@ -393,48 +401,50 @@ async def test_picture_source(
|
||||
else:
|
||||
raise ValueError("Unexpected image format from engine")
|
||||
|
||||
# Create thumbnail
|
||||
thumbnail_width = 640
|
||||
aspect_ratio = pil_image.height / pil_image.width
|
||||
thumbnail_height = int(thumbnail_width * aspect_ratio)
|
||||
thumbnail = pil_image.copy()
|
||||
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Apply postprocessing filters if this is a processed stream
|
||||
# Create thumbnail + encode (CPU-bound — run in thread)
|
||||
pp_template_ids = chain["postprocessing_template_ids"]
|
||||
flat_filters = None
|
||||
if pp_template_ids:
|
||||
try:
|
||||
pp_template = pp_store.get_template(pp_template_ids[0])
|
||||
flat_filters = pp_store.resolve_filter_instances(pp_template.filters)
|
||||
if flat_filters:
|
||||
pool = ImagePool()
|
||||
|
||||
def apply_filters(img):
|
||||
arr = np.array(img)
|
||||
for fi in flat_filters:
|
||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
result = f.process_image(arr, pool)
|
||||
if result is not None:
|
||||
arr = result
|
||||
return Image.fromarray(arr)
|
||||
|
||||
thumbnail = apply_filters(thumbnail)
|
||||
pil_image = apply_filters(pil_image)
|
||||
flat_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
|
||||
except ValueError:
|
||||
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
|
||||
|
||||
# Encode thumbnail
|
||||
img_buffer = io.BytesIO()
|
||||
thumbnail.save(img_buffer, format='JPEG', quality=85)
|
||||
img_buffer.seek(0)
|
||||
thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
|
||||
def _create_thumbnails_and_encode(pil_img, filters):
|
||||
thumbnail_w = 640
|
||||
aspect_ratio = pil_img.height / pil_img.width
|
||||
thumbnail_h = int(thumbnail_w * aspect_ratio)
|
||||
thumb = pil_img.copy()
|
||||
thumb.thumbnail((thumbnail_w, thumbnail_h), Image.Resampling.LANCZOS)
|
||||
|
||||
# Encode full-resolution image
|
||||
full_buffer = io.BytesIO()
|
||||
pil_image.save(full_buffer, format='JPEG', quality=90)
|
||||
full_buffer.seek(0)
|
||||
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
|
||||
if filters:
|
||||
pool = ImagePool()
|
||||
def apply_filters(img):
|
||||
arr = np.array(img)
|
||||
for fi in filters:
|
||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
result = f.process_image(arr, pool)
|
||||
if result is not None:
|
||||
arr = result
|
||||
return Image.fromarray(arr)
|
||||
thumb = apply_filters(thumb)
|
||||
pil_img = apply_filters(pil_img)
|
||||
|
||||
img_buffer = io.BytesIO()
|
||||
thumb.save(img_buffer, format='JPEG', quality=85)
|
||||
thumb_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||
|
||||
full_buffer = io.BytesIO()
|
||||
pil_img.save(full_buffer, format='JPEG', quality=90)
|
||||
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
|
||||
|
||||
return thumbnail_w, thumbnail_h, thumb_b64, full_b64
|
||||
|
||||
thumbnail_width, thumbnail_height, thumbnail_b64, full_b64 = await asyncio.to_thread(
|
||||
_create_thumbnails_and_encode, pil_image, flat_filters
|
||||
)
|
||||
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
|
||||
full_data_uri = f"data:image/jpeg;base64,{full_b64}"
|
||||
|
||||
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
|
||||
|
||||
Reference in New Issue
Block a user