# insta-lab Design Importer 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:** 사용자가 `insta-lab/app/templates//pages/`에 둔 카드 디자인 PNG 10장을 Claude Sonnet Vision으로 분석해 단일 `card.html.j2`를 자동 생성하고, 새 theme를 env로 활성화해 카드 렌더에 반영한다. **Architecture:** 신규 `design_importer.py` 모듈이 (a) 파일명 자동 매핑 (b) 이미지 검증 (c) Vision API 호출 (d) Jinja sanity render (e) HTML 저장 + 백업까지 처리. CLI 진입점 `python -m app.design_importer `. 카드 렌더는 `INSTA_DEFAULT_THEME` env로 어떤 theme의 HTML을 쓸지 결정하며, theme HTML이 없으면 default로 폴백. **Tech Stack:** Python 3.12, anthropic 0.52 (Claude Sonnet Vision), Jinja2, Pillow (이미지 dimension 검증), pytest. **Spec reference:** `docs/superpowers/specs/2026-05-17-insta-design-importer-design.md` --- ## File Structure ### Files to create | Path | Responsibility | |------|----------------| | `insta-lab/app/design_importer.py` | 페이지 매핑 + 이미지 검증 + Vision 호출 + Jinja 검증 + 저장. CLI `__main__` 포함 | | `insta-lab/tests/test_design_importer.py` | 6 케이스 (매핑 / 검증 / Vision mock / Jinja 폴백) | ### Files to modify | Path | Change | |------|--------| | `insta-lab/app/config.py` | `INSTA_DEFAULT_THEME = os.getenv("INSTA_DEFAULT_THEME", "default")` 추가 | | `insta-lab/app/main.py` | `_bg_create_slate`에서 `render_slate(sid, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")` | | `insta-lab/app/card_renderer.py` | `render_slate` 시작부에 template 파일 존재 가드 (없으면 default 폴백) | | `insta-lab/tests/test_card_renderer.py` | 폴백 가드 테스트 1개 추가 | | `docker-compose.yml` | insta-lab env에 `INSTA_DEFAULT_THEME=${INSTA_DEFAULT_THEME:-default}` 추가 | | `CLAUDE.md` | section 9 insta-lab에 design_importer + INSTA_DEFAULT_THEME + CLI 항목 추가 | --- ## Conventions - 작업 디렉토리: `C:\Users\jaeoh\Desktop\workspace\web-backend`. 모든 commit은 feat 브랜치 `feat/insta-design-importer`에서. - TDD 엄수: failing test → 실패 확인 → 구현 → 통과 확인 → commit. - Windows-safe SQLite cleanup 불필요 (DB 미사용 모듈). - Anthropic Vision은 모든 테스트에서 mock — 실제 API 호출 금지. - Pillow 이미지 dimension 검증 시 실제 PNG fixture는 가벼운 1080×1350 단색 PNG로 임시 생성. --- ## Task 0: Branch + scaffold **Files:** - No code changes. - [ ] **Step 1: 현재 main 동기화 확인** ```bash cd /c/Users/jaeoh/Desktop/workspace/web-backend git checkout main git pull --ff-only origin main git log --oneline -3 ``` Expected: 최근 commit이 `077d411` 또는 그 이후 (spec commit `ecf1f64` 포함). - [ ] **Step 2: feat 브랜치 생성** ```bash git checkout -b feat/insta-design-importer ``` Expected: `Switched to a new branch 'feat/insta-design-importer'` - [ ] **Step 3: insta-lab 기존 테스트 baseline** ```bash cd insta-lab && pytest -q 2>&1 | tail -3 ``` Expected: 모두 PASS. 통과 못 하면 baseline부터 fix 후 plan 진행. --- ## Task 1: `_resolve_page_mapping` (파일명 자동 매핑 + `_order.json` override) **Files:** - Create: `insta-lab/app/design_importer.py` (이 task에서 페이지 매핑 부분만) - Create: `insta-lab/tests/test_design_importer.py` (이 task에서 매핑 테스트 3건만) - [ ] **Step 1: failing test 작성** `insta-lab/tests/test_design_importer.py` ```python """design_importer 회귀 테스트.""" import json import os import tempfile from pathlib import Path import pytest from app import design_importer @pytest.fixture def tmp_theme(tmp_path): """templates//pages/ 구조를 가진 임시 디렉토리.""" pages = tmp_path / "minimal" / "pages" pages.mkdir(parents=True) return tmp_path / "minimal" def _touch(pages_dir: Path, names: list[str]): for n in names: (pages_dir / n).write_bytes(b"") # 매핑 테스트는 dimension 검증 안 함 def test_auto_page_mapping_with_cover_and_cta(tmp_theme): """cover 키워드 → 1, cta 키워드 → 10, 나머지는 알파벳 순 2~9.""" _touch(tmp_theme / "pages", [ "insta_card_start.png", # start → page 1 (cover priority) "insta_card_keyword.png", "insta_card_highlight.png", "insta_card_observation.png", "insta_card_memo.png", "insta_card_oneline.png", "insta_card_checklist.png", "insta_card_study.png", "insta_card_cta.png", # cta → page 10 "insta_card_finish.png", # finish은 cta가 이미 채워 본문 풀로 ]) mapping = design_importer._resolve_page_mapping(tmp_theme / "pages") assert mapping["insta_card_start.png"] == 1 assert mapping["insta_card_cta.png"] == 10 # 본문 풀 (남은 8장)은 알파벳 정렬: checklist, finish, highlight, keyword, memo, observation, oneline, study body_pages = {p: n for n, p in mapping.items() if 2 <= p <= 9} assert body_pages[2] == "insta_card_checklist.png" assert body_pages[3] == "insta_card_finish.png" assert body_pages[9] == "insta_card_study.png" assert set(mapping.values()) == set(range(1, 11)) def test_explicit_order_json_overrides_auto_mapping(tmp_theme): """_order.json이 있으면 자동 매핑보다 우선.""" pages = tmp_theme / "pages" _touch(pages, [ "insta_card_start.png", "insta_card_cta.png", "insta_card_finish.png", ] + [f"insta_card_body{i}.png" for i in range(1, 8)]) (pages / "_order.json").write_text(json.dumps({ "insta_card_start.png": 1, "insta_card_finish.png": 10, # cta 대신 finish를 page 10으로 "insta_card_cta.png": 5, # cta를 본문 한가운데로 강제 "insta_card_body1.png": 2, "insta_card_body2.png": 3, "insta_card_body3.png": 4, "insta_card_body4.png": 6, "insta_card_body5.png": 7, "insta_card_body6.png": 8, "insta_card_body7.png": 9, }), encoding="utf-8") mapping = design_importer._resolve_page_mapping(pages) assert mapping["insta_card_finish.png"] == 10 assert mapping["insta_card_cta.png"] == 5 assert mapping["insta_card_start.png"] == 1 def test_validates_exactly_ten_pngs(tmp_theme): """PNG가 정확히 10장이 아니면 ValueError.""" _touch(tmp_theme / "pages", [f"x{i}.png" for i in range(5)]) # 5장 with pytest.raises(ValueError, match="10"): design_importer._resolve_page_mapping(tmp_theme / "pages") ``` - [ ] **Step 2: 테스트 실패 확인** ```bash cd insta-lab && pytest tests/test_design_importer.py -v ``` Expected: ImportError (design_importer 모듈 없음). - [ ] **Step 3: 구현** — `insta-lab/app/design_importer.py` 생성 ```python """사용자 디자인 PNG 10장 → Claude Sonnet Vision → Jinja card.html.j2 자동 생성. CLI: python -m app.design_importer """ import json import logging import re from pathlib import Path from typing import Dict, List logger = logging.getLogger(__name__) # 페이지 1 (커버) 키워드 우선순위 — 먼저 매치된 키워드를 가진 첫 파일만 page 1 _COVER_KEYWORDS = ("cover", "start", "intro") # 페이지 10 (CTA) 키워드 우선순위 _CTA_KEYWORDS = ("cta", "outro", "finish", "end") def _resolve_page_mapping(pages_dir: Path) -> Dict[str, int]: """templates//pages/ 안의 PNG 10장을 page 1~10에 매핑. 우선순위: 1. `_order.json` 있으면 그 매핑 그대로 사용 (검증 후 반환) 2. 자동 매핑: - _COVER_KEYWORDS 우선순위 순서로 가장 앞 키워드를 가진 첫 PNG → page 1 - _CTA_KEYWORDS 우선순위 순서로 가장 앞 키워드를 가진 첫 PNG → page 10 - 남은 8장은 알파벳 정렬 → page 2~9 """ pages_dir = Path(pages_dir) pngs = sorted([p.name for p in pages_dir.glob("*.png")]) if len(pngs) != 10: raise ValueError( f"{pages_dir}에 PNG 10장 필요, 발견 {len(pngs)}장: {pngs}" ) # _order.json override order_path = pages_dir / "_order.json" if order_path.exists(): try: mapping = json.loads(order_path.read_text(encoding="utf-8")) except Exception as e: logger.warning("_order.json 파싱 실패, 자동 매핑으로 폴백: %s", e) else: if set(mapping.keys()) == set(pngs) and set(mapping.values()) == set(range(1, 11)): return {k: int(v) for k, v in mapping.items()} logger.warning( "_order.json 형식 오류 (파일 누락·page 중복), 자동 매핑으로 폴백: keys=%s values=%s", sorted(mapping.keys()), sorted(mapping.values()), ) # 자동 매핑 remaining = list(pngs) cover_pick = _pick_by_keywords(remaining, _COVER_KEYWORDS) cta_pick = _pick_by_keywords(remaining, _CTA_KEYWORDS) if cover_pick != _CTA_KEYWORDS_FIRST_MATCH else None # 위 한 줄은 명확하지 않으니 다음처럼 풀어쓰자 return _build_mapping(pngs) _CTA_KEYWORDS_FIRST_MATCH = object() # sentinel, 사용 안 함 def _pick_by_keywords(names: List[str], keywords: tuple) -> str | None: """names 중 keywords의 우선순위에 따라 첫 매치 파일명 반환 (없으면 None).""" lower_names = [(n, n.lower()) for n in names] for kw in keywords: for orig, low in lower_names: if kw in low: return orig return None def _build_mapping(pngs: List[str]) -> Dict[str, int]: """자동 매핑 알고리즘 본체.""" mapping: Dict[str, int] = {} remaining = list(pngs) cover = _pick_by_keywords(remaining, _COVER_KEYWORDS) if cover: mapping[cover] = 1 remaining.remove(cover) cta = _pick_by_keywords(remaining, _CTA_KEYWORDS) if cta: mapping[cta] = 10 remaining.remove(cta) # 남은 파일을 알파벳 정렬 후 비어있는 페이지 (2~9 우선, 1·10이 비었으면 거기도) remaining_sorted = sorted(remaining) free_pages = sorted(set(range(1, 11)) - set(mapping.values())) for name, page in zip(remaining_sorted, free_pages): mapping[name] = page return mapping ``` > 위 `_pick_by_keywords` 호출이 한 줄에 잘못 들어간 sentinel은 무시하고 `_build_mapping`만 사용. (다음 step에서 정리.) - [ ] **Step 4: 잘못된 sentinel 라인 제거** — 위 구현에서 `cover_pick = ...` 라인부터 `_CTA_KEYWORDS_FIRST_MATCH = object()`까지 삭제하고 `_resolve_page_mapping`이 바로 `_build_mapping(pngs)`을 호출하도록 정리. 최종 형태: ```python def _resolve_page_mapping(pages_dir: Path) -> Dict[str, int]: pages_dir = Path(pages_dir) pngs = sorted([p.name for p in pages_dir.glob("*.png")]) if len(pngs) != 10: raise ValueError( f"{pages_dir}에 PNG 10장 필요, 발견 {len(pngs)}장: {pngs}" ) order_path = pages_dir / "_order.json" if order_path.exists(): try: mapping = json.loads(order_path.read_text(encoding="utf-8")) except Exception as e: logger.warning("_order.json 파싱 실패, 자동 매핑으로 폴백: %s", e) else: if set(mapping.keys()) == set(pngs) and set(mapping.values()) == set(range(1, 11)): return {k: int(v) for k, v in mapping.items()} logger.warning( "_order.json 형식 오류 (파일 누락·page 중복), 자동 매핑으로 폴백" ) return _build_mapping(pngs) def _pick_by_keywords(names, keywords): lower_names = [(n, n.lower()) for n in names] for kw in keywords: for orig, low in lower_names: if kw in low: return orig return None def _build_mapping(pngs): mapping = {} remaining = list(pngs) cover = _pick_by_keywords(remaining, _COVER_KEYWORDS) if cover: mapping[cover] = 1 remaining.remove(cover) cta = _pick_by_keywords(remaining, _CTA_KEYWORDS) if cta: mapping[cta] = 10 remaining.remove(cta) remaining_sorted = sorted(remaining) free_pages = sorted(set(range(1, 11)) - set(mapping.values())) for name, page in zip(remaining_sorted, free_pages): mapping[name] = page return mapping ``` - [ ] **Step 5: 테스트 통과 확인** ```bash cd insta-lab && pytest tests/test_design_importer.py -v ``` Expected: 3 PASS. - [ ] **Step 6: Commit** ```bash git add insta-lab/app/design_importer.py insta-lab/tests/test_design_importer.py git commit -m "feat(insta-lab): design_importer page mapping (자동 + _order.json override)" ``` --- ## Task 2: `_validate_images` (이미지 dimension 검증) **Files:** - Modify: `insta-lab/app/design_importer.py` - Modify: `insta-lab/tests/test_design_importer.py` - [ ] **Step 1: failing test 추가** `tests/test_design_importer.py` 끝에 추가: ```python def _make_png(path: Path, size: tuple[int, int]) -> None: """size 픽셀의 단색 PNG를 생성.""" from PIL import Image Image.new("RGB", size, color=(200, 200, 200)).save(path, format="PNG") def test_validate_images_accepts_1080x1350(tmp_theme): pages = tmp_theme / "pages" for i in range(10): _make_png(pages / f"insta_card_{i:02d}.png", (1080, 1350)) # 예외 없이 통과해야 함 design_importer._validate_images(pages) def test_validate_images_rejects_wrong_dimensions(tmp_theme): pages = tmp_theme / "pages" for i in range(10): size = (800, 800) if i == 5 else (1080, 1350) _make_png(pages / f"insta_card_{i:02d}.png", size) with pytest.raises(ValueError, match="1080x1350"): design_importer._validate_images(pages) ``` - [ ] **Step 2: 실패 확인** ```bash cd insta-lab && pytest tests/test_design_importer.py::test_validate_images_accepts_1080x1350 -v ``` Expected: AttributeError on `_validate_images`. - [ ] **Step 3: 구현 — `design_importer.py`에 함수 추가** ```python from PIL import Image # 파일 상단 import 섹션에 추가 _EXPECTED_SIZE = (1080, 1350) def _validate_images(pages_dir: Path) -> None: """모든 PNG가 정확히 1080×1350인지 검증. 다르면 ValueError.""" pages_dir = Path(pages_dir) bad = [] for png_path in sorted(pages_dir.glob("*.png")): with Image.open(png_path) as img: if img.size != _EXPECTED_SIZE: bad.append((png_path.name, img.size)) if bad: msg = "; ".join(f"{n}: {s[0]}x{s[1]}" for n, s in bad) raise ValueError( f"모든 카드 디자인은 1080x1350이어야 함. 잘못된 파일: {msg}" ) ``` - [ ] **Step 4: 통과 확인** ```bash cd insta-lab && pytest tests/test_design_importer.py -v ``` Expected: 5 PASS (Task 1의 3개 + 신규 2개). - [ ] **Step 5: Commit** ```bash git add insta-lab/app/design_importer.py insta-lab/tests/test_design_importer.py git commit -m "feat(insta-lab): design_importer image dimension 검증 (1080x1350)" ``` --- ## Task 3: `import_design_theme` (Vision 호출 + Jinja sanity + 저장) **Files:** - Modify: `insta-lab/app/design_importer.py` - Modify: `insta-lab/tests/test_design_importer.py` - [ ] **Step 1: failing test 추가** `tests/test_design_importer.py` 끝에 추가: ```python def test_import_design_theme_writes_html_via_mocked_vision(tmp_theme, monkeypatch): """Vision mock이 정상 HTML 반환 시 card.html.j2 파일이 저장되고 결과 dict 반환.""" pages = tmp_theme / "pages" names = [ "insta_card_start.png", "insta_card_cta.png", ] + [f"insta_card_body{i}.png" for i in range(8)] for n in names: _make_png(pages / n, (1080, 1350)) fake_html = """ {% if page_no == 1 %}
{{ headline }}
{% endif %} {% if page_no >= 2 and page_no <= 9 %}
{{ headline }}

{{ body }}

{% endif %} {% if page_no == 10 %}
{{ headline }}

{{ cta }}

{% endif %} """ def fake_vision_call(images_with_pages, theme_name): return {"html": fake_html, "tokens": 12345, "summary": "test summary"} monkeypatch.setattr(design_importer, "_call_vision", fake_vision_call) result = design_importer.import_design_theme(str(tmp_theme)) assert result["theme_name"] == "minimal" assert "card.html.j2" in result["html_path"] assert (tmp_theme / "card.html.j2").exists() assert (tmp_theme / "card.html.j2").read_text(encoding="utf-8") == fake_html assert "insta_card_start.png" in result["page_mapping"] assert result["tokens_used"] == 12345 def test_import_design_theme_raises_on_jinja_parse_failure(tmp_theme, monkeypatch): """Vision이 깨진 Jinja 반환 시 ValueError + .error.txt 보존.""" pages = tmp_theme / "pages" for i in range(10): _make_png(pages / f"insta_card_{i:02d}.png", (1080, 1350)) broken_html = "
{% if page_no == 1 unclosed" monkeypatch.setattr(design_importer, "_call_vision", lambda imgs, name: {"html": broken_html, "tokens": 100, "summary": ""}) with pytest.raises(ValueError, match="Jinja"): design_importer.import_design_theme(str(tmp_theme)) assert (tmp_theme / "card.html.j2.error.txt").exists() def test_import_design_theme_backs_up_existing_html(tmp_theme, monkeypatch): """기존 card.html.j2가 있으면 .bak.YYYYMMDD-HHMMSS로 백업 후 새로 작성.""" pages = tmp_theme / "pages" for i in range(10): _make_png(pages / f"insta_card_{i:02d}.png", (1080, 1350)) (tmp_theme / "card.html.j2").write_text("OLD HTML", encoding="utf-8") monkeypatch.setattr(design_importer, "_call_vision", lambda imgs, name: {"html": "
{{ headline }}
", "tokens": 50, "summary": ""}) design_importer.import_design_theme(str(tmp_theme)) # .bak.* 파일이 생성되었어야 함 backups = list(tmp_theme.glob("card.html.j2.bak.*")) assert len(backups) == 1 assert backups[0].read_text(encoding="utf-8") == "OLD HTML" # 새 파일은 새 내용 assert "headline" in (tmp_theme / "card.html.j2").read_text(encoding="utf-8") ``` - [ ] **Step 2: 실패 확인** ```bash cd insta-lab && pytest tests/test_design_importer.py -v ``` Expected: 새 3개 test가 ImportError/AttributeError로 실패. - [ ] **Step 3: 구현 — `design_importer.py`에 함수 추가** 파일 상단 import 추가: ```python import base64 import datetime from typing import Any, Dict, List, Tuple from anthropic import Anthropic from jinja2 import Environment, BaseLoader, TemplateSyntaxError from .config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL_SONNET ``` 함수 추가 (파일 끝에): ```python _VISION_SYSTEM_PROMPT = """너는 인스타그램 카드 뉴스 디자인을 모방하는 프론트엔드 디자이너다. 입력: 10장의 카드 디자인 이미지 (각 1080×1350, placeholder 텍스트가 박혀있음) + 파일명 → 페이지 번호 매핑. 출력: 단일 Jinja2 HTML 파일 본문 (코드펜스·설명 텍스트 금지). 핵심 제약 — placeholder 텍스트 마스킹: PNG에는 디자인 placeholder 텍스트가 이미 그려져 있다. 동적 카피로 교체할 때 원본 텍스트가 비치면 안 된다. 각 텍스트 영역마다 두 layer를 그려라: (a) 마스킹 박스: position: absolute로 placeholder 영역과 같은 좌표. background는 그 영역 주변 픽셀 색 (카드 배경색)에서 추출. padding 8px 여유. (b) 동적 텍스트 layer: 마스킹 박스와 동일 좌표. font-size·font-weight·color는 원본 placeholder의 스타일을 모방. {{ headline }} / {{ body }} / {{ cta }} Jinja 변수 사용. 페이지 종류별 영역 가이드: - page 1 (cover): 메인 headline 1개 영역 - page 2~9 (body): headline 영역 + body 영역 - page 10 (cta): headline + body + cta 영역 요구사항: - 컨테이너 width 1080px, height 1350px - 각 페이지마다 `background-image: url('pages/{{filename}}')`로 사용자 PNG 로드 - page_no 1~10 분기: {% if page_no == N %}...{% endif %} 구조 - 폰트는 Noto Sans KR (Google Fonts CDN). letter-spacing -0.02em, line-height 1.3 기본 - 텍스트 영역은 word-wrap: break-word + overflow: hidden (동적 카피가 길어도 마스킹 박스 밖으로 안 새도록) - HTML 에