feat: rewrite cover generation — 480×800 portrait, themed AI backgrounds, two-layer pipeline
Made-with: Cursor
This commit is contained in:
177
src/cover.py
177
src/cover.py
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'"
|
||||||
|
|||||||
Reference in New Issue
Block a user