From 49acf09aa18a3f123f20295eb7757807041870b3 Mon Sep 17 00:00:00 2001 From: cottongin Date: Mon, 6 Apr 2026 17:02:36 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20rewrite=20cover=20generation=20?= =?UTF-8?q?=E2=80=94=20480=C3=97800=20portrait,=20themed=20AI=20background?= =?UTF-8?q?s,=20two-layer=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- src/cover.py | 177 +++++++++++++++++++++++++++++------------- src/routes/issues.py | 6 +- src/routes/publish.py | 7 +- src/scheduler.py | 7 +- tests/test_cover.py | 64 +++++++++++---- 5 files changed, 190 insertions(+), 71 deletions(-) diff --git a/src/cover.py b/src/cover.py index b17ca65..9d10a72 100644 --- a/src/cover.py +++ b/src/cover.py @@ -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 + ) diff --git a/src/routes/issues.py b/src/routes/issues.py index 5b905c4..aaf6302 100644 --- a/src/routes/issues.py +++ b/src/routes/issues.py @@ -56,10 +56,14 @@ def regenerate(issue_id): .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: cover_path = generate_cover( 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( issue.week_start, issue.week_end, article_ids, diff --git a/src/routes/publish.py b/src/routes/publish.py index 50d1144..39df648 100644 --- a/src/routes/publish.py +++ b/src/routes/publish.py @@ -74,9 +74,14 @@ def create_issue(): .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: 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( week_start, week_end, included_ids, cover_path, config.ISSUES_DIR diff --git a/src/scheduler.py b/src/scheduler.py index fd33bb3..54589cf 100644 --- a/src/scheduler.py +++ b/src/scheduler.py @@ -78,11 +78,16 @@ class SchedulerManager: article_ids = [a.id 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", {}) method = auto_pub.get("cover_method", "text") 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( week_start, week_end, article_ids, cover_path, config.ISSUES_DIR diff --git a/tests/test_cover.py b/tests/test_cover.py index c7e9743..5b00e23 100644 --- a/tests/test_cover.py +++ b/tests/test_cover.py @@ -1,29 +1,50 @@ import os +from collections import Counter from datetime import date from unittest.mock import patch, MagicMock from io import BytesIO 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): - path = generate_text_cover( +def test_programmatic_cover_is_portrait(tmp_path): + path = generate_programmatic_cover( output_dir=str(tmp_path), week_start=date(2026, 4, 6), week_end=date(2026, 4, 12), headlines=["Article One", "Article Two", "Article Three"], + categories=["Government", "Government", "Culture"], ) assert os.path.exists(path) img = PILImage.open(path) assert img.format == "JPEG" - assert img.width <= 800 - assert img.height <= 480 + assert img.size == (480, 800) -def test_ai_cover_creates_jpeg(tmp_path): - fake_img = PILImage.new("RGB", (800, 480), color="blue") +def test_programmatic_cover_no_headlines(tmp_path): + 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() fake_img.save(buf, format="JPEG") fake_bytes = buf.getvalue() @@ -38,40 +59,53 @@ def test_ai_cover_creates_jpeg(tmp_path): week_start=date(2026, 4, 6), week_end=date(2026, 4, 12), headlines=["Test Headline"], + categories=["Government"], ) assert os.path.exists(path) img = PILImage.open(path) assert img.format == "JPEG" - assert img.width <= 800 - assert img.height <= 480 + assert img.size == (480, 800) -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")): path = generate_ai_cover( output_dir=str(tmp_path), week_start=date(2026, 4, 6), week_end=date(2026, 4, 12), headlines=["Test"], + categories=["Government"], ) assert os.path.exists(path) img = PILImage.open(path) assert img.format == "JPEG" + assert img.size == (480, 800) def test_generate_cover_dispatches(tmp_path): with patch("src.cover.generate_ai_cover") as mock_ai: mock_ai.return_value = "/fake/ai.jpg" 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" mock_ai.assert_called_once() - with patch("src.cover.generate_text_cover") as mock_text: - mock_text.return_value = "/fake/text.jpg" + with patch("src.cover.generate_programmatic_cover") as mock_prog: + mock_prog.return_value = "/fake/text.jpg" 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" - 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'"