diff --git a/docs/PHOTO_SOURCES.md b/docs/PHOTO_SOURCES.md index 08e89cd..2d2e4ab 100644 --- a/docs/PHOTO_SOURCES.md +++ b/docs/PHOTO_SOURCES.md @@ -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. diff --git a/docs/openapi.json b/docs/openapi.json index 2f18848..f109ff2 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -10734,6 +10734,16 @@ }, "OperationResult": { "properties": { + "photo_attempts": { + "items": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "type": "array", + "title": "Photo Attempts" + }, "image_id": { "anyOf": [ { diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py index 798cfa1..bb74ff5 100644 --- a/pigway_frame_flow/display/service.py +++ b/pigway_frame_flow/display/service.py @@ -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=?", diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py index 2fcde53..dc8b08b 100644 --- a/pigway_frame_flow/models.py +++ b/pigway_frame_flow/models.py @@ -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 diff --git a/pigway_frame_flow/providers/photo_sources.py b/pigway_frame_flow/providers/photo_sources.py index 3c6f8aa..6334f32 100644 --- a/pigway_frame_flow/providers/photo_sources.py +++ b/pigway_frame_flow/providers/photo_sources.py @@ -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, diff --git a/pigway_frame_flow/renderers/images.py b/pigway_frame_flow/renderers/images.py index 268d066..4fdb9b9 100644 --- a/pigway_frame_flow/renderers/images.py +++ b/pigway_frame_flow/renderers/images.py @@ -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") diff --git a/tests/schema-contract.json b/tests/schema-contract.json index 8693824..fc3d749 100644 --- a/tests/schema-contract.json +++ b/tests/schema-contract.json @@ -652,6 +652,16 @@ }, "OperationResult": { "properties": { + "photo_attempts": { + "items": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "type": "array", + "title": "Photo Attempts" + }, "image_id": { "anyOf": [ { diff --git a/tests/test_photo_failures.py b/tests/test_photo_failures.py new file mode 100644 index 0000000..a8ef885 --- /dev/null +++ b/tests/test_photo_failures.py @@ -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