Use full-bleed photos with a single-line bottom watermark
This commit is contained in:
@@ -202,3 +202,16 @@ Endpoints:
|
|||||||
|
|
||||||
The legacy cloud star route forwards to the linked local asset when available;
|
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.
|
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.
|
||||||
|
|||||||
@@ -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 io import BytesIO
|
||||||
|
|
||||||
from PIL import Image, ImageDraw
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
from pigway_frame_flow.renderers.images import convert
|
from pigway_frame_flow.renderers.images import convert
|
||||||
|
from pigway_frame_flow.renderers.photo_layout import WatermarkLayout
|
||||||
|
|
||||||
|
|
||||||
def render_captioned_photo(
|
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
|
width, height = size
|
||||||
landscape = width > height
|
layout = (layout_provider or WatermarkLayout()).layout(size, caption)
|
||||||
caption_height = 240 if landscape else 320
|
if not (
|
||||||
photo_height = height - caption_height
|
0 < layout.footer_height < height
|
||||||
photo_data = convert(data, fit, profile, size=(width, photo_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")
|
canvas = Image.new("RGB", size, "white")
|
||||||
with Image.open(BytesIO(photo_data)) as photo:
|
with Image.open(BytesIO(photo_data)) as photo:
|
||||||
canvas.paste(photo, (0, 0))
|
canvas.paste(photo, (0, 0))
|
||||||
draw = ImageDraw.Draw(canvas)
|
draw = ImageDraw.Draw(canvas)
|
||||||
rows = (
|
values = [
|
||||||
[(24, 44), (88, 36), (140, 34)]
|
" ".join(str(caption.get(key, "")).split())
|
||||||
if landscape
|
for key in ("title", "date", "place")
|
||||||
else [(40, 52), (130, 40), (200, 38)]
|
]
|
||||||
)
|
text = " · ".join(value for value in values if value)
|
||||||
values = [caption["title"], caption["date"], caption.get("place", "")]
|
font_size = layout.font_size
|
||||||
for text, (offset, font_size) in zip(values, rows, strict=True):
|
font = font_loader(font_size)
|
||||||
if not text:
|
available = width - 2 * layout.margin
|
||||||
continue
|
while font_size > 24 and draw.textlength(text, font=font) > available:
|
||||||
|
font_size -= 2
|
||||||
font = font_loader(font_size)
|
font = font_loader(font_size)
|
||||||
while font_size > 24 and draw.textlength(text, font=font) > width - 120:
|
# Shorten the title before touching the date/location. Never wrap the band.
|
||||||
font_size -= 2
|
while draw.textlength(text, font=font) > available and len(values[0]) > 2:
|
||||||
font = font_loader(font_size)
|
values[0] = values[0].rstrip("…")[:-1] + "…"
|
||||||
while draw.textlength(text, font=font) > width - 120 and text:
|
text = " · ".join(value for value in values if value)
|
||||||
text = text[:-2] + "…"
|
while draw.textlength(text, font=font) > available and len(text) > 2:
|
||||||
draw.text(
|
text = text.rstrip("…")[:-1] + "…"
|
||||||
(width / 2, photo_height + offset),
|
draw.text(
|
||||||
text,
|
(layout.margin, photo_height + layout.footer_height / 2),
|
||||||
font=font,
|
text,
|
||||||
fill="black",
|
font=font,
|
||||||
anchor="mt",
|
fill="black",
|
||||||
)
|
anchor="lm",
|
||||||
|
)
|
||||||
out = BytesIO()
|
out = BytesIO()
|
||||||
canvas.save(out, "JPEG", quality=93, optimize=True)
|
canvas.save(out, "JPEG", quality=93, optimize=True)
|
||||||
return out.getvalue()
|
return out.getvalue()
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
)
|
||||||
@@ -25,7 +25,7 @@ def test_canvas_respects_rotation_and_explicit_direction(rotation, mode):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("profile", ["color", "grayscale"])
|
@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()
|
out = BytesIO()
|
||||||
Image.new("RGB", (1600, 900), "blue").save(out, "JPEG")
|
Image.new("RGB", (1600, 900), "blue").save(out, "JPEG")
|
||||||
data = render_captioned_photo(
|
data = render_captioned_photo(
|
||||||
@@ -37,8 +37,9 @@ def test_landscape_caption_keeps_three_rows(service, profile):
|
|||||||
)
|
)
|
||||||
with Image.open(BytesIO(data)) as image:
|
with Image.open(BytesIO(data)) as image:
|
||||||
assert image.size == (1600, 1200)
|
assert image.size == (1600, 1200)
|
||||||
for y in [984, 1048, 1100]:
|
assert image.crop((30, 1128, 1570, 1200)).convert("L").getextrema()[0] < 50
|
||||||
assert image.crop((60, y, 1540, y + 50)).convert("L").getextrema()[0] < 50
|
assert image.getpixel((20, 1100))[0] < 230
|
||||||
|
assert image.getpixel((20, 1190))[0] > 240
|
||||||
pixel = image.getpixel((800, 480))
|
pixel = image.getpixel((800, 480))
|
||||||
assert (
|
assert (
|
||||||
len(set(pixel)) <= 2
|
len(set(pixel)) <= 2
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ def test_child_folder_inherits_date_and_location():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("profile", ["color", "grayscale"])
|
@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()
|
out = BytesIO()
|
||||||
Image.new("RGB", (1600, 900), "blue").save(out, "JPEG")
|
Image.new("RGB", (1600, 900), "blue").save(out, "JPEG")
|
||||||
result = render_captioned_photo(
|
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:
|
with Image.open(BytesIO(result)) as image:
|
||||||
assert image.size == (1200, 1600)
|
assert image.size == (1200, 1600)
|
||||||
for y in [1320, 1410, 1480]:
|
assert image.crop((30, 1528, 1170, 1600)).convert("L").getextrema()[0] < 50
|
||||||
darkest = image.crop((50, y, 1150, y + 65)).convert("L").getextrema()[0]
|
assert image.getpixel((20, 1500))[0] < 230
|
||||||
assert darkest < 50, "Caption row is missing"
|
assert image.getpixel((20, 1590))[0] > 240
|
||||||
pixel = image.getpixel((600, 640))
|
pixel = image.getpixel((600, 640))
|
||||||
assert (
|
assert (
|
||||||
len(set(pixel)) <= 2
|
len(set(pixel)) <= 2
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user