Files
web-page-backend/insta-lab/app/design_importer.py
gahusb 6895e2f8dc fix(insta-lab): design_importer dimension 검증을 4:5 비율로 완화
운영에서 사용자 디자인이 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).
2026-05-18 00:42:30 +09:00

310 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""사용자 디자인 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()