사용자 디자인 PNG에 placeholder 텍스트가 이미 박혀있는 경우 대응. Vision system prompt에 두 layer 요구: (a) 마스킹 박스: placeholder 영역 좌표 + 주변 배경색으로 덮음 (b) 동적 텍스트 layer: 동일 좌표에 새 카피, 원본 폰트 스타일 모방 + overflow:hidden으로 긴 카피가 박스 밖 새지 않게. spec 4-3 + plan Task 3 step 3 동시 패치.
35 KiB
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/<theme>/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 <theme>. 카드 렌더는 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 동기화 확인
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 브랜치 생성
git checkout -b feat/insta-design-importer
Expected: Switched to a new branch 'feat/insta-design-importer'
- Step 3: insta-lab 기존 테스트 baseline
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
"""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/<theme>/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: 테스트 실패 확인
cd insta-lab && pytest tests/test_design_importer.py -v
Expected: ImportError (design_importer 모듈 없음).
- Step 3: 구현 —
insta-lab/app/design_importer.py생성
"""사용자 디자인 PNG 10장 → Claude Sonnet Vision → Jinja card.html.j2 자동 생성.
CLI: python -m app.design_importer <theme_name>
"""
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/<theme>/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)을 호출하도록 정리. 최종 형태:
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: 테스트 통과 확인
cd insta-lab && pytest tests/test_design_importer.py -v
Expected: 3 PASS.
- Step 6: Commit
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 끝에 추가:
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: 실패 확인
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에 함수 추가
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: 통과 확인
cd insta-lab && pytest tests/test_design_importer.py -v
Expected: 5 PASS (Task 1의 3개 + 신규 2개).
- Step 5: Commit
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 끝에 추가:
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 = """<!DOCTYPE html><html><body>
{% if page_no == 1 %}<div class="cover">{{ headline }}</div>{% endif %}
{% if page_no >= 2 and page_no <= 9 %}<div class="body">{{ headline }}<p>{{ body }}</p></div>{% endif %}
{% if page_no == 10 %}<div class="cta">{{ headline }}<p>{{ cta }}</p></div>{% endif %}
</body></html>"""
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 = "<div>{% 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": "<div>{{ headline }}</div>", "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: 실패 확인
cd insta-lab && pytest tests/test_design_importer.py -v
Expected: 새 3개 test가 ImportError/AttributeError로 실패.
- Step 3: 구현 —
design_importer.py에 함수 추가
파일 상단 import 추가:
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
함수 추가 (파일 끝에):
_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 <head>에 <style>로 모든 CSS 인라인. <link> 외부 stylesheet 금지
- 출력은 <!DOCTYPE html>로 시작하는 완전한 HTML 문서
"""
def _call_vision(images_with_pages: List[Tuple[str, int, bytes]],
theme_name: str) -> Dict[str, Any]:
"""Claude Sonnet Vision 호출. images_with_pages: [(filename, page_no, png_bytes), ...].
Returns: {"html": str, "tokens": int, "summary": str}
"""
if not ANTHROPIC_API_KEY:
raise RuntimeError("ANTHROPIC_API_KEY 미설정 — design_importer 사용 불가")
client = Anthropic(api_key=ANTHROPIC_API_KEY)
content: List[Dict[str, Any]] = []
for filename, page_no, png_bytes in sorted(images_with_pages, key=lambda x: x[1]):
content.append({
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": base64.b64encode(png_bytes).decode("ascii"),
},
})
content.append({
"type": "text",
"text": f"위 이미지 = '{filename}' = page {page_no}",
})
content.append({
"type": "text",
"text": (
f"theme 이름: '{theme_name}'. 위 10장 디자인을 모방한 단일 Jinja2 HTML을 출력해라."
),
})
msg = client.messages.create(
model=ANTHROPIC_MODEL_SONNET,
max_tokens=16000,
system=_VISION_SYSTEM_PROMPT,
messages=[{"role": "user", "content": content}],
)
raw = msg.content[0].text.strip()
# 코드펜스 자르기
if raw.startswith("```"):
raw = re.sub(r"^```(?:html)?\s*|\s*```$", "", raw).strip()
summary = raw[:200].replace("\n", " ") # 첫 200자만 분석 요약으로
return {"html": raw, "tokens": msg.usage.input_tokens + msg.usage.output_tokens,
"summary": summary}
def _validate_html_template(html: str) -> None:
"""Jinja2 Environment로 sanity render. 문법 오류면 TemplateSyntaxError 전파."""
env = Environment(loader=BaseLoader())
env.from_string(html) # 파싱만으로도 syntax error 검출
def import_design_theme(theme_dir: str) -> Dict[str, Any]:
"""templates/<theme>/pages/*.png 10장 → Vision → card.html.j2 생성.
Args:
theme_dir: theme 디렉토리 절대 경로 (예: /app/app/templates/minimal)
Returns:
{theme_name, html_path, page_mapping, analysis_summary, tokens_used}
"""
theme_path = Path(theme_dir)
theme_name = theme_path.name
pages_dir = theme_path / "pages"
# 1. 매핑 + 검증
mapping = _resolve_page_mapping(pages_dir)
_validate_images(pages_dir)
# 2. Vision 호출
images_with_pages = []
for filename, page_no in mapping.items():
png_bytes = (pages_dir / filename).read_bytes()
images_with_pages.append((filename, page_no, png_bytes))
vision_result = _call_vision(images_with_pages, theme_name)
html = vision_result["html"]
# 3. Jinja sanity
html_path = theme_path / "card.html.j2"
try:
_validate_html_template(html)
except TemplateSyntaxError as e:
error_path = theme_path / "card.html.j2.error.txt"
error_path.write_text(html, encoding="utf-8")
raise ValueError(
f"Vision 응답이 Jinja 문법 오류: {e}. 원본 HTML은 {error_path}에 저장됨"
)
# 4. 백업 + 저장
if html_path.exists():
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = theme_path / f"card.html.j2.bak.{ts}"
html_path.rename(backup_path)
logger.info("기존 HTML 백업: %s", backup_path)
html_path.write_text(html, encoding="utf-8")
return {
"theme_name": theme_name,
"html_path": str(html_path),
"page_mapping": mapping,
"analysis_summary": vision_result["summary"],
"tokens_used": vision_result["tokens"],
}
- Step 4: 통과 확인
cd insta-lab && pytest tests/test_design_importer.py -v
Expected: 8 PASS (Task 1·2의 5개 + 신규 3개).
- Step 5: Commit
git add insta-lab/app/design_importer.py insta-lab/tests/test_design_importer.py
git commit -m "feat(insta-lab): import_design_theme — Vision 호출 + Jinja sanity + 백업 저장"
Task 4: CLI entrypoint __main__
Files:
-
Modify:
insta-lab/app/design_importer.py -
Step 1: 파일 끝에
__main__추가
def main_cli():
"""CLI: python -m app.design_importer <theme_name> [--templates-dir PATH]"""
import argparse
parser = argparse.ArgumentParser(
prog="design_importer",
description="사용자 카드 디자인 PNG 10장을 Claude Vision으로 분석해 card.html.j2 생성",
)
parser.add_argument("theme_name", help="templates/<theme_name>/ 디렉토리명")
parser.add_argument(
"--templates-dir",
default="/app/app/templates",
help="templates 루트 디렉토리 (기본 컨테이너 내부 경로)",
)
args = parser.parse_args()
theme_dir = Path(args.templates_dir) / args.theme_name
if not theme_dir.is_dir():
print(f"ERROR: theme 디렉토리 없음: {theme_dir}")
raise SystemExit(1)
try:
result = import_design_theme(str(theme_dir))
except Exception as e:
print(f"ERROR: {e}")
raise SystemExit(1)
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main_cli()
- Step 2: CLI 동작 sanity (no Vision 호출, --help만)
cd insta-lab && python -m app.design_importer --help
Expected: argparse usage 메시지 출력 (theme_name 인자 + --templates-dir 옵션 표시).
- Step 3: Commit
git add insta-lab/app/design_importer.py
git commit -m "feat(insta-lab): design_importer CLI entrypoint (python -m app.design_importer)"
Task 5: card_renderer 폴백 가드
Files:
-
Modify:
insta-lab/app/card_renderer.py -
Modify:
insta-lab/tests/test_card_renderer.py -
Step 1: failing test 추가
insta-lab/tests/test_card_renderer.py끝에:
@pytest.mark.asyncio
async def test_render_falls_back_to_default_when_theme_html_missing(tmp_db_and_dirs):
"""존재하지 않는 theme HTML 지정 시 default/card.html.j2로 폴백, 정상 PNG 생성."""
sid = _seed_slate()
paths = await card_renderer.render_slate(sid, template="ghost_theme/card.html.j2")
assert len(paths) == 10
for p in paths:
assert os.path.exists(p)
assert os.path.getsize(p) > 1000
- Step 2: 실패 확인
cd insta-lab && pytest tests/test_card_renderer.py::test_render_falls_back_to_default_when_theme_html_missing -v
Expected: jinja2 TemplateNotFound 또는 유사 에러.
- Step 3: 구현 —
card_renderer.py의render_slate시작부 수정
기존 (대략 line 50 근처):
async def render_slate(slate_id: int, template: str = "default/card.html.j2") -> List[str]:
slate = db.get_card_slate(slate_id)
if not slate:
raise ValueError(f"slate {slate_id} not found")
env = _env()
tmpl = env.get_template(template)
수정:
async def render_slate(slate_id: int, template: str = "default/card.html.j2") -> List[str]:
slate = db.get_card_slate(slate_id)
if not slate:
raise ValueError(f"slate {slate_id} not found")
env = _env()
# template 파일이 없으면 default로 폴백 (INSTA_DEFAULT_THEME가 import 안 된 theme이면 안전)
template_full = Path(_resolve_template_dir()) / template
if not template_full.exists():
logger.warning("Template '%s' 없음 → 'default/card.html.j2'로 폴백", template)
template = "default/card.html.j2"
tmpl = env.get_template(template)
- Step 4: 통과 확인
cd insta-lab && pytest tests/test_card_renderer.py -v
Expected: 모두 PASS (기존 1 + 신규 1).
- Step 5: Commit
git add insta-lab/app/card_renderer.py insta-lab/tests/test_card_renderer.py
git commit -m "feat(insta-lab): card_renderer theme 폴백 가드 (HTML 없으면 default)"
Task 6: env + main.py + docker-compose 통합
Files:
-
Modify:
insta-lab/app/config.py -
Modify:
insta-lab/app/main.py -
Modify:
docker-compose.yml -
Step 1:
insta-lab/app/config.py에 추가
기존 CARD_TEMPLATE_DIR 라인 다음에:
INSTA_DEFAULT_THEME = os.getenv("INSTA_DEFAULT_THEME", "default")
- Step 2:
insta-lab/app/main.py변경
상단 import에 추가:
from .config import (
CORS_ALLOW_ORIGINS, NAVER_CLIENT_ID, ANTHROPIC_API_KEY,
INSTA_DATA_PATH, DB_PATH, DEFAULT_CATEGORY_SEEDS, KEYWORDS_PER_CATEGORY,
INSTA_DEFAULT_THEME,
)
_bg_create_slate 함수 안의 render_slate 호출:
await card_renderer.render_slate(sid)
→
await card_renderer.render_slate(sid, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")
_bg_render도 동일 변경 (재렌더 endpoint도 같은 theme 적용):
await card_renderer.render_slate(slate_id)
→
await card_renderer.render_slate(slate_id, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")
- Step 3:
docker-compose.ymlinsta-lab env에 추가
- INSTA_DATA_PATH=/app/data
- CARD_TEMPLATE_DIR=/app/app/templates
- INSTA_DEFAULT_THEME=${INSTA_DEFAULT_THEME:-default}
(CARD_TEMPLATE_DIR 다음 줄에 추가)
- Step 4: 전체 insta-lab pytest 검증
cd insta-lab && pytest -v 2>&1 | tail -5
Expected: 모두 PASS. 새 변경이 기존 test 깨뜨리면 fix 후 진행.
- Step 5: docker-compose syntax 검증
cd /c/Users/jaeoh/Desktop/workspace/web-backend
python -c "import yaml; yaml.safe_load(open('docker-compose.yml', encoding='utf-8')); print('YAML OK')"
Expected: YAML OK
- Step 6: Commit
git add insta-lab/app/config.py insta-lab/app/main.py docker-compose.yml
git commit -m "feat(insta-lab): INSTA_DEFAULT_THEME env 통합 (main + docker-compose)"
Task 7: CLAUDE.md insta-lab 섹션 갱신
Files:
-
Modify:
CLAUDE.md -
Step 1: CLAUDE.md의 insta-lab 섹션 (section 9)에서 환경변수 목록 부분 찾기
다음 패턴 검색:
- `INSTA_DATA_PATH`: SQLite + 카드 PNG 저장 경로 (기본 `/app/data`)
- `CARD_TEMPLATE_DIR`: HTML 템플릿 디렉토리 (기본 `/app/app/templates`)
다음 라인 추가:
- `INSTA_DEFAULT_THEME`: 카드 렌더에 사용할 theme 디렉토리명 (기본 `default`). `templates/<theme>/card.html.j2`가 없으면 자동으로 default 폴백
- Step 2: insta-lab 섹션에 "디자인 import" 항목 추가
insta-lab API 목록 표 직전 또는 직후에 추가:
**디자인 import (사용자 디자인 PNG → Jinja HTML)**
- `insta-lab/app/templates/<theme>/pages/*.png` (10장, 1080×1350) → Claude Sonnet Vision → `templates/<theme>/card.html.j2` 자동 생성
- CLI: `docker exec insta-lab python -m app.design_importer <theme>`
- 파일명 자동 매핑: `cover`/`start`/`intro` → page 1, `cta`/`outro`/`finish`/`end` → page 10, 나머지 알파벳 순 → page 2~9
- 매핑 override: `pages/_order.json`에 `{filename: page_no}` 명시 (10장 + page 1~10 완전 매핑일 때만 적용)
- 기존 HTML 자동 백업 (`card.html.j2.bak.YYYYMMDD-HHMMSS`)
- 활성화: NAS `.env`에 `INSTA_DEFAULT_THEME=<theme>` 추가 + `docker compose restart insta-lab`
- 토큰 비용: 1회당 ~15K tokens (~$0.05 Sonnet 기준)
- Step 3: Commit
git add CLAUDE.md
git commit -m "docs(claude-md): insta-lab section에 design_importer + INSTA_DEFAULT_THEME 항목"
Task 8: PR + 운영 활성화 안내
Files: none
- Step 1: 브랜치 push
cd /c/Users/jaeoh/Desktop/workspace/web-backend
git log --oneline main..HEAD
git push -u origin feat/insta-design-importer
Expected: 7개 commit visible, PR 링크 출력.
- Step 2: PR 머지 안내
Gitea에서 PR 생성 + 머지. webhook이 자동으로 design_importer.py를 NAS 컨테이너에 배포.
- Step 3: 머지 후 NAS에서 실제 디자인 import 실행
ssh gahusb.synology.me
# minimal theme 디자인 변환 (15K 토큰 ≈ $0.05)
docker exec insta-lab python -m app.design_importer minimal
# 결과 dict JSON 확인 — page_mapping이 의도와 맞는지 검토
매핑이 의도와 다르면 (예: insta_card_finish.png가 본문 page 2에 가버림) pages/_order.json 만들어 명시:
# 로컬 web-backend에서
cat > insta-lab/app/templates/minimal/pages/_order.json <<'EOF'
{
"insta_card_start.png": 1,
"insta_card_keyword.png": 2,
"insta_card_highlight.png": 3,
"insta_card_observation.png": 4,
"insta_card_memo.png": 5,
"insta_card_oneline.png": 6,
"insta_card_checklist.png": 7,
"insta_card_study.png": 8,
"insta_card_finish.png": 9,
"insta_card_cta.png": 10
}
EOF
git add insta-lab/app/templates/minimal/pages/_order.json
git commit -m "feat(insta-lab): minimal theme page order override"
git push origin main
webhook 배포 후 NAS에서 importer 재실행.
- Step 4: theme 활성화
# NAS .env에 추가
ssh gahusb.synology.me
echo "INSTA_DEFAULT_THEME=minimal" >> /volume1/docker/webpage/.env
cd /volume1/docker/webpage
docker compose restart insta-lab
- Step 5: 시각 검증
# 새 카드 슬레이트 생성 (Insta UI 또는 수동 트리거)
curl -X POST http://localhost:18700/api/insta/slates \
-H "Content-Type: application/json" \
-d '{"keyword":"테스트", "category":"economy"}'
# task 폴링 후 새 슬레이트의 첫 페이지 PNG 받아보기
curl http://localhost:18700/api/insta/slates/<new_id>/assets/1 -o test_minimal_page1.png
PNG가 minimal 디자인 배경 + 동적 카피로 렌더되었으면 성공.
생성된 card.html.j2가 마음에 안 들면:
_order.json매핑 수정 후 importer 재실행- 또는
card.html.j2를 직접 수동 편집 (.bak으로 롤백 가능)
Verification matrix (PR 생성 전 모두 통과)
| Check | Command | Expected |
|---|---|---|
| design_importer tests | cd insta-lab && pytest tests/test_design_importer.py -v |
8 PASS |
| card_renderer regression | cd insta-lab && pytest tests/test_card_renderer.py -v |
2 PASS (기존 1 + 신규 1) |
| insta-lab 전체 | cd insta-lab && pytest -v |
모두 PASS, 0 failures |
| CLI help | cd insta-lab && python -m app.design_importer --help |
argparse usage 출력 |
| docker-compose | python -c "import yaml; yaml.safe_load(open('docker-compose.yml', encoding='utf-8'))" |
YAML OK |
| 잔여 placeholder grep | grep -n "TODO|TBD" insta-lab/app/design_importer.py |
0 매치 |