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>
|
CLI (이 phase 이후 추가): python -m app.design_importer <theme_name>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import datetime
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from pathlib import Path
|
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 PIL import Image
|
||||||
|
|
||||||
|
from .config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL_SONNET
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"_resolve_page_mapping",
|
"_resolve_page_mapping",
|
||||||
"_validate_images",
|
"_validate_images",
|
||||||
|
"_call_vision",
|
||||||
|
"_validate_html_template",
|
||||||
|
"import_design_theme",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 페이지 1 (커버) 키워드 우선순위 — 먼저 매치된 키워드를 가진 첫 파일만 page 1
|
# 페이지 1 (커버) 키워드 우선순위 — 먼저 매치된 키워드를 가진 첫 파일만 page 1
|
||||||
@@ -108,3 +118,144 @@ def _validate_images(pages_dir: Path) -> None:
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"모든 카드 디자인은 1080x1350이어야 함. 잘못된 파일: {msg}"
|
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"],
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,3 +101,68 @@ def test_validate_images_rejects_wrong_dimensions(tmp_theme):
|
|||||||
_make_png(pages / f"insta_card_{i:02d}.png", size)
|
_make_png(pages / f"insta_card_{i:02d}.png", size)
|
||||||
with pytest.raises(ValueError, match="1080x1350"):
|
with pytest.raises(ValueError, match="1080x1350"):
|
||||||
design_importer._validate_images(pages)
|
design_importer._validate_images(pages)
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user