diff --git a/docs/PHOTO_SOURCES.md b/docs/PHOTO_SOURCES.md index 171e242..b61f780 100644 --- a/docs/PHOTO_SOURCES.md +++ b/docs/PHOTO_SOURCES.md @@ -202,3 +202,16 @@ Endpoints: The legacy cloud star route forwards to the linked local asset when available; cloud star controls are removed from the UI. App/Synology favorites are separate. + +### Camera-style watermark layout + +Captioned source photos fill the photo area with an aspect-preserving cover +crop (or smart crop when requested). Legacy contain jobs use cover for this +layout; standalone image uploads still preserve their explicit fit modes. +The default white bottom band is 6% of the shorter canvas dimension (72px on +1200×1600 or 1600×1200). Title, date and location share one horizontal line. +Long text shrinks and is shortened without wrapping; source metadata is intact. +`PhotoLayoutProvider` separates layout decisions from image decoding/rendering +and device upload. The built-in `WatermarkLayout` is deterministic; an AI layout +provider is not implemented or called. Existing local/cloud images are unchanged +until a new preview or scheduled photo is generated. diff --git a/pigway_frame_flow/renderers/photo_caption.py b/pigway_frame_flow/renderers/photo_caption.py index 18a89c4..48453c2 100644 --- a/pigway_frame_flow/renderers/photo_caption.py +++ b/pigway_frame_flow/renderers/photo_caption.py @@ -1,46 +1,68 @@ -"""Native frame image with a separate, high-contrast caption area.""" +"""Full-bleed photo with a compact, single-line camera-style watermark.""" from io import BytesIO from PIL import Image, ImageDraw from pigway_frame_flow.renderers.images import convert +from pigway_frame_flow.renderers.photo_layout import WatermarkLayout def render_captioned_photo( - data, caption, font_loader, fit="contain", profile="color", *, size=(1200, 1600) + data, + caption, + font_loader, + fit="cover", + profile="color", + *, + size=(1200, 1600), + layout_provider=None, ): width, height = size - landscape = width > height - caption_height = 240 if landscape else 320 - photo_height = height - caption_height - photo_data = convert(data, fit, profile, size=(width, photo_height)) + layout = (layout_provider or WatermarkLayout()).layout(size, caption) + if not ( + 0 < layout.footer_height < height + and 0 < layout.margin < width / 2 + and layout.font_size > 0 + ): + raise ValueError("Invalid photo layout") + photo_height = height - layout.footer_height + # Legacy tasks used contain: watermark photos now fill the photo area, + # keeping aspect ratio and cropping rather than stretching or letterboxing. + photo_data = convert( + data, + "smart" if fit == "smart" else "cover", + profile, + size=(width, photo_height), + ) canvas = Image.new("RGB", size, "white") with Image.open(BytesIO(photo_data)) as photo: canvas.paste(photo, (0, 0)) draw = ImageDraw.Draw(canvas) - rows = ( - [(24, 44), (88, 36), (140, 34)] - if landscape - else [(40, 52), (130, 40), (200, 38)] - ) - values = [caption["title"], caption["date"], caption.get("place", "")] - for text, (offset, font_size) in zip(values, rows, strict=True): - if not text: - continue + values = [ + " ".join(str(caption.get(key, "")).split()) + for key in ("title", "date", "place") + ] + text = " · ".join(value for value in values if value) + font_size = layout.font_size + font = font_loader(font_size) + available = width - 2 * layout.margin + while font_size > 24 and draw.textlength(text, font=font) > available: + font_size -= 2 font = font_loader(font_size) - while font_size > 24 and draw.textlength(text, font=font) > width - 120: - font_size -= 2 - font = font_loader(font_size) - while draw.textlength(text, font=font) > width - 120 and text: - text = text[:-2] + "…" - draw.text( - (width / 2, photo_height + offset), - text, - font=font, - fill="black", - anchor="mt", - ) + # Shorten the title before touching the date/location. Never wrap the band. + while draw.textlength(text, font=font) > available and len(values[0]) > 2: + values[0] = values[0].rstrip("…")[:-1] + "…" + text = " · ".join(value for value in values if value) + while draw.textlength(text, font=font) > available and len(text) > 2: + text = text.rstrip("…")[:-1] + "…" + draw.text( + (layout.margin, photo_height + layout.footer_height / 2), + text, + font=font, + fill="black", + anchor="lm", + ) out = BytesIO() canvas.save(out, "JPEG", quality=93, optimize=True) return out.getvalue() diff --git a/pigway_frame_flow/renderers/photo_layout.py b/pigway_frame_flow/renderers/photo_layout.py new file mode 100644 index 0000000..0a5a848 --- /dev/null +++ b/pigway_frame_flow/renderers/photo_layout.py @@ -0,0 +1,25 @@ +"""Layout-only extension point for a future AI layout provider.""" + +from dataclasses import dataclass +from typing import Protocol + + +@dataclass(frozen=True) +class PhotoLayout: + footer_height: int + font_size: int + margin: int + + +class PhotoLayoutProvider(Protocol): + def layout(self, size: tuple[int, int], caption: dict[str, str]) -> PhotoLayout: ... + + +class WatermarkLayout: + def layout(self, size: tuple[int, int], caption: dict[str, str]) -> PhotoLayout: + width, height = size + return PhotoLayout( + footer_height=max(60, round(min(width, height) * 0.06)), + font_size=max(24, round(min(width, height) * 0.03)), + margin=max(24, round(width * 0.025)), + ) diff --git a/tests/test_photo_canvas.py b/tests/test_photo_canvas.py index e87541c..f33abb2 100644 --- a/tests/test_photo_canvas.py +++ b/tests/test_photo_canvas.py @@ -25,7 +25,7 @@ def test_canvas_respects_rotation_and_explicit_direction(rotation, mode): @pytest.mark.parametrize("profile", ["color", "grayscale"]) -def test_landscape_caption_keeps_three_rows(service, profile): +def test_landscape_caption_uses_compact_watermark(service, profile): out = BytesIO() Image.new("RGB", (1600, 900), "blue").save(out, "JPEG") data = render_captioned_photo( @@ -37,8 +37,9 @@ def test_landscape_caption_keeps_three_rows(service, profile): ) with Image.open(BytesIO(data)) as image: assert image.size == (1600, 1200) - for y in [984, 1048, 1100]: - assert image.crop((60, y, 1540, y + 50)).convert("L").getextrema()[0] < 50 + assert image.crop((30, 1128, 1570, 1200)).convert("L").getextrema()[0] < 50 + assert image.getpixel((20, 1100))[0] < 230 + assert image.getpixel((20, 1190))[0] > 240 pixel = image.getpixel((800, 480)) assert ( len(set(pixel)) <= 2 diff --git a/tests/test_photo_captions.py b/tests/test_photo_captions.py index bbbdb03..7c2ba24 100644 --- a/tests/test_photo_captions.py +++ b/tests/test_photo_captions.py @@ -46,7 +46,7 @@ def test_child_folder_inherits_date_and_location(): @pytest.mark.parametrize("profile", ["color", "grayscale"]) -def test_native_caption_layout_has_three_separate_lines(service, profile): +def test_native_caption_layout_has_single_watermark_line(service, profile): out = BytesIO() Image.new("RGB", (1600, 900), "blue").save(out, "JPEG") result = render_captioned_photo( @@ -57,9 +57,9 @@ def test_native_caption_layout_has_three_separate_lines(service, profile): ) with Image.open(BytesIO(result)) as image: assert image.size == (1200, 1600) - for y in [1320, 1410, 1480]: - darkest = image.crop((50, y, 1150, y + 65)).convert("L").getextrema()[0] - assert darkest < 50, "Caption row is missing" + assert image.crop((30, 1528, 1170, 1600)).convert("L").getextrema()[0] < 50 + assert image.getpixel((20, 1500))[0] < 230 + assert image.getpixel((20, 1590))[0] > 240 pixel = image.getpixel((600, 640)) assert ( len(set(pixel)) <= 2 diff --git a/tests/test_photo_watermark.py b/tests/test_photo_watermark.py new file mode 100644 index 0000000..33511f1 --- /dev/null +++ b/tests/test_photo_watermark.py @@ -0,0 +1,61 @@ +from io import BytesIO + +import pytest +from PIL import Image + +from pigway_frame_flow.renderers.photo_caption import render_captioned_photo +from pigway_frame_flow.renderers.photo_layout import PhotoLayout, WatermarkLayout + + +@pytest.mark.parametrize("size", [(1600, 1200), (1200, 1600)]) +@pytest.mark.parametrize("fit", ["contain", "cover", "smart"]) +def test_watermark_has_no_letterboxing(service, size, fit): + stream = BytesIO() + Image.new("RGB", (400, 100), "blue").save(stream, "JPEG") + data = render_captioned_photo( + stream.getvalue(), + {"title": "Family", "date": "2026-10-06", "place": "Shanghai"}, + service.renderer.font, + fit=fit, + size=size, + ) + with Image.open(BytesIO(data)) as image: + assert image.size == size + layout = WatermarkLayout().layout(size, {}) + for point in [ + (10, 10), + (size[0] - 10, 10), + (10, size[1] - layout.footer_height - 10), + ]: + assert image.getpixel(point)[2] > image.getpixel(point)[0] + 100 + assert image.getpixel((size[0] - 10, size[1] - 10))[0] > 240 + assert layout.footer_height / min(size) <= 0.06 + + +def test_layout_provider_is_injectable(service): + class Custom: + def layout(self, size, caption): + return PhotoLayout(100, 30, 40) + + stream = BytesIO() + Image.new("RGB", (160, 120), "blue").save(stream, "JPEG") + data = render_captioned_photo( + stream.getvalue(), + {"title": "Custom", "date": "2026-10-06"}, + service.renderer.font, + size=(1600, 1200), + layout_provider=Custom(), + ) + with Image.open(BytesIO(data)) as image: + assert image.getpixel((1500, 1120))[0] > 240 + + +def test_long_multiline_caption_does_not_wrap_or_hang(service): + stream = BytesIO() + Image.new("RGB", (160, 120), "blue").save(stream, "JPEG") + data = render_captioned_photo( + stream.getvalue(), + {"title": "Long title\n" * 100, "date": "2026-10-06", "place": "Place " * 100}, + service.renderer.font, + ) + assert Image.open(BytesIO(data)).size == (1200, 1600)