diff --git a/docs/superpowers/plans/2026-04-06-mosaic-cover.md b/docs/superpowers/plans/2026-04-06-mosaic-cover.md new file mode 100644 index 0000000..702be48 --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-mosaic-cover.md @@ -0,0 +1,283 @@ +# Mosaic Cover Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the AI-generated cover background with a deterministic mosaic of article images. + +**Architecture:** We will modify the cover generation pipeline to accept a list of local image paths instead of categories. The new `generate_mosaic_cover` function in `src/cover.py` will arrange these images into a 2-column masonry layout on a 480x800 canvas, preserving aspect ratios and adding 4px gaps, before applying the standard text overlays. + +**Tech Stack:** Python, Pillow (PIL), Flask, SQLite (SQLAlchemy) + +--- + +### Task 1: Update Cover Generation Logic (`src/cover.py`) + +**Files:** +- Modify: `src/cover.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_cover.py (add to existing tests or create if missing) +import os +from datetime import date +from PIL import Image as PILImage +from src.cover import generate_mosaic_cover + +def test_generate_mosaic_cover_single_image(tmp_path): + # Create a dummy image + img_path = str(tmp_path / "dummy.jpg") + img = PILImage.new("RGB", (1000, 500), color="red") + img.save(img_path) + + output_dir = str(tmp_path) + week_start = date(2026, 4, 6) + week_end = date(2026, 4, 12) + headlines = ["Test Headline"] + + cover_path = generate_mosaic_cover(output_dir, week_start, week_end, headlines, [img_path]) + + assert os.path.exists(cover_path) + with PILImage.open(cover_path) as result_img: + assert result_img.size == (480, 800) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_cover.py::test_generate_mosaic_cover_single_image -v` +Expected: FAIL with `ImportError: cannot import name 'generate_mosaic_cover'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# src/cover.py (Add new function, remove generate_ai_cover) +def generate_mosaic_cover( + output_dir: str, + week_start: date, + week_end: date, + headlines: list[str], + image_paths: list[str], +) -> str: + os.makedirs(output_dir, exist_ok=True) + w, h = config.COVER_SIZE + img = PILImage.new("RGBA", (w, h), color=(245, 245, 245, 255)) + + if len(image_paths) == 1: + # Single image case + try: + with PILImage.open(image_paths[0]) as source_img: + source_img = source_img.convert("RGBA") + # Resize to fit within 480x800 preserving aspect ratio + ratio = min(w / source_img.width, h / source_img.height) + new_size = (int(source_img.width * ratio), int(source_img.height * ratio)) + resized = source_img.resize(new_size, PILImage.Resampling.LANCZOS) + # Center it + x = (w - new_size[0]) // 2 + y = (h - new_size[1]) // 2 + img.paste(resized, (x, y), resized) + except Exception as e: + logger.error(f"Failed to process single image for cover: {e}") + elif len(image_paths) > 1: + # Mosaic case + col_width = (w - (3 * 4)) // 2 + col_heights = [4, 4] + + for path in image_paths: + if col_heights[0] > h and col_heights[1] > h: + break # Both columns full + + try: + with PILImage.open(path) as source_img: + source_img = source_img.convert("RGBA") + new_h = int(source_img.height * (col_width / source_img.width)) + resized = source_img.resize((col_width, new_h), PILImage.Resampling.LANCZOS) + + col_idx = 0 if col_heights[0] <= col_heights[1] else 1 + x = 4 + col_idx * (col_width + 4) + y = col_heights[col_idx] + + img.paste(resized, (x, y), resized) + col_heights[col_idx] += new_h + 4 + except Exception as e: + logger.error(f"Failed to process image {path} for mosaic: {e}") + + draw = ImageDraw.Draw(img) + _draw_text_overlays(draw, w, h, week_start, week_end, headlines) + + out = img.convert("RGB") + filename = f"cover-{week_start.isoformat()}-mosaic.jpg" + path = os.path.join(output_dir, filename) + out.save(path, format="JPEG", progressive=False, quality=90) + return path + +def generate_cover( + method: str, + output_dir: str, + week_start: date, + week_end: date, + headlines: list[str], + image_paths: list[str], +) -> str: + if method == "mosaic": + return generate_mosaic_cover( + output_dir, week_start, week_end, headlines, image_paths + ) + # Fallback to programmatic text cover (ignore image_paths) + return generate_programmatic_cover( + output_dir, week_start, week_end, headlines, [] + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_cover.py::test_generate_mosaic_cover_single_image -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/cover.py tests/test_cover.py +git commit -m "feat: implement mosaic cover generation logic" +``` + +--- + +### Task 2: Update Publish Route (`src/routes/publish.py`) + +**Files:** +- Modify: `src/routes/publish.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_publish.py +def test_publish_route_uses_mosaic(client, app): + # This requires setting up DB state with articles and images + # A full integration test might be complex to set up here if not already present. + # We will rely on manual verification or existing integration tests if they exist. + pass +``` + +- [ ] **Step 2: Write minimal implementation** + +```python +# src/routes/publish.py +# Update the imports +from src.models import Article, Issue, Image + +# Inside create_issue() + articles_for_issue = ( + Article.query.filter(Article.id.in_(included_ids)) + .order_by(Article.pub_date.asc()) + .all() + ) + headlines = [ + a.title for a in articles_for_issue + if "Obituaries" not in json.loads(a.categories) + ] + + # NEW: Extract first image path for each article + image_paths = [] + for a in articles_for_issue: + first_image = Image.query.filter_by(article_id=a.id).first() + if first_image: + image_paths.append(first_image.local_path) + + try: + cover_path = generate_cover( + cover_method, config.ISSUES_DIR, week_start, week_end, + headlines, image_paths # Changed from categories_list + ) +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/routes/publish.py +git commit -m "feat: pass image paths to cover generator in publish route" +``` + +--- + +### Task 3: Update Scheduler (`src/scheduler.py`) + +**Files:** +- Modify: `src/scheduler.py` + +- [ ] **Step 1: Write minimal implementation** + +```python +# src/scheduler.py +# Assuming there is an auto-publish job that calls generate_cover +# We need to update it similarly to publish.py + +# Inside the auto-publish job function: + # ... fetching articles ... + headlines = [ + a.title for a in articles_for_issue + if "Obituaries" not in json.loads(a.categories) + ] + + # NEW: Extract first image path for each article + from src.models import Image + image_paths = [] + for a in articles_for_issue: + first_image = Image.query.filter_by(article_id=a.id).first() + if first_image: + image_paths.append(first_image.local_path) + + try: + cover_path = generate_cover( + cover_method, config.ISSUES_DIR, week_start, week_end, + headlines, image_paths # Changed from categories_list + ) +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/scheduler.py +git commit -m "feat: pass image paths to cover generator in scheduler" +``` + +--- + +### Task 4: Update UI and Config (`templates/`, `config.py`) + +**Files:** +- Modify: `templates/publish.html` +- Modify: `templates/settings.html` +- Modify: `config.py` + +- [ ] **Step 1: Write minimal implementation** + +```html + + + +``` + +```html + + + +``` + +```python +# config.py +# Remove POLLINATIONS_URL +# POLLINATIONS_URL = "https://image.pollinations.ai/prompt/{prompt}?width=480&height=800&nologo=true" +``` + +- [ ] **Step 2: Commit** + +```bash +git add templates/publish.html templates/settings.html config.py +git commit -m "feat: update UI labels and remove unused config for mosaic cover" +```