fix: honor Synology photo orientation before landscape rendering
This commit is contained in:
@@ -30,4 +30,4 @@ CMD ["python", "-m", "uvicorn", "pigway_frame_flow.main:app", "--host", "0.0.0.0
|
||||
ARG BUILD_COMMIT
|
||||
ARG BUILD_TIME
|
||||
ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME
|
||||
LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.17" org.opencontainers.image.revision=$BUILD_COMMIT
|
||||
LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.18" org.opencontainers.image.revision=$BUILD_COMMIT
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "PIGWay Frame Flow API",
|
||||
"version": "0.1.9"
|
||||
"version": "0.1.18"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/devices/{device_id}/widgets/selection": {
|
||||
@@ -15227,7 +15227,7 @@
|
||||
"version": {
|
||||
"type": "string",
|
||||
"title": "Version",
|
||||
"default": "0.1.9"
|
||||
"default": "0.1.18"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -17490,7 +17490,7 @@
|
||||
"version": {
|
||||
"type": "string",
|
||||
"title": "Version",
|
||||
"default": "0.1.9"
|
||||
"default": "0.1.18"
|
||||
},
|
||||
"api_version": {
|
||||
"type": "string",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# v0.1.18
|
||||
|
||||
- Synology Photos 列表显式请求照片方向信息;按 EXIF 旋转后的宽高筛选横向、竖向和自动方向,避免存储像素为横向的竖拍照片误入横图来源。
|
||||
- 渲染前复核实际取得图片的 EXIF 方向;若元数据或缓存预览与来源方向不符,则跳过该候选。显式选择方向不符的照片时返回明确错误。
|
||||
- 保持既有水印和铺满布局,不修改 NAS 原照片、来源设置或现有图库;修复不自动重新生成、上传或切换已显示图片。
|
||||
- 源码及镜像保留 LICENSE、NOTICE;公开验证只使用合成照片和通用示例,私有部署参数与回执保存在仓库之外。
|
||||
@@ -126,7 +126,7 @@ def create_app(settings=None, start_worker=True):
|
||||
app = FastAPI(
|
||||
title="PIGWay Frame Flow API",
|
||||
docs_url=None,
|
||||
version="0.1.17",
|
||||
version="0.1.18",
|
||||
lifespan=lifespan,
|
||||
responses=error_responses,
|
||||
)
|
||||
@@ -1040,7 +1040,7 @@ def create_app(settings=None, start_worker=True):
|
||||
supports_upload=True,
|
||||
),
|
||||
],
|
||||
version="0.1.17",
|
||||
version="0.1.18",
|
||||
mock=settings.mock,
|
||||
write_enabled=settings.write_enabled,
|
||||
api_key_configured=bool(settings.api_key)
|
||||
|
||||
@@ -366,7 +366,7 @@ class SettingsView(BaseModel):
|
||||
|
||||
class Health(BaseModel):
|
||||
status: str
|
||||
version: str = "0.1.17"
|
||||
version: str = "0.1.18"
|
||||
|
||||
|
||||
class Readiness(BaseModel):
|
||||
@@ -397,7 +397,7 @@ class RestoreRequest(StrictModel):
|
||||
|
||||
class Version(BaseModel):
|
||||
product: str = "PIGWay Frame Flow"
|
||||
version: str = "0.1.17"
|
||||
version: str = "0.1.18"
|
||||
api_version: str = "v1"
|
||||
commit: str = "development"
|
||||
build_time: str = "unknown"
|
||||
|
||||
@@ -662,13 +662,27 @@ class PhotoSources:
|
||||
data = self.assets.fetch_url(item["id"], headers=self.headers(source))
|
||||
if len(data) > self.settings.max_bytes:
|
||||
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
|
||||
if source.minimum_short_edge or source.minimum_long_edge:
|
||||
if source.orientation or source.minimum_short_edge or source.minimum_long_edge:
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(BytesIO(data)) as image:
|
||||
if not self.resolution_matches(source, *image.size):
|
||||
width, height = image.size
|
||||
if image.getexif().get(274, 1) in (5, 6, 7, 8):
|
||||
width, height = height, width
|
||||
wanted = (
|
||||
orientation(*target_size)
|
||||
if source.orientation == "auto"
|
||||
else source.orientation
|
||||
)
|
||||
# Validate the fetched rendition before a crop can hide a mismatch.
|
||||
if wanted and orientation(width, height) != wanted:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST,
|
||||
"Photo direction does not match the source orientation",
|
||||
)
|
||||
if not self.resolution_matches(source, width, height):
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST,
|
||||
"Photo is below the configured minimum resolution",
|
||||
|
||||
@@ -299,7 +299,9 @@ class SynologyPhotos:
|
||||
rows = self.pages(
|
||||
prefix + ".Browse.Item",
|
||||
7,
|
||||
additional=json.dumps(["folder", "thumbnail", "resolution"]),
|
||||
additional=json.dumps(
|
||||
["folder", "thumbnail", "resolution", "orientation"]
|
||||
),
|
||||
**({"person_id": person} if person is not None else {}),
|
||||
)
|
||||
groups.append(
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
|
||||
<body><aside class="sidebar"><a class="brand" href="/"><span>PIGWay Frame Flow<small>Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.17</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=photo-quality-v0117"></script><script src="/ui/features.js?v=photo-quality-v0117"></script><script src="/ui/extensions.js?v=photo-quality-v0117"></script><script src="/ui/app.js?v=photo-quality-v0117"></script></body></html>
|
||||
<body><aside class="sidebar"><a class="brand" href="/"><span>PIGWay Frame Flow<small>Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.18</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=photo-quality-v0117"></script><script src="/ui/features.js?v=photo-quality-v0117"></script><script src="/ui/extensions.js?v=photo-quality-v0117"></script><script src="/ui/app.js?v=photo-quality-v0117"></script></body></html>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pigway-frame-flow"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
requires-python = ">=3.14,<3.15"
|
||||
dependencies = ["paho-mqtt==2.1.0", "python-dateutil==2.9.0.post0", "fastapi==0.142.2", "uvicorn==0.54.0", "pillow==12.3.0", "requests==2.34.2", "croniter==6.2.4", "python-multipart==0.0.32", "pyyaml==6.0.3"]
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -155,7 +155,7 @@ def release(lock):
|
||||
assert dst.execute("pragma quick_check").fetchone()[0] == "ok"
|
||||
base = "pigway-frame-flow:release-base-" + commit[:12]
|
||||
run("docker", "image", "tag", before["Image"], base)
|
||||
image = "pigway-frame-flow:0.1.17-" + commit[:12]
|
||||
image = "pigway-frame-flow:0.1.18-" + commit[:12]
|
||||
try:
|
||||
run(
|
||||
"docker",
|
||||
|
||||
@@ -401,3 +401,77 @@ def test_explicit_offline_conversion_preserves_task_reference(service, settings)
|
||||
and after.person_match == "all"
|
||||
and after.location == "family"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("space", ["shared", "personal"])
|
||||
@pytest.mark.parametrize("exif", [5, 6, 7, 8])
|
||||
def test_api_requests_orientation_before_filtering_rotated_portraits(
|
||||
service, settings, monkeypatch, space, exif
|
||||
):
|
||||
session(settings)
|
||||
photos = service.photo_sources
|
||||
requested = []
|
||||
|
||||
def pages(api, version, **params):
|
||||
requested[:] = json.loads(params["additional"])
|
||||
item = row(1)
|
||||
# Model Photos' opt-in fields: stored pixels alone look landscape.
|
||||
if "orientation" in requested:
|
||||
item["additional"]["orientation"] = exif
|
||||
return [item]
|
||||
|
||||
monkeypatch.setattr(photos.synology, "pages", pages)
|
||||
photos.save(
|
||||
"direction",
|
||||
PhotoSourceInput(
|
||||
name="Fixture",
|
||||
kind="synology-photos",
|
||||
location=".",
|
||||
people_space=space,
|
||||
orientation="landscape",
|
||||
),
|
||||
)
|
||||
assert photos.catalog("direction") == []
|
||||
photos.save(
|
||||
"direction",
|
||||
photos.get("direction").model_copy(update={"orientation": "portrait"}),
|
||||
)
|
||||
items = photos.catalog("direction")
|
||||
assert "orientation" in requested
|
||||
assert [(x["width"], x["height"]) for x in items] == [(16, 24)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rotation", [1, 8])
|
||||
def test_fetched_photo_direction_is_checked_before_cropping(
|
||||
service, settings, monkeypatch, rotation
|
||||
):
|
||||
session(settings)
|
||||
photos = service.photo_sources
|
||||
monkeypatch.setattr(photos.synology, "items", lambda *a, **k: [row(1)])
|
||||
out = io.BytesIO()
|
||||
exif = Image.Exif()
|
||||
exif[274] = rotation
|
||||
size = (16, 24) if rotation == 1 else (24, 16)
|
||||
Image.new("RGB", size, "blue").save(out, "JPEG", exif=exif)
|
||||
monkeypatch.setattr(photos.synology, "download", lambda *a, **k: out.getvalue())
|
||||
photos.save(
|
||||
"direction",
|
||||
PhotoSourceInput(
|
||||
name="Fixture",
|
||||
kind="synology-photos",
|
||||
location=".",
|
||||
orientation="landscape",
|
||||
),
|
||||
)
|
||||
with pytest.raises(FlowError, match="direction"):
|
||||
photos.read("direction", "1")
|
||||
|
||||
# A correctly selected portrait remains readable, including EXIF-only rotation.
|
||||
item = row(1)
|
||||
item["additional"]["orientation"] = 8
|
||||
monkeypatch.setattr(photos.synology, "items", lambda *a, **k: [item])
|
||||
photos.save(
|
||||
"direction",
|
||||
photos.get("direction").model_copy(update={"orientation": "portrait"}),
|
||||
)
|
||||
assert photos.read("direction", "1")[0] == out.getvalue()
|
||||
|
||||
Reference in New Issue
Block a user