Handle invalid random photos before device writes and trace selections

This commit is contained in:
Pigway
2026-10-06 08:47:03 +08:00
parent 59345f12a7
commit 0a5b859ca0
8 changed files with 231 additions and 21 deletions
+10
View File
@@ -151,3 +151,13 @@ This optional integration reads the existing shared-space People catalog. It doe
已有数据源 `orientation=null` 保持原来的全部方向,编辑器显示“不筛方向(原有设置)”;新建数据源默认自动。尺寸缓存持久化在 SQLite `photo_dimensions`,按路径、修改时间和大小失效。首次扫描读取本地图片尺寸和 EXIF,不批量生成全图库预览;后续刷新复用缓存。仅 NAS 本地源支持,WebDAV 不批量下载原图测尺寸。自动模式读取统一 Device Model,不依赖 KoKonna 私有字段;90°/270° 交换宽高。
Orientation options: landscape, portrait, auto. Auto follows the target device dimensions and rotation (90°/270° swap width and height). Manual direction controls both selection and rendering. Photo and caption layouts support landscape and portrait. Classification honors EXIF orientation; square photos are excluded from directional matches. Existing sources remain unfiltered (`null`) until changed. Dimensions persist in SQLite and refresh when the file changes. Local NAS sources only; no bulk original downloads from WebDAV. Empty matches fail closed. This controls selection, not photo rotation or stretching.
### Invalid photos and selection tracing
Local catalogs share the renderer's 25-million-pixel limit. Oversized originals
use an existing Synology XL preview when enabled and available; otherwise they
are excluded. Header-only catalog inspection does not guarantee full decoding.
Random rendering tries at most five distinct candidates before any device write,
skipping only invalid or unavailable images. Explicit selections never fall back.
Operation `result.photo_attempts` records source-relative item IDs, names, status
and stable error codes, including failures. This does not retry upload or switch.
+10
View File
@@ -10734,6 +10734,16 @@
},
"OperationResult": {
"properties": {
"photo_attempts": {
"items": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"type": "array",
"title": "Photo Attempts"
},
"image_id": {
"anyOf": [
{
+61 -4
View File
@@ -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,8 +185,35 @@ class DisplayService:
self.registry.get(device_id or self.settings.device_id).device().display
)
size = frame_canvas(display, source.orientation)
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)))
)
if not candidates:
raise FlowError(
E.NOT_FOUND, "Photo source contains no matching images", 404
)
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, content.item_id, target_size=size
content.source_id, item["id"], target_size=size
)
rendered = (
render_captioned_photo(
@@ -195,7 +227,32 @@ class DisplayService:
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"
)
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=?",
+1
View File
@@ -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 -2
View File
@@ -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:
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,
+3 -2
View File
@@ -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")
+10
View File
@@ -652,6 +652,16 @@
},
"OperationResult": {
"properties": {
"photo_attempts": {
"items": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"type": "array",
"title": "Photo Attempts"
},
"image_id": {
"anyOf": [
{
+103
View File
@@ -0,0 +1,103 @@
from PIL import Image
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
def setup_source(service, settings):
root = settings.data_dir.parent / "photos"
root.mkdir()
settings.photo_root = root
for i in range(6):
Image.new("RGB", (300, 200)).save(root / f"{i}.jpg")
service.photo_sources.save(
"family", PhotoSourceInput(name="Family", kind="local", location=".")
)
return root
def test_pixel_limit_excludes_original_and_uses_preview(service, settings, monkeypatch):
root = setup_source(service, settings)
monkeypatch.setattr(
"pigway_frame_flow.providers.photo_sources.dimensions",
lambda *args: (8000, 4000),
)
assert service.photo_sources.catalog("family") == []
preview = root / "@eaDir" / "0.jpg"
preview.mkdir(parents=True)
Image.new("RGB", (100, 80)).save(preview / "SYNOPHOTO_THUMB_XL.jpg")
service.photo_sources.save(
"family",
PhotoSourceInput(
name="Family", kind="local", location=".", synology_previews=True
),
)
items = service.photo_sources.catalog("family")
assert len(items) == 1 and items[0]["provider_preview"]
def test_random_skips_invalid_photo_and_records_attempts(
client, service, settings, monkeypatch
):
setup_source(service, settings)
read = service.photo_sources.read
calls = []
def flaky(*args, **kwargs):
calls.append(args[1])
return (
(b"broken", "bad.jpg", None) if len(calls) == 1 else read(*args, **kwargs)
)
monkeypatch.setattr(service.photo_sources, "read", flaky)
response = client.post(
"/api/v1/devices/living-room/upload",
json={"type": "photo_source", "source_id": "family"},
)
service.process_one()
op = client.get("/api/v1/operations/" + response.json()["operation_id"]).json()
assert op["status"] == "COMPLETED"
assert [x["status"] for x in op["result"]["photo_attempts"]] == [
"SKIPPED",
"PREPARED",
]
assert len(set(calls)) == 2
def test_explicit_invalid_photo_never_changes_selection(
client, service, settings, monkeypatch
):
setup_source(service, settings)
monkeypatch.setattr(
service.photo_sources,
"read",
lambda *args, **kwargs: (b"broken", "bad.jpg", None),
)
response = client.post(
"/api/v1/devices/living-room/upload",
json={"type": "photo_source", "source_id": "family", "item_id": "0.jpg"},
)
service.process_one()
op = client.get("/api/v1/operations/" + response.json()["operation_id"]).json()
assert op["status"] == "FAILED"
assert len(op["result"]["photo_attempts"]) == 1
def test_random_bad_images_bounded_to_five(client, service, settings, monkeypatch):
setup_source(service, settings)
monkeypatch.setattr(
service.photo_sources,
"read",
lambda *args, **kwargs: (b"broken", "bad.jpg", None),
)
response = client.post(
"/api/v1/devices/living-room/upload",
json={"type": "photo_source", "source_id": "family"},
)
service.process_one()
op = client.get("/api/v1/operations/" + response.json()["operation_id"]).json()
assert op["status"] == "FAILED"
assert len(op["result"]["photo_attempts"]) == 5
row = service.db.one(
"SELECT write_started FROM operations WHERE id=?", (op["operation_id"],)
)
assert row["write_started"] == 0