10 Commits

Author SHA1 Message Date
2270072fe5 docs(claude-md): insta-lab section에 design_importer + INSTA_DEFAULT_THEME 항목 2026-05-18 00:21:24 +09:00
15f24dc890 feat(insta-lab): INSTA_DEFAULT_THEME env 통합 (config + main + compose) 2026-05-18 00:19:47 +09:00
2915f2b697 feat(insta-lab): card_renderer theme 폴백 가드 (HTML 없으면 default) 2026-05-18 00:18:44 +09:00
7640a2b4a8 feat(insta-lab): design_importer CLI entrypoint (python -m app.design_importer) 2026-05-18 00:16:34 +09:00
427522bd1a feat(insta-lab): import_design_theme — Vision 호출 + Jinja sanity + 백업 저장 2026-05-18 00:14:59 +09:00
0bddc5c607 feat(insta-lab): design_importer image dimension 검증 (1080x1350) 2026-05-18 00:10:44 +09:00
54c677f75a feat(insta-lab): design_importer page mapping (자동 + _order.json override) 2026-05-18 00:10:02 +09:00
01bb837525 docs(insta-lab): design_importer — placeholder 텍스트 마스킹 요구 추가
사용자 디자인 PNG에 placeholder 텍스트가 이미 박혀있는 경우 대응.
Vision system prompt에 두 layer 요구:
(a) 마스킹 박스: placeholder 영역 좌표 + 주변 배경색으로 덮음
(b) 동적 텍스트 layer: 동일 좌표에 새 카피, 원본 폰트 스타일 모방
+ overflow:hidden으로 긴 카피가 박스 밖 새지 않게.

spec 4-3 + plan Task 3 step 3 동시 패치.
2026-05-17 20:54:00 +09:00
8ceb0af736 docs(insta-lab): design_importer implementation plan (8 TDD tasks)
페이지 매핑 → 이미지 검증 → Vision 호출 → Jinja sanity → 백업 저장 →
CLI → card_renderer 폴백 → env/compose/CLAUDE.md 통합. Vision은
모든 테스트에서 mock, 실제 호출은 운영 NAS에서 수동 (~$0.05/import).
2026-05-17 20:52:28 +09:00
ecf1f643b2 docs(insta-lab): design_importer spec — 파일명 매핑 충돌 처리 명시 (셀프 리뷰) 2026-05-17 20:47:26 +09:00
10 changed files with 1548 additions and 12 deletions

View File

@@ -467,6 +467,7 @@ docker compose up -d
- `ANTHROPIC_MODEL_HAIKU` / `ANTHROPIC_MODEL_SONNET`: 모델명 오버라이드
- `INSTA_DATA_PATH`: SQLite + 카드 PNG 저장 경로 (기본 `/app/data`)
- `CARD_TEMPLATE_DIR`: HTML 템플릿 디렉토리 (기본 `/app/app/templates`)
- `INSTA_DEFAULT_THEME`: 카드 렌더에 사용할 theme 디렉토리명 (기본 `default`). `templates/<theme>/card.html.j2`가 없으면 자동으로 default 폴백
- `NEWS_PER_CATEGORY` / `KEYWORDS_PER_CATEGORY`: 수집·추출 limit 튜닝
**카테고리 시드 키워드**
@@ -482,6 +483,17 @@ docker compose up -d
- 09:30 매일 — `_run_insta_schedule` (insta_pipeline) → 뉴스 수집 → 키워드 추출 → 텔레그램 후보 푸시
- `agent_config.custom_config.auto_select=True`이면 카테고리당 1위 키워드 자동 슬레이트 생성·발송
**디자인 import (사용자 디자인 PNG → Claude Vision → Jinja HTML 자동 생성)**
- `insta-lab/app/templates/<theme>/pages/*.png` (10장, 1080×1350, placeholder 텍스트 박혀있는 형태) → Claude Sonnet Vision → `templates/<theme>/card.html.j2` 자동 생성
- CLI: `docker exec insta-lab python -m app.design_importer <theme>`
- 파일명 자동 매핑: `cover`/`start`/`intro` → page 1, `cta`/`outro`/`finish`/`end` → page 10, 나머지 알파벳 순 → page 2~9
- 매핑 override: `pages/_order.json``{filename: page_no}` 명시 (10장 + page 1~10 완전 매핑일 때만 적용)
- Vision prompt에 placeholder 마스킹 요구 포함 (2-layer: 마스킹 박스 + 동적 텍스트 layer)
- 기존 HTML 자동 백업 (`card.html.j2.bak.YYYYMMDD-HHMMSS`)
- Jinja 문법 깨진 응답은 `card.html.j2.error.txt`로 보존 + ValueError
- 활성화: NAS `.env``INSTA_DEFAULT_THEME=<theme>` 추가 + `docker compose restart insta-lab`
- 토큰 비용: 1회당 ~15K tokens (~$0.05 Sonnet 기준)
**insta-lab API 목록**
| 메서드 | 경로 | 설명 |

View File

@@ -103,6 +103,7 @@ services:
- YOUTUBE_DATA_API_KEY=${YOUTUBE_DATA_API_KEY:-}
- INSTA_DATA_PATH=/app/data
- CARD_TEMPLATE_DIR=/app/app/templates
- INSTA_DEFAULT_THEME=${INSTA_DEFAULT_THEME:-default}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes:
- ${RUNTIME_PATH}/data/insta:/app/data

File diff suppressed because it is too large Load Diff

View File

@@ -51,10 +51,12 @@ insta-lab/app/templates/
**파일명 컨벤션**:
- 페이지 번호 매핑은 사용자가 제공하지 않음. design_importer가 다음 순서로 자동 매핑:
1. 파일명에 `cover`/`start`/`intro` 단어 포함 → page 1 (커버)
2. 파일명에 `cta`/`outro`/`finish`/`end` 단어 포함 → page 10 (CTA)
3. 나머지 8장은 알파벳 정렬 순으로 page 2~9 (본문)
- 사용자가 매핑을 override하려면 `pages/_order.json` 파일에 `{"insta_card_start.png": 1, ...}` 명시 가능 (선택)
1. 파일명에 `cover` > `start` > `intro` 키워드 포함 (우선순위 순서) → page 1 (커버). 여러 파일이 매치되면 가장 앞 키워드를 가진 파일만 선택, 나머지는 본문 풀로
2. 파일명에 `cta` > `outro` > `finish` > `end` 키워드 포함 (우선순위 순서) → page 10. 동일하게 첫 매치만 page 10, 나머지는 본문 풀로
3. 남은 8장은 알파벳 정렬 순으로 page 2~9 (본문)
- **현재 운영 케이스**: `insta_card_start.png`(start=1순위) → page 1, `insta_card_cta.png`(cta=1순위) → page 10, `insta_card_finish.png`는 finish=3순위인데 cta가 이미 page 10이므로 본문 풀로 떨어져 알파벳 순에 따라 page 2~9 어딘가 배치됨
- 사용자가 매핑을 override하려면 `pages/_order.json` 파일에 `{"insta_card_start.png": 1, "insta_card_finish.png": 10, ...}` 명시 가능 (충돌·의도 명시 시 강력 권장)
- 매핑이 의도와 어긋나면 importer 실행 결과 dict의 `page_mapping` 필드로 확인 후 `_order.json` 추가하고 재실행
---
@@ -94,26 +96,43 @@ def import_design_theme(theme_name: str) -> dict:
6. `templates/<theme>/card.html.j2`에 저장
7. dict 반환
### 4-3. Vision 프롬프트 스킴
### 4-3. Vision 프롬프트 스킴 (placeholder 텍스트 마스킹 포함)
**중요 제약**: 사용자 PNG에는 **placeholder 텍스트가 이미 박혀있다**. 동적 카피(headline, body, cta)로 교체해야 하며 원본 placeholder 텍스트는 보이면 안 된다. 따라서 단순히 텍스트 layer를 얹는 것만으로는 부족하고, 원본 텍스트가 있던 영역을 그 영역의 **배경색으로 덮은 후** 그 위에 새 텍스트를 그려야 한다.
시스템 프롬프트 (요약):
```
너는 인스타그램 카드 뉴스 디자인을 모방하는 프론트엔드 디자이너다.
입력: 10장의 카드 디자인 이미지 (각 1080×1350) + 페이지 번호 매핑.
입력: 10장의 카드 디자인 이미지 (각 1080×1350, placeholder 텍스트 포함) + 페이지 번호 매핑.
출력: 단일 Jinja2 HTML 파일.
요구사항:
- 컨테이너 width 1080px, height 1350px
- background-image로 해당 페이지 PNG를 url('pages/{{filename}}')로 로드
- 그 위에 텍스트 layer (headline, body, cta) — 각 페이지의 원본 디자인에서
텍스트가 있던 위치·크기·색을 그대로 모방. 비어 있는 디자인 영역은 layer 위치 추정
- 각 페이지에서 placeholder 텍스트가 있는 영역을 식별하고, 다음 두 layer를 그 위에 그린다:
(a) 마스킹 박스: position: absolute로 텍스트 영역과 같은 좌표·크기.
background는 PNG의 그 영역 주변 픽셀 색 (보통 카드 배경색)에서 추출.
placeholder가 완전히 가려지도록 padding 8px 정도 여유.
(b) 동적 텍스트 layer: 마스킹 박스와 동일 좌표.
font-size·font-weight·color는 원본 placeholder 폰트 스타일을 그대로 모방.
`{{ headline }}`, `{{ body }}`, `{{ cta }}` (page_no=10에서만) Jinja 변수 사용.
- 페이지 종류별 영역 추정:
· page 1 (cover): 메인 헤드라인 1개 영역. 보통 화면 상단 1/3 또는 중앙
· page 2~9 (body): 헤드라인 1개 + 본문 1개 영역 (보통 헤드라인 상단, 본문 그 아래)
· page 10 (cta): 헤드라인 1개 + 본문 1개 + CTA 강조 텍스트 1개 영역
- page_no 1~10 분기: {% if page_no == N %}...{% endif %} 구조
- 폰트는 Noto Sans KR (기존 default 템플릿과 동일)
- 출력은 HTML 본문만 (```html 코드펜스 금지)
- 폰트는 Noto Sans KR (Google Fonts CDN), letter-spacing -0.02em
- 텍스트 영역은 word-wrap: break-word + overflow: hidden으로 길이 초과 시도 마스킹 박스 밖으로 새지 않게
- 출력은 <!DOCTYPE html>로 시작하는 완전한 HTML 본문만 (```html 코드펜스·설명 텍스트 금지)
```
사용자 메시지에 각 이미지 + filename + page_no 매핑 포함.
**시각 품질 보장 절차** (importer 운영 후 사용자 검증):
1. 첫 import 후 1개 슬레이트 생성해서 PNG 10장 육안 확인
2. placeholder 텍스트가 비치거나 마스킹 박스가 어색하면 — `card.html.j2`를 직접 수정해서 영역 좌표·색 fine-tune (백업 자동 보존)
3. 새 디자인을 import할 일 있을 때까지는 수동 수정본 그대로 사용
### 4-4. 캐시 / 재실행 정책
- 이미 `card.html.j2`가 존재하면 덮어쓰기 (사용자 명시적 재import 의도)

View File

@@ -6,6 +6,7 @@ import json
import logging
import os
import tempfile
from pathlib import Path
from typing import List
from jinja2 import Environment, FileSystemLoader, select_autoescape
@@ -67,6 +68,13 @@ async def render_slate(slate_id: int, template: str = "default/card.html.j2") ->
if not slate:
raise ValueError(f"slate {slate_id} not found")
env = _env()
# template 파일이 없으면 default로 폴백 (INSTA_DEFAULT_THEME가 import 안 된 theme이면 안전)
template_full = Path(_resolve_template_dir()) / template
if not template_full.exists():
logger.warning("Template '%s' 없음 → 'default/card.html.j2'로 폴백", template)
template = "default/card.html.j2"
tmpl = env.get_template(template)
pages = _build_pages(slate)
out_dir = _slate_dir(slate_id)

View File

@@ -11,6 +11,7 @@ INSTA_DATA_PATH = os.getenv("INSTA_DATA_PATH", "/app/data")
DB_PATH = os.path.join(INSTA_DATA_PATH, "insta.db")
CARDS_DIR = os.path.join(INSTA_DATA_PATH, "insta_cards")
CARD_TEMPLATE_DIR = os.getenv("CARD_TEMPLATE_DIR", "/app/app/templates")
INSTA_DEFAULT_THEME = os.getenv("INSTA_DEFAULT_THEME", "default")
CORS_ALLOW_ORIGINS = os.getenv(
"CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080"

View File

@@ -0,0 +1,296 @@
"""사용자 디자인 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
def _validate_images(pages_dir: Path) -> None:
"""모든 PNG가 정확히 1080×1350인지 검증. 다르면 ValueError.
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:
if img.size != _EXPECTED_SIZE:
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"모든 카드 디자인은 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()

View File

@@ -14,6 +14,7 @@ from pydantic import BaseModel
from .config import (
CORS_ALLOW_ORIGINS, NAVER_CLIENT_ID, ANTHROPIC_API_KEY,
INSTA_DATA_PATH, DB_PATH, DEFAULT_CATEGORY_SEEDS, KEYWORDS_PER_CATEGORY,
INSTA_DEFAULT_THEME,
)
from . import db, news_collector, keyword_extractor, card_writer, card_renderer, trend_collector
@@ -146,7 +147,7 @@ async def _bg_create_slate(task_id: str, keyword: str, category: str, keyword_id
db.update_task(task_id, "processing", 30, "카피 생성 중")
sid = card_writer.write_slate(keyword=keyword, category=category)
db.update_task(task_id, "processing", 70, "카드 렌더 중")
await card_renderer.render_slate(sid)
await card_renderer.render_slate(sid, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")
db.update_slate_status(sid, "rendered")
if keyword_id:
db.mark_keyword_used(keyword_id)
@@ -186,7 +187,7 @@ def get_slate(slate_id: int):
async def _bg_render(task_id: str, slate_id: int):
try:
db.update_task(task_id, "processing", 30, "재렌더 중")
await card_renderer.render_slate(slate_id)
await card_renderer.render_slate(slate_id, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")
db.update_slate_status(slate_id, "rendered")
db.update_task(task_id, "succeeded", 100, "완료", result_id=slate_id)
except Exception as e:

View File

@@ -46,3 +46,14 @@ async def test_render_slate_produces_ten_pngs(tmp_db_and_dirs):
db_module.update_slate_status(sid, "rendered")
assets = db_module.list_card_assets(sid)
assert {a["page_index"] for a in assets} == set(range(1, 11))
@pytest.mark.asyncio
async def test_render_falls_back_to_default_when_theme_html_missing(tmp_db_and_dirs):
"""존재하지 않는 theme HTML 지정 시 default/card.html.j2로 폴백, 정상 PNG 생성."""
sid = _seed_slate()
paths = await card_renderer.render_slate(sid, template="ghost_theme/card.html.j2")
assert len(paths) == 10
for p in paths:
assert os.path.exists(p)
assert os.path.getsize(p) > 1000

View File

@@ -0,0 +1,168 @@
"""design_importer 회귀 테스트."""
import json
import os
import tempfile
from pathlib import Path
import pytest
from app import design_importer
@pytest.fixture
def tmp_theme(tmp_path):
"""templates/<theme>/pages/ 구조를 가진 임시 디렉토리."""
pages = tmp_path / "minimal" / "pages"
pages.mkdir(parents=True)
return tmp_path / "minimal"
def _touch(pages_dir: Path, names: list[str]):
for n in names:
(pages_dir / n).write_bytes(b"") # 매핑 테스트는 dimension 검증 안 함
def test_auto_page_mapping_with_cover_and_cta(tmp_theme):
"""cover 키워드 → 1, cta 키워드 → 10, 나머지는 알파벳 순 2~9."""
_touch(tmp_theme / "pages", [
"insta_card_start.png", # start → page 1 (cover priority)
"insta_card_keyword.png",
"insta_card_highlight.png",
"insta_card_observation.png",
"insta_card_memo.png",
"insta_card_oneline.png",
"insta_card_checklist.png",
"insta_card_study.png",
"insta_card_cta.png", # cta → page 10
"insta_card_finish.png", # finish은 cta가 이미 채워 본문 풀로
])
mapping = design_importer._resolve_page_mapping(tmp_theme / "pages")
assert mapping["insta_card_start.png"] == 1
assert mapping["insta_card_cta.png"] == 10
# 본문 풀 (남은 8장)은 알파벳 정렬: checklist, finish, highlight, keyword, memo, observation, oneline, study
body_pages = {p: n for n, p in mapping.items() if 2 <= p <= 9}
assert body_pages[2] == "insta_card_checklist.png"
assert body_pages[3] == "insta_card_finish.png"
assert body_pages[9] == "insta_card_study.png"
assert set(mapping.values()) == set(range(1, 11))
def test_explicit_order_json_overrides_auto_mapping(tmp_theme):
"""_order.json이 있으면 자동 매핑보다 우선."""
pages = tmp_theme / "pages"
_touch(pages, [
"insta_card_start.png",
"insta_card_cta.png",
"insta_card_finish.png",
] + [f"insta_card_body{i}.png" for i in range(1, 8)])
(pages / "_order.json").write_text(json.dumps({
"insta_card_start.png": 1,
"insta_card_finish.png": 10, # cta 대신 finish를 page 10으로
"insta_card_cta.png": 5, # cta를 본문 한가운데로 강제
"insta_card_body1.png": 2,
"insta_card_body2.png": 3,
"insta_card_body3.png": 4,
"insta_card_body4.png": 6,
"insta_card_body5.png": 7,
"insta_card_body6.png": 8,
"insta_card_body7.png": 9,
}), encoding="utf-8")
mapping = design_importer._resolve_page_mapping(pages)
assert mapping["insta_card_finish.png"] == 10
assert mapping["insta_card_cta.png"] == 5
assert mapping["insta_card_start.png"] == 1
def test_validates_exactly_ten_pngs(tmp_theme):
"""PNG가 정확히 10장이 아니면 ValueError."""
_touch(tmp_theme / "pages", [f"x{i}.png" for i in range(5)]) # 5장
with pytest.raises(ValueError, match="10"):
design_importer._resolve_page_mapping(tmp_theme / "pages")
def _make_png(path: Path, size: tuple[int, int]) -> None:
"""size 픽셀의 단색 PNG를 생성."""
from PIL import Image
Image.new("RGB", size, color=(200, 200, 200)).save(path, format="PNG")
def test_validate_images_accepts_1080x1350(tmp_theme):
pages = tmp_theme / "pages"
for i in range(10):
_make_png(pages / f"insta_card_{i:02d}.png", (1080, 1350))
# 예외 없이 통과해야 함
design_importer._validate_images(pages)
def test_validate_images_rejects_wrong_dimensions(tmp_theme):
pages = tmp_theme / "pages"
for i in range(10):
size = (800, 800) if i == 5 else (1080, 1350)
_make_png(pages / f"insta_card_{i:02d}.png", size)
with pytest.raises(ValueError, match="1080x1350"):
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")