Files
pi-weekly-newspaper/docs/superpowers/plans/2026-04-06-mosaic-cover.md
2026-04-06 19:18:18 -04:00

8.7 KiB

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

# 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
# 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
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

# 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
# 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
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

# 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
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

<!-- templates/publish.html -->
<!-- Find the cover method select/radio buttons and change "ai" to "mosaic" -->
<select name="cover_method">
    <option value="mosaic">Mosaic Cover</option>
    <option value="text">Text Cover</option>
</select>
<!-- templates/settings.html -->
<!-- Find the default cover method select/radio buttons and change "ai" to "mosaic" -->
<select name="default_cover_method">
    <option value="mosaic">Mosaic Cover</option>
    <option value="text">Text Cover</option>
</select>
# config.py
# Remove POLLINATIONS_URL
# POLLINATIONS_URL = "https://image.pollinations.ai/prompt/{prompt}?width=480&height=800&nologo=true"
  • Step 2: Commit
git add templates/publish.html templates/settings.html config.py
git commit -m "feat: update UI labels and remove unused config for mosaic cover"