Handle invalid random photos before device writes and trace selections
This commit is contained in:
@@ -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=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.
|
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.
|
||||||
|
|||||||
@@ -10734,6 +10734,16 @@
|
|||||||
},
|
},
|
||||||
"OperationResult": {
|
"OperationResult": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"photo_attempts": {
|
||||||
|
"items": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
"title": "Photo Attempts"
|
||||||
|
},
|
||||||
"image_id": {
|
"image_id": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -132,6 +132,11 @@ class DisplayService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def update(self, id, status, result=None, error=None):
|
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(
|
self.db.execute(
|
||||||
"UPDATE operations SET status=?,updated_at=?,result=COALESCE(?,result),error=? WHERE id=?",
|
"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,))
|
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):
|
if isinstance(content, TemplateContent):
|
||||||
values = self.renderer.resolve(content.template, content.data)
|
values = self.renderer.resolve(content.template, content.data)
|
||||||
data = self.renderer.render(content.template, values, content.profile)
|
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
|
self.registry.get(device_id or self.settings.device_id).device().display
|
||||||
)
|
)
|
||||||
size = frame_canvas(display, source.orientation)
|
size = frame_canvas(display, source.orientation)
|
||||||
data, name, caption = self.photo_sources.read(
|
import random
|
||||||
content.source_id, content.item_id, target_size=size
|
|
||||||
|
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 = (
|
if not candidates:
|
||||||
render_captioned_photo(
|
raise FlowError(
|
||||||
data,
|
E.NOT_FOUND, "Photo source contains no matching images", 404
|
||||||
caption,
|
|
||||||
self.renderer.font,
|
|
||||||
content.fit,
|
|
||||||
content.profile,
|
|
||||||
size=size,
|
|
||||||
)
|
)
|
||||||
if caption
|
attempts = []
|
||||||
else convert(data, content.fit, content.profile, size=size)
|
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):
|
if isinstance(content, URLContent):
|
||||||
data = self.assets.fetch_url(content.url)
|
data = self.assets.fetch_url(content.url)
|
||||||
elif isinstance(content, ImageContent) and content.asset_id:
|
elif isinstance(content, ImageContent) and content.asset_id:
|
||||||
@@ -322,7 +379,7 @@ class DisplayService:
|
|||||||
from pigway_frame_flow.models import Content
|
from pigway_frame_flow.models import Content
|
||||||
|
|
||||||
asset = self.content_asset(
|
asset = self.content_asset(
|
||||||
TypeAdapter(Content).validate_python(payload), device_id
|
TypeAdapter(Content).validate_python(payload), device_id, id
|
||||||
)
|
)
|
||||||
if action == "local-upload":
|
if action == "local-upload":
|
||||||
asset = self.assets.promote(asset["asset_id"])
|
asset = self.assets.promote(asset["asset_id"])
|
||||||
@@ -402,7 +459,7 @@ class DisplayService:
|
|||||||
)
|
)
|
||||||
image_id = matches[0].image_id
|
image_id = matches[0].image_id
|
||||||
else:
|
else:
|
||||||
asset = self.content_asset(content, device_id)
|
asset = self.content_asset(content, device_id, id)
|
||||||
image_id = None
|
image_id = None
|
||||||
self.db.execute(
|
self.db.execute(
|
||||||
"UPDATE device_state SET previous_image_id=? WHERE device_id=?",
|
"UPDATE device_state SET previous_image_id=? WHERE device_id=?",
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ class ErrorResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class OperationResult(BaseModel):
|
class OperationResult(BaseModel):
|
||||||
|
photo_attempts: list[dict[str, str]] = Field(default_factory=list)
|
||||||
image_id: int | None = None
|
image_id: int | None = None
|
||||||
asset_id: str | None = None
|
asset_id: str | None = None
|
||||||
rendered_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_names import path_caption
|
||||||
from pigway_frame_flow.providers.photo_orientation import dimensions, orientation
|
from pigway_frame_flow.providers.photo_orientation import dimensions, orientation
|
||||||
from pigway_frame_flow.providers.synology_photos import SynologyPhotos
|
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.settings import secret
|
||||||
from pigway_frame_flow.storage.database import encode
|
from pigway_frame_flow.storage.database import encode
|
||||||
|
|
||||||
@@ -319,7 +320,7 @@ class PhotoSources:
|
|||||||
if allowed is not None and relative not in allowed:
|
if allowed is not None and relative not in allowed:
|
||||||
continue
|
continue
|
||||||
info = {}
|
info = {}
|
||||||
if wanted:
|
if wanted or source.kind == "local":
|
||||||
key = str(path)
|
key = str(path)
|
||||||
stamp = path.stat()
|
stamp = path.stat()
|
||||||
cached_size = sizes.get(key)
|
cached_size = sizes.get(key)
|
||||||
@@ -334,8 +335,17 @@ class PhotoSources:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
size = dimensions(root, path)
|
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 size is None:
|
||||||
continue
|
if wanted:
|
||||||
|
continue
|
||||||
|
size = (0, 0)
|
||||||
width, height = size
|
width, height = size
|
||||||
size_updates.append(
|
size_updates.append(
|
||||||
(
|
(
|
||||||
@@ -347,13 +357,21 @@ class PhotoSources:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
direction = orientation(width, height)
|
direction = orientation(width, height)
|
||||||
if direction != wanted:
|
if wanted and direction != wanted:
|
||||||
continue
|
continue
|
||||||
info = {
|
info = {
|
||||||
"width": width,
|
"width": width,
|
||||||
"height": height,
|
"height": height,
|
||||||
"orientation": direction,
|
"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(
|
items.append(
|
||||||
{
|
{
|
||||||
**info,
|
**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.errors import FlowError
|
||||||
from pigway_frame_flow.storage.database import now_iso
|
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)):
|
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:
|
with Image.open(BytesIO(data)) as source:
|
||||||
if source.format not in {"JPEG", "PNG", "WEBP"}:
|
if source.format not in {"JPEG", "PNG", "WEBP"}:
|
||||||
raise ValueError()
|
raise ValueError()
|
||||||
if source.width * source.height > 25_000_000:
|
if source.width * source.height > MAX_IMAGE_PIXELS:
|
||||||
raise ValueError()
|
raise ValueError()
|
||||||
img = ImageOps.exif_transpose(source).convert("RGBA")
|
img = ImageOps.exif_transpose(source).convert("RGBA")
|
||||||
canvas = Image.new("RGBA", img.size, "white")
|
canvas = Image.new("RGBA", img.size, "white")
|
||||||
|
|||||||
@@ -652,6 +652,16 @@
|
|||||||
},
|
},
|
||||||
"OperationResult": {
|
"OperationResult": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"photo_attempts": {
|
||||||
|
"items": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
"title": "Photo Attempts"
|
||||||
|
},
|
||||||
"image_id": {
|
"image_id": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user