운영에서 사용자 디자인이 1122x1402로 작성됨. 1080x1350과 정확히 같은 4:5 종횡비지만 절대 사이즈만 다르므로 정확한 사이즈 강제는 과도. - 검증: 종횡비 4:5 (±2% tolerance). 1080x1350·1122x1402 등 동일 비율 높은 해상도 모두 통과. - Vision은 base64로 원본 분석 (사이즈 무관). - Playwright는 background-size: cover로 1080x1350 컨테이너에 자동 fit. - 비율이 깨지면 (예: 1024x1024 정사각) 여전히 reject. test_validate_images_accepts_higher_resolution_4_5_ratio 신규 (1 case).
310 lines
11 KiB
Python
310 lines
11 KiB
Python
"""사용자 디자인 PNG 10장 → Claude Sonnet Vision → Jinja card.html.j2 자동 생성.
|
||
|
||
CLI (이 phase 이후 추가): python -m app.design_importer <theme_name>
|
||
"""
|
||
|
||
import base64
|
||
import datetime
|
||
import json
|
||
import logging
|
||
import re
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Tuple
|
||
|
||
from anthropic import Anthropic
|
||
from jinja2 import BaseLoader, Environment, TemplateSyntaxError
|
||
from PIL import Image
|
||
|
||
from .config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL_SONNET
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
__all__ = [
|
||
"_resolve_page_mapping",
|
||
"_validate_images",
|
||
"_call_vision",
|
||
"_validate_html_template",
|
||
"import_design_theme",
|
||
]
|
||
|
||
# 페이지 1 (커버) 키워드 우선순위 — 먼저 매치된 키워드를 가진 첫 파일만 page 1
|
||
_COVER_KEYWORDS = ("cover", "start", "intro")
|
||
# 페이지 10 (CTA) 키워드 우선순위
|
||
_CTA_KEYWORDS = ("cta", "outro", "finish", "end")
|
||
|
||
# 인스타그램 카드 규격 (세로형 4:5 비율)
|
||
_EXPECTED_SIZE = (1080, 1350)
|
||
|
||
|
||
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_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: 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)
|
||
|
||
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
|
||
|
||
|
||
_EXPECTED_RATIO = 1080 / 1350 # 4:5 = 0.8
|
||
_RATIO_TOLERANCE = 0.02 # ±2% (1122/1402 ≈ 0.80028도 통과)
|
||
|
||
|
||
def _validate_images(pages_dir: Path) -> None:
|
||
"""모든 PNG가 4:5 종횡비(1080x1350 권장)에 가까운지 검증.
|
||
|
||
Vision은 base64로 원본을 분석하고 Playwright는 background-size: cover로
|
||
1080x1350 컨테이너에 fit하므로 절대 사이즈는 유연. 단 종횡비가 어긋나면
|
||
카드가 늘어나거나 잘리므로 ±2% 허용 범위 내에서만 통과.
|
||
|
||
early-exit 하지 않고 전체 파일을 검사한 뒤 한 메시지에 모아 raise.
|
||
"""
|
||
pages_dir = Path(pages_dir)
|
||
bad = []
|
||
for png_path in sorted(pages_dir.glob("*.png")):
|
||
with Image.open(png_path) as img:
|
||
w, h = img.size
|
||
if h == 0:
|
||
bad.append((png_path.name, img.size))
|
||
continue
|
||
ratio = w / h
|
||
if abs(ratio - _EXPECTED_RATIO) > _RATIO_TOLERANCE:
|
||
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"카드 디자인은 4:5 비율(1080x1350 권장)이어야 함. 잘못된 파일: {msg}"
|
||
)
|
||
|
||
|
||
# ── Vision 호출 + HTML 생성 ───────────────────────────────────────────────────
|
||
|
||
_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"],
|
||
}
|
||
|
||
|
||
# ── CLI entrypoint ───────────────────────────────────────────────────────────
|
||
|
||
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()
|