diff --git a/deploy/Dockerfile.runtime-update b/deploy/Dockerfile.runtime-update index 0443f4a..b5dee9d 100644 --- a/deploy/Dockerfile.runtime-update +++ b/deploy/Dockerfile.runtime-update @@ -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 diff --git a/docs/openapi.json b/docs/openapi.json index 456f826..fba81c2 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -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", diff --git a/docs/releases/v0.1.18.md b/docs/releases/v0.1.18.md new file mode 100644 index 0000000..ab9f361 --- /dev/null +++ b/docs/releases/v0.1.18.md @@ -0,0 +1,6 @@ +# v0.1.18 + +- Synology Photos 列表显式请求照片方向信息;按 EXIF 旋转后的宽高筛选横向、竖向和自动方向,避免存储像素为横向的竖拍照片误入横图来源。 +- 渲染前复核实际取得图片的 EXIF 方向;若元数据或缓存预览与来源方向不符,则跳过该候选。显式选择方向不符的照片时返回明确错误。 +- 保持既有水印和铺满布局,不修改 NAS 原照片、来源设置或现有图库;修复不自动重新生成、上传或切换已显示图片。 +- 源码及镜像保留 LICENSE、NOTICE;公开验证只使用合成照片和通用示例,私有部署参数与回执保存在仓库之外。 diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index aae546a..8135077 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -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) diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py index 0472fee..af1c681 100644 --- a/pigway_frame_flow/models.py +++ b/pigway_frame_flow/models.py @@ -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" diff --git a/pigway_frame_flow/providers/photo_sources.py b/pigway_frame_flow/providers/photo_sources.py index fefcf3f..6447b12 100644 --- a/pigway_frame_flow/providers/photo_sources.py +++ b/pigway_frame_flow/providers/photo_sources.py @@ -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", diff --git a/pigway_frame_flow/providers/synology_photos.py b/pigway_frame_flow/providers/synology_photos.py index 07f615f..1c84c52 100644 --- a/pigway_frame_flow/providers/synology_photos.py +++ b/pigway_frame_flow/providers/synology_photos.py @@ -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( diff --git a/pigway_frame_flow/web/index.html b/pigway_frame_flow/web/index.html index 837c3a5..b133672 100644 --- a/pigway_frame_flow/web/index.html +++ b/pigway_frame_flow/web/index.html @@ -1,3 +1,3 @@ PIGWay Frame Flow -
PIGWay Frame Flow

+
PIGWay Frame Flow

diff --git a/pyproject.toml b/pyproject.toml index 3269eeb..6e174ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/scripts/server-release.py b/scripts/server-release.py index d0a6195..2770678 100644 --- a/scripts/server-release.py +++ b/scripts/server-release.py @@ -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", diff --git a/tests/test_synology_photos_api.py b/tests/test_synology_photos_api.py index c358d44..3631680 100644 --- a/tests/test_synology_photos_api.py +++ b/tests/test_synology_photos_api.py @@ -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()