feat: rewrite cover generation — 480×800 portrait, themed AI backgrounds, two-layer pipeline

Made-with: Cursor
This commit is contained in:
cottongin
2026-04-06 17:02:36 -04:00
parent 5fec07c287
commit 49acf09aa1
5 changed files with 190 additions and 71 deletions

View File

@@ -1,5 +1,6 @@
import logging
import os
from collections import Counter
from datetime import date
from io import BytesIO
from urllib.parse import quote
@@ -8,14 +9,50 @@ import requests
from PIL import Image as PILImage, ImageDraw, ImageFont
import config
from src.images import _resize_to_fit
logger = logging.getLogger(__name__)
CATEGORY_PROMPTS = {
"Government": (
"Watercolor painting of Plymouth Massachusetts town hall, "
"New England civic architecture, soft morning light, no text"
),
"Culture": (
"Plymouth Massachusetts waterfront boardwalk, autumn festival "
"atmosphere, warm tones, New England charm, no text"
),
"Culture Calendar": (
"Plymouth Massachusetts harbor at golden hour, sailboats and "
"historic waterfront, warm autumn palette, no text"
),
"Sports": (
"Plymouth Massachusetts harbor marina at sunrise, boats and "
"morning mist on Cape Cod Bay, no text"
),
"Environment": (
"Cape Cod Bay shoreline near Plymouth Massachusetts, coastal "
"wetlands and dune grass, golden hour light, no text"
),
"Opinion": (
"Plymouth Rock historic site in Plymouth Massachusetts, "
"dramatic sky, iconic New England landmark, no text"
),
"Feature": (
"Downtown Plymouth Massachusetts street scene, historic "
"brick storefronts, New England small town charm, no text"
),
"Default": (
"Aerial view of Plymouth Massachusetts coastal town, serene "
"harbor, Cape Cod Bay, New England charm, no text"
),
}
def _get_font(size: int) -> ImageFont.FreeTypeFont:
try:
return ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", size)
return ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", size
)
except OSError:
pass
try:
@@ -25,41 +62,76 @@ def _get_font(size: int) -> ImageFont.FreeTypeFont:
return ImageFont.load_default()
def generate_text_cover(
def _get_dominant_category(categories: list[str]) -> str:
if not categories:
return "Default"
counter = Counter(categories)
most_common = counter.most_common(1)[0][0]
return most_common
def _draw_text_overlays(
draw: ImageDraw.ImageDraw,
width: int,
height: int,
week_start: date,
week_end: date,
headlines: list[str],
) -> None:
title_font = _get_font(38)
date_font = _get_font(20)
headline_font = _get_font(14)
draw.rectangle([(0, 0), (width, 90)], fill=(0, 0, 0, 204))
draw.text(
(width // 2, 20), "Plymouth Independent",
fill="white", font=title_font, anchor="mt",
)
iso_week = week_start.isocalendar()[1]
date_str = (
f"Week {iso_week} \u00b7 "
f"{week_start.strftime('%b %d')} \u2013 {week_end.strftime('%b %d, %Y')}"
)
draw.text(
(width // 2, 62), date_str,
fill=(220, 220, 220), font=date_font, anchor="mt",
)
if headlines:
max_headlines = min(len(headlines), 5)
strip_height = 28 + max_headlines * 22
strip_top = height - strip_height
draw.rectangle(
[(0, strip_top), (width, height)], fill=(0, 0, 0, 170)
)
y = strip_top + 8
for headline in headlines[:max_headlines]:
text = f"\u2022 {headline}"
if len(text) > 45:
text = text[:42] + "..."
draw.text((16, y), text, fill="white", font=headline_font)
y += 22
def generate_programmatic_cover(
output_dir: str,
week_start: date,
week_end: date,
headlines: list[str],
categories: list[str],
) -> str:
os.makedirs(output_dir, exist_ok=True)
img = PILImage.new("RGB", (800, 480), color="white")
w, h = config.COVER_SIZE
img = PILImage.new("RGBA", (w, h), color=(245, 245, 245, 255))
draw = ImageDraw.Draw(img)
title_font = _get_font(36)
date_font = _get_font(20)
headline_font = _get_font(16)
draw.text((400, 30), "Plymouth Independent", fill="black",
font=title_font, anchor="mt")
date_str = f"Week of {week_start.strftime('%b %d')} \u2013 {week_end.strftime('%b %d, %Y')}"
draw.text((400, 80), date_str, fill="gray", font=date_font, anchor="mt")
draw.line([(50, 110), (750, 110)], fill="black", width=2)
y = 130
for i, headline in enumerate(headlines[:8]):
if y > 440:
break
prefix = f"\u2022 {headline}"
if len(prefix) > 70:
prefix = prefix[:67] + "..."
draw.text((60, y), prefix, fill="black", font=headline_font)
y += 35
_draw_text_overlays(draw, w, h, week_start, week_end, headlines)
out = img.convert("RGB")
filename = f"cover-{week_start.isoformat()}-text.jpg"
path = os.path.join(output_dir, filename)
img.save(path, format="JPEG", progressive=False, quality=90)
out.save(path, format="JPEG", progressive=False, quality=90)
return path
@@ -68,47 +140,41 @@ def generate_ai_cover(
week_start: date,
week_end: date,
headlines: list[str],
categories: list[str],
) -> str:
os.makedirs(output_dir, exist_ok=True)
w, h = config.COVER_SIZE
top_headlines = ", ".join(headlines[:3])
prompt = (
f"Newspaper front page illustration for Plymouth Massachusetts local news. "
f"Headlines: {top_headlines}. "
f"Classic broadsheet newspaper style, black and white ink drawing, editorial illustration."
)
dominant = _get_dominant_category(categories)
prompt = CATEGORY_PROMPTS.get(dominant, CATEGORY_PROMPTS["Default"])
url = config.POLLINATIONS_URL.format(prompt=quote(prompt))
try:
response = requests.get(url, timeout=60)
response.raise_for_status()
img = PILImage.open(BytesIO(response.content))
if img.mode != "RGB":
img = img.convert("RGB")
img = _resize_to_fit(img)
bg = PILImage.open(BytesIO(response.content))
if bg.mode not in ("RGB", "RGBA"):
bg = bg.convert("RGB")
bg = bg.resize((w, h), PILImage.Resampling.LANCZOS)
draw = ImageDraw.Draw(img)
title_font = _get_font(28)
date_font = _get_font(16)
draw.text((img.width // 2, 15), "Plymouth Independent",
fill="white", font=title_font, anchor="mt",
stroke_width=2, stroke_fill="black")
date_str = f"Week of {week_start.strftime('%b %d')} \u2013 {week_end.strftime('%b %d, %Y')}"
draw.text((img.width // 2, 50), date_str,
fill="white", font=date_font, anchor="mt",
stroke_width=1, stroke_fill="black")
img = bg.convert("RGBA")
overlay = PILImage.new("RGBA", (w, h), (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
_draw_text_overlays(draw, w, h, week_start, week_end, headlines)
img = PILImage.alpha_composite(img, overlay)
out = img.convert("RGB")
filename = f"cover-{week_start.isoformat()}-ai.jpg"
path = os.path.join(output_dir, filename)
img.save(path, format="JPEG", progressive=False, quality=90)
out.save(path, format="JPEG", progressive=False, quality=90)
return path
except Exception as e:
logger.error("AI cover generation failed, falling back to text: %s", e)
return generate_text_cover(output_dir, week_start, week_end, headlines)
logger.error("AI cover generation failed, falling back to programmatic: %s", e)
return generate_programmatic_cover(
output_dir, week_start, week_end, headlines, categories
)
def generate_cover(
@@ -117,7 +183,12 @@ def generate_cover(
week_start: date,
week_end: date,
headlines: list[str],
categories: list[str],
) -> str:
if method == "ai":
return generate_ai_cover(output_dir, week_start, week_end, headlines)
return generate_text_cover(output_dir, week_start, week_end, headlines)
return generate_ai_cover(
output_dir, week_start, week_end, headlines, categories
)
return generate_programmatic_cover(
output_dir, week_start, week_end, headlines, categories
)