feat(insta-lab): import_design_theme — Vision 호출 + Jinja sanity + 백업 저장
This commit is contained in:
@@ -3,18 +3,28 @@
|
||||
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 Dict, List
|
||||
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
|
||||
@@ -108,3 +118,144 @@ def _validate_images(pages_dir: Path) -> None:
|
||||
raise ValueError(
|
||||
f"모든 카드 디자인은 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"],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user