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 logging
import os import os
from collections import Counter
from datetime import date from datetime import date
from io import BytesIO from io import BytesIO
from urllib.parse import quote from urllib.parse import quote
@@ -8,14 +9,50 @@ import requests
from PIL import Image as PILImage, ImageDraw, ImageFont from PIL import Image as PILImage, ImageDraw, ImageFont
import config import config
from src.images import _resize_to_fit
logger = logging.getLogger(__name__) 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: def _get_font(size: int) -> ImageFont.FreeTypeFont:
try: 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: except OSError:
pass pass
try: try:
@@ -25,41 +62,76 @@ def _get_font(size: int) -> ImageFont.FreeTypeFont:
return ImageFont.load_default() 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, output_dir: str,
week_start: date, week_start: date,
week_end: date, week_end: date,
headlines: list[str], headlines: list[str],
categories: list[str],
) -> str: ) -> str:
os.makedirs(output_dir, exist_ok=True) 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) draw = ImageDraw.Draw(img)
title_font = _get_font(36) _draw_text_overlays(draw, w, h, week_start, week_end, headlines)
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
out = img.convert("RGB")
filename = f"cover-{week_start.isoformat()}-text.jpg" filename = f"cover-{week_start.isoformat()}-text.jpg"
path = os.path.join(output_dir, filename) 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 return path
@@ -68,47 +140,41 @@ def generate_ai_cover(
week_start: date, week_start: date,
week_end: date, week_end: date,
headlines: list[str], headlines: list[str],
categories: list[str],
) -> str: ) -> str:
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
w, h = config.COVER_SIZE
top_headlines = ", ".join(headlines[:3]) dominant = _get_dominant_category(categories)
prompt = ( prompt = CATEGORY_PROMPTS.get(dominant, CATEGORY_PROMPTS["Default"])
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."
)
url = config.POLLINATIONS_URL.format(prompt=quote(prompt)) url = config.POLLINATIONS_URL.format(prompt=quote(prompt))
try: try:
response = requests.get(url, timeout=60) response = requests.get(url, timeout=60)
response.raise_for_status() response.raise_for_status()
img = PILImage.open(BytesIO(response.content)) bg = PILImage.open(BytesIO(response.content))
if img.mode != "RGB": if bg.mode not in ("RGB", "RGBA"):
img = img.convert("RGB") bg = bg.convert("RGB")
img = _resize_to_fit(img) bg = bg.resize((w, h), PILImage.Resampling.LANCZOS)
draw = ImageDraw.Draw(img) img = bg.convert("RGBA")
title_font = _get_font(28) overlay = PILImage.new("RGBA", (w, h), (0, 0, 0, 0))
date_font = _get_font(16) draw = ImageDraw.Draw(overlay)
_draw_text_overlays(draw, w, h, week_start, week_end, headlines)
draw.text((img.width // 2, 15), "Plymouth Independent", img = PILImage.alpha_composite(img, overlay)
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")
out = img.convert("RGB")
filename = f"cover-{week_start.isoformat()}-ai.jpg" filename = f"cover-{week_start.isoformat()}-ai.jpg"
path = os.path.join(output_dir, filename) 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 return path
except Exception as e: except Exception as e:
logger.error("AI cover generation failed, falling back to text: %s", e) logger.error("AI cover generation failed, falling back to programmatic: %s", e)
return generate_text_cover(output_dir, week_start, week_end, headlines) return generate_programmatic_cover(
output_dir, week_start, week_end, headlines, categories
)
def generate_cover( def generate_cover(
@@ -117,7 +183,12 @@ def generate_cover(
week_start: date, week_start: date,
week_end: date, week_end: date,
headlines: list[str], headlines: list[str],
categories: list[str],
) -> str: ) -> str:
if method == "ai": if method == "ai":
return generate_ai_cover(output_dir, week_start, week_end, headlines) return generate_ai_cover(
return generate_text_cover(output_dir, week_start, week_end, headlines) output_dir, week_start, week_end, headlines, categories
)
return generate_programmatic_cover(
output_dir, week_start, week_end, headlines, categories
)

View File

@@ -56,10 +56,14 @@ def regenerate(issue_id):
.order_by(Article.pub_date.asc()).all() .order_by(Article.pub_date.asc()).all()
] ]
categories_list = []
for a in Article.query.filter(Article.id.in_(article_ids)).all():
categories_list.extend(json.loads(a.categories))
try: try:
cover_path = generate_cover( cover_path = generate_cover(
issue.cover_method, config.ISSUES_DIR, issue.cover_method, config.ISSUES_DIR,
issue.week_start, issue.week_end, headlines issue.week_start, issue.week_end, headlines, categories_list
) )
epub_path = build_epub( epub_path = build_epub(
issue.week_start, issue.week_end, article_ids, issue.week_start, issue.week_end, article_ids,

View File

@@ -74,9 +74,14 @@ def create_issue():
.order_by(Article.pub_date.asc()).all() .order_by(Article.pub_date.asc()).all()
] ]
categories_list = []
for a in Article.query.filter(Article.id.in_(included_ids)).all():
categories_list.extend(json.loads(a.categories))
try: try:
cover_path = generate_cover( cover_path = generate_cover(
cover_method, config.ISSUES_DIR, week_start, week_end, headlines cover_method, config.ISSUES_DIR, week_start, week_end,
headlines, categories_list
) )
epub_path = build_epub( epub_path = build_epub(
week_start, week_end, included_ids, cover_path, config.ISSUES_DIR week_start, week_end, included_ids, cover_path, config.ISSUES_DIR

View File

@@ -78,11 +78,16 @@ class SchedulerManager:
article_ids = [a.id for a in articles] article_ids = [a.id for a in articles]
headlines = [a.title for a in articles] headlines = [a.title for a in articles]
categories_list = []
for a in articles:
categories_list.extend(json.loads(a.categories))
auto_pub = Setting.get("auto_publish", {}) auto_pub = Setting.get("auto_publish", {})
method = auto_pub.get("cover_method", "text") method = auto_pub.get("cover_method", "text")
cover_path = generate_cover( cover_path = generate_cover(
method, config.ISSUES_DIR, week_start, week_end, headlines method, config.ISSUES_DIR, week_start, week_end,
headlines, categories_list
) )
epub_path = build_epub( epub_path = build_epub(
week_start, week_end, article_ids, cover_path, config.ISSUES_DIR week_start, week_end, article_ids, cover_path, config.ISSUES_DIR

View File

@@ -1,29 +1,50 @@
import os import os
from collections import Counter
from datetime import date from datetime import date
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from io import BytesIO from io import BytesIO
from PIL import Image as PILImage from PIL import Image as PILImage
from src.cover import generate_text_cover, generate_ai_cover, generate_cover from src.cover import (
generate_programmatic_cover,
generate_ai_cover,
generate_cover,
_get_dominant_category,
CATEGORY_PROMPTS,
)
def test_text_cover_creates_jpeg(tmp_path): def test_programmatic_cover_is_portrait(tmp_path):
path = generate_text_cover( path = generate_programmatic_cover(
output_dir=str(tmp_path), output_dir=str(tmp_path),
week_start=date(2026, 4, 6), week_start=date(2026, 4, 6),
week_end=date(2026, 4, 12), week_end=date(2026, 4, 12),
headlines=["Article One", "Article Two", "Article Three"], headlines=["Article One", "Article Two", "Article Three"],
categories=["Government", "Government", "Culture"],
) )
assert os.path.exists(path) assert os.path.exists(path)
img = PILImage.open(path) img = PILImage.open(path)
assert img.format == "JPEG" assert img.format == "JPEG"
assert img.width <= 800 assert img.size == (480, 800)
assert img.height <= 480
def test_ai_cover_creates_jpeg(tmp_path): def test_programmatic_cover_no_headlines(tmp_path):
fake_img = PILImage.new("RGB", (800, 480), color="blue") path = generate_programmatic_cover(
output_dir=str(tmp_path),
week_start=date(2026, 4, 6),
week_end=date(2026, 4, 12),
headlines=[],
categories=[],
)
assert os.path.exists(path)
img = PILImage.open(path)
assert img.size == (480, 800)
def test_ai_cover_is_portrait(tmp_path):
fake_img = PILImage.new("RGB", (480, 800), color="blue")
buf = BytesIO() buf = BytesIO()
fake_img.save(buf, format="JPEG") fake_img.save(buf, format="JPEG")
fake_bytes = buf.getvalue() fake_bytes = buf.getvalue()
@@ -38,40 +59,53 @@ def test_ai_cover_creates_jpeg(tmp_path):
week_start=date(2026, 4, 6), week_start=date(2026, 4, 6),
week_end=date(2026, 4, 12), week_end=date(2026, 4, 12),
headlines=["Test Headline"], headlines=["Test Headline"],
categories=["Government"],
) )
assert os.path.exists(path) assert os.path.exists(path)
img = PILImage.open(path) img = PILImage.open(path)
assert img.format == "JPEG" assert img.format == "JPEG"
assert img.width <= 800 assert img.size == (480, 800)
assert img.height <= 480
def test_ai_cover_falls_back_on_failure(tmp_path): def test_ai_cover_falls_back_to_programmatic(tmp_path):
with patch("src.cover.requests.get", side_effect=Exception("API down")): with patch("src.cover.requests.get", side_effect=Exception("API down")):
path = generate_ai_cover( path = generate_ai_cover(
output_dir=str(tmp_path), output_dir=str(tmp_path),
week_start=date(2026, 4, 6), week_start=date(2026, 4, 6),
week_end=date(2026, 4, 12), week_end=date(2026, 4, 12),
headlines=["Test"], headlines=["Test"],
categories=["Government"],
) )
assert os.path.exists(path) assert os.path.exists(path)
img = PILImage.open(path) img = PILImage.open(path)
assert img.format == "JPEG" assert img.format == "JPEG"
assert img.size == (480, 800)
def test_generate_cover_dispatches(tmp_path): def test_generate_cover_dispatches(tmp_path):
with patch("src.cover.generate_ai_cover") as mock_ai: with patch("src.cover.generate_ai_cover") as mock_ai:
mock_ai.return_value = "/fake/ai.jpg" mock_ai.return_value = "/fake/ai.jpg"
result = generate_cover("ai", str(tmp_path), date(2026, 4, 6), result = generate_cover("ai", str(tmp_path), date(2026, 4, 6),
date(2026, 4, 12), ["A"]) date(2026, 4, 12), ["A"], ["Government"])
assert result == "/fake/ai.jpg" assert result == "/fake/ai.jpg"
mock_ai.assert_called_once() mock_ai.assert_called_once()
with patch("src.cover.generate_text_cover") as mock_text: with patch("src.cover.generate_programmatic_cover") as mock_prog:
mock_text.return_value = "/fake/text.jpg" mock_prog.return_value = "/fake/text.jpg"
result = generate_cover("text", str(tmp_path), date(2026, 4, 6), result = generate_cover("text", str(tmp_path), date(2026, 4, 6),
date(2026, 4, 12), ["A"]) date(2026, 4, 12), ["A"], ["Government"])
assert result == "/fake/text.jpg" assert result == "/fake/text.jpg"
mock_text.assert_called_once() mock_prog.assert_called_once()
def test_get_dominant_category():
assert _get_dominant_category(["Government", "Government", "Culture"]) == "Government"
assert _get_dominant_category(["Culture", "Government"]) == "Culture"
assert _get_dominant_category([]) == "Default"
def test_category_prompts_include_massachusetts():
for category, prompt in CATEGORY_PROMPTS.items():
assert "Massachusetts" in prompt, f"Prompt for {category} missing 'Massachusetts'"