docs: add mosaic cover implementation plan
Made-with: Cursor
This commit is contained in:
283
docs/superpowers/plans/2026-04-06-mosaic-cover.md
Normal file
283
docs/superpowers/plans/2026-04-06-mosaic-cover.md
Normal file
@@ -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
|
||||||
|
<!-- 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>
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- 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>
|
||||||
|
```
|
||||||
|
|
||||||
|
```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"
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user