"""사용자 디자인 PNG 10장 → Claude Sonnet Vision → Jinja card.html.j2 자동 생성. CLI (이 phase 이후 추가): python -m app.design_importer """ 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//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 에