# insta-lab Design Importer — Claude Vision으로 이미지 디자인 → Jinja HTML 자동 생성

작성일: 2026-05-17
상태: 사용자 승인 대기 → writing-plans 진입 예정
연관 문서: `2026-05-15-insta-agent-design.md`, `2026-05-16-insta-trends-design.md`, `feedback_external_data_sources.md`

---

## 1. 목적·배경

insta-lab의 카드 렌더는 현재 `templates/default/card.html.j2` 한 골격만 사용 (단순 그라데이션 + Noto Sans KR). 사용자가 직접 디자인한 10장 카드 이미지(`templates/minimal/pages/insta_card_*.png`)를 이미 NAS에 배포한 상태인데, 이 이미지들이 카드 렌더에 반영되지 않음.

이 spec은 사용자가 만든 디자인 이미지를 **카드 렌더 파이프라인에 통합**하는 메커니즘을 정의한다. 핵심은 Claude Vision으로 10장 PNG를 분석해 페이지별 텍스트 영역·색·폰트·레이아웃을 도출하고, 이를 그대로 모방한 단일 Jinja2 HTML 파일을 자동 생성하는 것이다. 생성된 HTML은 동적 카피(headline, body, cta)를 사용자 디자인 위에 layer로 얹어 일관된 시각 + 동적 텍스트를 동시에 확보한다.

---

## 2. 스코프

### 포함

- 신규 백엔드 모듈 `insta-lab/app/design_importer.py` — 10장 PNG → Claude Sonnet Vision → `card.html.j2` 생성
- CLI 진입점 `python -m app.design_importer <theme_name>` (운영자가 한 번씩 실행)
- 환경변수 `INSTA_DEFAULT_THEME` 신규 (default="default") — 모든 슬레이트가 이 theme 사용
- `card_renderer.render_slate`에 theme 전달 (기존 `template` 인자 활용, 호출자만 변경)
- pytest: Vision 호출 mock + 출력 HTML 파싱 검증

### 제외 (후속)

- API endpoint `POST /api/insta/templates/import` — UI에서 트리거 가능
- `card_slates.theme` 컬럼 — 슬레이트별 다른 theme 선택
- 다중 theme 비교/A·B 테스트 UI
- 자동 theme 추천 (트렌드 카테고리별 다른 theme)

---

## 3. 데이터·디렉토리 구조

```
insta-lab/app/templates/
├── default/                          # 기존 — 폴백 / 초기 골격
│   ├── card.html.j2
│   └── .gitkeep
└── <theme_name>/                     # 사용자 디자인 1세트 (반복 가능)
    ├── pages/                        # 사용자가 git commit으로 업로드
    │   ├── insta_card_start.png      # 의미 있는 이름 권장 (Claude가 페이지 의도 파악에 활용)
    │   ├── insta_card_keyword.png
    │   ├── ... (총 10장)
    │   └── README.md (선택, 디자인 의도 메모)
    └── card.html.j2                  # design_importer가 자동 생성
```

**파일명 컨벤션**:
- 페이지 번호 매핑은 사용자가 제공하지 않음. design_importer가 다음 순서로 자동 매핑:
  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` 추가하고 재실행

---

## 4. 핵심 모듈 `design_importer.py`

### 4-1. Public API

```python
def import_design_theme(theme_name: str) -> dict:
    """templates/<theme>/pages/*.png 10장 → Claude Sonnet Vision → card.html.j2 생성.

    Returns:
        {
            "theme_name": str,
            "html_path": str,
            "page_mapping": {filename: page_no, ...},
            "analysis_summary": str,  # Claude가 도출한 디자인 분석 짧은 요약
            "tokens_used": int,
        }

    Raises:
        ValueError: pages/ 폴더에 PNG 10장 미만이거나 매핑 실패
        anthropic.APIError: Vision 호출 실패 (retry 1회 후)
    """
```

### 4-2. 처리 흐름

1. `templates/<theme>/pages/` 폴더 스캔 → PNG 10장 검증 (10장 정확히)
2. 파일명 → 페이지 매핑 결정 (3장 규칙 + 선택적 `_order.json` override)
3. 각 PNG base64 인코딩
4. Claude Sonnet(`claude-sonnet-4-6`) Vision 호출 1회:
   - 시스템 프롬프트: 디자이너 역할 + 출력 형식 명세
   - 사용자 메시지: 10장 이미지 + 페이지 매핑 정보 + 변수 명세 (`page_no`, `headline`, `body`, `cta`)
   - 출력 요청: 단일 Jinja2 HTML 파일 (page_no 분기 + 텍스트 영역 절대 위치 CSS + `background-image: url('pages/{{filename}}')`)
5. 응답 HTML 파싱 + Jinja Environment로 sanity render 1회 (분기·문법 검증)
6. `templates/<theme>/card.html.j2`에 저장
7. dict 반환

### 4-3. Vision 프롬프트 스킴 (placeholder 텍스트 마스킹 포함)

**중요 제약**: 사용자 PNG에는 **placeholder 텍스트가 이미 박혀있다**. 동적 카피(headline, body, cta)로 교체해야 하며 원본 placeholder 텍스트는 보이면 안 된다. 따라서 단순히 텍스트 layer를 얹는 것만으로는 부족하고, 원본 텍스트가 있던 영역을 그 영역의 **배경색으로 덮은 후** 그 위에 새 텍스트를 그려야 한다.

시스템 프롬프트 (요약):
```
너는 인스타그램 카드 뉴스 디자인을 모방하는 프론트엔드 디자이너다.
입력: 10장의 카드 디자인 이미지 (각 1080×1350, placeholder 텍스트 포함) + 페이지 번호 매핑.
출력: 단일 Jinja2 HTML 파일.

요구사항:
- 컨테이너 width 1080px, height 1350px
- background-image로 해당 페이지 PNG를 url('pages/{{filename}}')로 로드
- 각 페이지에서 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 (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 의도)
- 백업: 기존 HTML이 있으면 `card.html.j2.bak.YYYYMMDD-HHMMSS`로 rename 후 새 파일 작성
- 분석 결과 캐시 X (재실행할 때마다 최신 결과)

---

## 5. CLI 진입점

```bash
# 컨테이너 내부에서 실행
docker exec insta-lab python -m app.design_importer <theme_name>

# 결과 stdout (예시)
{
  "theme_name": "minimal",
  "html_path": "/app/app/templates/minimal/card.html.j2",
  "page_mapping": {
    "insta_card_start.png": 1,
    "insta_card_keyword.png": 2,
    ...
    "insta_card_cta.png": 10
  },
  "analysis_summary": "미니멀 카드 — 흰 배경 + 검정 헤드라인 + 회색 본문...",
  "tokens_used": 15234
}
```

`__main__` 가드: argparse로 `theme_name` 위치 인자 + `--force` (기존 HTML 백업 없이 덮어쓰기) 옵션. 실패 시 exit 1.

---

## 6. 카드 렌더 통합

### 6-1. 환경변수 추가 (`config.py`)

```python
INSTA_DEFAULT_THEME = os.getenv("INSTA_DEFAULT_THEME", "default")
```

### 6-2. `main.py:_bg_create_slate` 호출 변경

기존:
```python
await card_renderer.render_slate(sid)
```

신규:
```python
template_path = f"{INSTA_DEFAULT_THEME}/card.html.j2"
await card_renderer.render_slate(sid, template=template_path)
```

`card_renderer.render_slate`는 이미 `template` 인자를 받으며 default 값이 `"default/card.html.j2"`. 변경 없음.

### 6-3. `card_renderer` 폴백 가드

`render_slate` 시작부에 template 파일 존재 확인 추가:
```python
template_full = Path(_resolve_template_dir()) / template
if not template_full.exists():
    logger.warning("Template %s 없음, default로 폴백", template)
    template = "default/card.html.j2"
```

→ env에 `INSTA_DEFAULT_THEME=minimal` 설정했는데 `minimal/card.html.j2`가 아직 import 안 됐으면 자동 default 폴백.

### 6-4. 운영 활성화 절차

```bash
# 1. 이미지 commit + push (이미 완료 — minimal/pages/ 10장)
# 2. NAS 머지 후 design_importer 실행
docker exec insta-lab python -m app.design_importer minimal

# 3. NAS .env에 추가
echo "INSTA_DEFAULT_THEME=minimal" >> /volume1/docker/webpage/.env

# 4. 컨테이너 재시작 (env 재로드)
docker compose restart insta-lab
```

---

## 7. 에러 처리

| 상황 | 처리 |
|------|------|
| `pages/` 폴더 없음 또는 PNG 10장 미만 | ValueError + 어떤 파일이 빠졌는지 명시. 모든 이미지가 1080×1350인지도 검증 (Pillow로 size 체크) |
| Vision 호출 실패 (network, rate limit) | retry 1회 (5초 대기), 그래도 실패 시 anthropic.APIError 전파 |
| Vision 응답이 HTML이 아님 / Jinja 문법 깨짐 | Jinja Environment로 sanity render 시도 → 실패 시 raw 응답을 `card.html.j2.error.txt`에 저장 + ValueError 전파 (운영자가 수동 수정 가능) |
| Vision 응답이 max_tokens(16K) 초과 → 잘림 | 응답 끝이 닫힌 `</html>` 없으면 잘렸다고 판단, max_tokens 24K로 retry 1회 |
| 이미지 base64 인코딩 실패 (파일 깨짐) | 어느 파일이 문제인지 로그 + ValueError |
| `_order.json` 형식 깨짐 | log warning + 자동 매핑 규칙으로 폴백 |

---

## 8. 테스트

### `insta-lab/tests/test_design_importer.py` (~6 케이스)

1. `test_auto_page_mapping_with_cover_and_cta`: 의미 이름 파일 10개 → cover→1, cta→10, 나머지 알파벳 순
2. `test_explicit_order_json_overrides`: `_order.json` 있으면 그것 우선
3. `test_validates_exactly_ten_pngs`: 9장 또는 11장이면 ValueError
4. `test_validates_image_dimensions`: 1080×1350 아닌 이미지 있으면 ValueError + 어떤 파일인지
5. `test_import_generates_html_via_mocked_claude`: Anthropic Vision mock, 응답 HTML이 Jinja 렌더 가능한 형식인지 검증
6. `test_import_falls_back_on_jinja_parse_failure`: mock이 깨진 HTML 반환 시 ValueError + `.error.txt` 저장

### `insta-lab/tests/test_card_renderer.py` (기존, 보강 1개)

7. `test_render_falls_back_to_default_when_theme_html_missing`: `template="ghost/card.html.j2"` 지정 시 파일 없어도 default로 폴백 + 정상 PNG 생성

---

## 9. 운영 영향

| 항목 | 영향 |
|------|------|
| Anthropic 토큰 비용 | +1회당 ~15K 토큰 (이미지 10장 × ~1K + 프롬프트 + HTML 출력). Claude Sonnet 단가 기준 ~$0.05/import. 자주 실행 X |
| 빌드 시간 | 영향 없음 (코드 변경만, 의존성 추가 없음) |
| 카드 렌더 시간 | 영향 없음 (Playwright는 background-image까지 wait_until="networkidle"로 처리) |
| 디스크 | 사용자 디자인 PNG 12MB (이미 push됨) + 자동 생성 HTML ~10KB |
| 운영 중 카드 품질 | env `INSTA_DEFAULT_THEME=minimal` 설정 후 다음 슬레이트부터 사용자 디자인 적용. 기존 슬레이트는 default 그대로 |

---

## 10. 마이그레이션 절차

배포 후 사용자가 운영 NAS에서 수동 실행:

1. PR 머지 → webhook으로 `design_importer.py` 코드 배포 + minimal/ 디렉토리는 이미 배포됨
2. SSH NAS:
   ```bash
   docker exec insta-lab python -m app.design_importer minimal
   ```
3. 결과 JSON에서 `html_path`와 `page_mapping` 확인. 매핑이 의도와 다르면 `pages/_order.json`로 override 후 재실행
4. `.env`에 `INSTA_DEFAULT_THEME=minimal` 추가
5. `docker compose restart insta-lab` (env 재로드)
6. 새 슬레이트 1개 만들어서 시각 검증 (Insta 페이지 Trends 탭 또는 수동 트리거)

생성된 `card.html.j2`가 마음에 안 들면:
- `pages/_order.json`으로 페이지 순서 조정 후 importer 재실행
- 또는 자동 생성 HTML을 사용자가 직접 수정 (importer 재실행 안 함)
- 백업본 `card.html.j2.bak.YYYYMMDD-HHMMSS`로 롤백 가능

---

## 11. 완료 정의

- [ ] `insta-lab/app/design_importer.py` 작성, CLI `python -m app.design_importer` 작동
- [ ] `_resolve_page_mapping` + 의미 이름 기반 자동 매핑 + `_order.json` override
- [ ] Vision 호출 mock 기반 pytest 6 케이스 PASS
- [ ] `card_renderer.render_slate`에 theme 폴백 가드 추가, 테스트 1 케이스 PASS
- [ ] `insta-lab/app/config.py`에 `INSTA_DEFAULT_THEME` 추가
- [ ] `insta-lab/app/main.py:_bg_create_slate`가 `INSTA_DEFAULT_THEME` 사용
- [ ] `docker-compose.yml` insta-lab 환경변수에 `INSTA_DEFAULT_THEME=${INSTA_DEFAULT_THEME:-default}` 추가
- [ ] CLAUDE.md 9.x insta-lab 섹션에 design_importer + INSTA_DEFAULT_THEME 항목 추가
- [ ] 운영 NAS에서 `docker exec insta-lab python -m app.design_importer minimal` 실행 → `card.html.j2` 생성 확인
- [ ] `.env` 설정 + 새 슬레이트 1개 생성 → 시각적으로 minimal 디자인 반영 확인
