Handle invalid random photos before device writes and trace selections
This commit is contained in:
@@ -132,6 +132,11 @@ class DisplayService:
|
||||
)
|
||||
|
||||
def update(self, id, status, result=None, error=None):
|
||||
if result:
|
||||
row = self.db.one("SELECT result FROM operations WHERE id=?", (id,))
|
||||
previous = json.loads(row["result"]) if row and row["result"] else {}
|
||||
if "photo_attempts" in previous:
|
||||
result = {**result, "photo_attempts": previous["photo_attempts"]}
|
||||
self.db.execute(
|
||||
"UPDATE operations SET status=?,updated_at=?,result=COALESCE(?,result),error=? WHERE id=?",
|
||||
(
|
||||
@@ -158,7 +163,7 @@ class DisplayService:
|
||||
)
|
||||
return self.db.one("SELECT * FROM device_state WHERE device_id=?", (id,))
|
||||
|
||||
def content_asset(self, content, device_id=None):
|
||||
def content_asset(self, content, device_id=None, operation_id=None):
|
||||
if isinstance(content, TemplateContent):
|
||||
values = self.renderer.resolve(content.template, content.data)
|
||||
data = self.renderer.render(content.template, values, content.profile)
|
||||
@@ -180,22 +185,74 @@ class DisplayService:
|
||||
self.registry.get(device_id or self.settings.device_id).device().display
|
||||
)
|
||||
size = frame_canvas(display, source.orientation)
|
||||
data, name, caption = self.photo_sources.read(
|
||||
content.source_id, content.item_id, target_size=size
|
||||
import random
|
||||
|
||||
items = self.photo_sources.catalog(content.source_id, target_size=size)
|
||||
candidates = (
|
||||
[item for item in items if item["id"] == content.item_id]
|
||||
if content.item_id
|
||||
else random.SystemRandom().sample(items, min(5, len(items)))
|
||||
)
|
||||
rendered = (
|
||||
render_captioned_photo(
|
||||
data,
|
||||
caption,
|
||||
self.renderer.font,
|
||||
content.fit,
|
||||
content.profile,
|
||||
size=size,
|
||||
if not candidates:
|
||||
raise FlowError(
|
||||
E.NOT_FOUND, "Photo source contains no matching images", 404
|
||||
)
|
||||
if caption
|
||||
else convert(data, content.fit, content.profile, size=size)
|
||||
attempts = []
|
||||
for item in candidates:
|
||||
attempt = {
|
||||
"source_id": content.source_id,
|
||||
"item_id": item["id"],
|
||||
"name": item["name"],
|
||||
"status": "SELECTED",
|
||||
}
|
||||
attempts.append(attempt)
|
||||
if operation_id:
|
||||
self.db.execute(
|
||||
"UPDATE operations SET result=? WHERE id=?",
|
||||
(encode({"photo_attempts": attempts}), operation_id),
|
||||
)
|
||||
try:
|
||||
data, name, caption = self.photo_sources.read(
|
||||
content.source_id, item["id"], target_size=size
|
||||
)
|
||||
rendered = (
|
||||
render_captioned_photo(
|
||||
data,
|
||||
caption,
|
||||
self.renderer.font,
|
||||
content.fit,
|
||||
content.profile,
|
||||
size=size,
|
||||
)
|
||||
if caption
|
||||
else convert(data, content.fit, content.profile, size=size)
|
||||
)
|
||||
except FlowError as exc:
|
||||
attempt["status"] = "SKIPPED"
|
||||
attempt["code"] = exc.code
|
||||
if operation_id:
|
||||
self.db.execute(
|
||||
"UPDATE operations SET result=? WHERE id=?",
|
||||
(encode({"photo_attempts": attempts}), operation_id),
|
||||
)
|
||||
# Only local preparation can choose another image. No device
|
||||
# write has begun; explicit selections never change silently.
|
||||
if content.item_id or exc.code not in (
|
||||
E.INVALID_REQUEST,
|
||||
E.NOT_FOUND,
|
||||
):
|
||||
raise
|
||||
continue
|
||||
attempt["status"] = "PREPARED"
|
||||
if operation_id:
|
||||
self.db.execute(
|
||||
"UPDATE operations SET result=? WHERE id=?",
|
||||
(encode({"photo_attempts": attempts}), operation_id),
|
||||
)
|
||||
return self.assets.put(rendered, name)
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST, "No decodable photo found within 5 candidates"
|
||||
)
|
||||
return self.assets.put(rendered, name)
|
||||
if isinstance(content, URLContent):
|
||||
data = self.assets.fetch_url(content.url)
|
||||
elif isinstance(content, ImageContent) and content.asset_id:
|
||||
@@ -322,7 +379,7 @@ class DisplayService:
|
||||
from pigway_frame_flow.models import Content
|
||||
|
||||
asset = self.content_asset(
|
||||
TypeAdapter(Content).validate_python(payload), device_id
|
||||
TypeAdapter(Content).validate_python(payload), device_id, id
|
||||
)
|
||||
if action == "local-upload":
|
||||
asset = self.assets.promote(asset["asset_id"])
|
||||
@@ -402,7 +459,7 @@ class DisplayService:
|
||||
)
|
||||
image_id = matches[0].image_id
|
||||
else:
|
||||
asset = self.content_asset(content, device_id)
|
||||
asset = self.content_asset(content, device_id, id)
|
||||
image_id = None
|
||||
self.db.execute(
|
||||
"UPDATE device_state SET previous_image_id=? WHERE device_id=?",
|
||||
|
||||
@@ -131,6 +131,7 @@ class ErrorResponse(BaseModel):
|
||||
|
||||
|
||||
class OperationResult(BaseModel):
|
||||
photo_attempts: list[dict[str, str]] = Field(default_factory=list)
|
||||
image_id: int | None = None
|
||||
asset_id: str | None = None
|
||||
rendered_asset_id: str | None = None
|
||||
|
||||
@@ -20,6 +20,7 @@ from pigway_frame_flow.models import StrictModel
|
||||
from pigway_frame_flow.providers.photo_names import path_caption
|
||||
from pigway_frame_flow.providers.photo_orientation import dimensions, orientation
|
||||
from pigway_frame_flow.providers.synology_photos import SynologyPhotos
|
||||
from pigway_frame_flow.renderers.images import MAX_IMAGE_PIXELS
|
||||
from pigway_frame_flow.settings import secret
|
||||
from pigway_frame_flow.storage.database import encode
|
||||
|
||||
@@ -319,7 +320,7 @@ class PhotoSources:
|
||||
if allowed is not None and relative not in allowed:
|
||||
continue
|
||||
info = {}
|
||||
if wanted:
|
||||
if wanted or source.kind == "local":
|
||||
key = str(path)
|
||||
stamp = path.stat()
|
||||
cached_size = sizes.get(key)
|
||||
@@ -334,8 +335,17 @@ class PhotoSources:
|
||||
)
|
||||
else:
|
||||
size = dimensions(root, path)
|
||||
if size is None and preview:
|
||||
preview_path = self.thumbnail(path)
|
||||
size = (
|
||||
dimensions(root, preview_path)
|
||||
if preview_path
|
||||
else None
|
||||
)
|
||||
if size is None:
|
||||
continue
|
||||
if wanted:
|
||||
continue
|
||||
size = (0, 0)
|
||||
width, height = size
|
||||
size_updates.append(
|
||||
(
|
||||
@@ -347,13 +357,21 @@ class PhotoSources:
|
||||
)
|
||||
)
|
||||
direction = orientation(width, height)
|
||||
if direction != wanted:
|
||||
if wanted and direction != wanted:
|
||||
continue
|
||||
info = {
|
||||
"width": width,
|
||||
"height": height,
|
||||
"orientation": direction,
|
||||
}
|
||||
if width * height > MAX_IMAGE_PIXELS:
|
||||
if (
|
||||
source.synology_previews
|
||||
and self.thumbnail(path) is not None
|
||||
):
|
||||
preview = True
|
||||
else:
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
**info,
|
||||
|
||||
@@ -11,7 +11,8 @@ from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.storage.database import now_iso
|
||||
|
||||
Image.MAX_IMAGE_PIXELS = 25_000_000
|
||||
MAX_IMAGE_PIXELS = 25_000_000
|
||||
Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS
|
||||
|
||||
|
||||
def convert(data, fit="contain", profile="color", *, size=(1200, 1600)):
|
||||
@@ -19,7 +20,7 @@ def convert(data, fit="contain", profile="color", *, size=(1200, 1600)):
|
||||
with Image.open(BytesIO(data)) as source:
|
||||
if source.format not in {"JPEG", "PNG", "WEBP"}:
|
||||
raise ValueError()
|
||||
if source.width * source.height > 25_000_000:
|
||||
if source.width * source.height > MAX_IMAGE_PIXELS:
|
||||
raise ValueError()
|
||||
img = ImageOps.exif_transpose(source).convert("RGBA")
|
||||
canvas = Image.new("RGBA", img.size, "white")
|
||||
|
||||
Reference in New Issue
Block a user