Files
web-page-backend/docs/superpowers/plans/2026-06-02-insta-cardnews-upgrade.md
gahusb 11f591e3d4 docs(plan): 인스타 카드뉴스 고도화 구현 plan (6 Phase, 3 repo, TDD)
Phase 1 디자인시스템 템플릿(web-ai+insta-lab) → 2 렌더 견고화(fonts.ready+
PNG검증) → 3 카피 글자수 가이드 → 4 zip 패키지 API → 5 web-ui 버튼 → 6 검증.
템플릿 sync open-item 해결(web-ai templates/ authoritative).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:20:30 +09:00

409 lines
22 KiB
Markdown
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.
# 인스타 카드뉴스 품질 고도화 + 업로드 친화 패키지 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 인스타 카드를 모던 미니멀 디자인 시스템으로 격상하고(렌더 견고화로 known-issue 해결), 완성 패키지를 zip으로 받아 인스타에 쉽게 업로드(반자동)할 수 있게 한다.
**Architecture:** 디자인 시스템 Jinja 템플릿(페이지 타입별 레이아웃)을 web-ai insta-render 워커(authoritative)와 insta-lab(참조 복사본)에 작성. 워커 `card_renderer.py``document.fonts.ready` 대기 + PNG 검증 추가. card_writer 프롬프트에 글자수 가이드. insta-lab에 zip 패키지 API + web-ui 다운로드 버튼. Graph API 미사용(반자동).
**Tech Stack:** Jinja2 + HTML/CSS, Playwright(Chromium), FastAPI, pytest / React+Vite(web-ui).
**Spec:** `docs/superpowers/specs/2026-06-02-insta-cardnews-upgrade-design.md`
**⚠️ 3 repo 작업** (커밋·배포 경로 다름):
- `web-backend/insta-lab` — git push → Gitea webhook 자동배포 (NAS)
- `web-ai/services/insta-render`**별도 repo(ai-trade.git), Windows 머신 구동** — 워커가 실제 렌더하는 authoritative 템플릿 위치
- `web-ui`**별도 repo**, `npm run release:nas` 수동 배포
---
## 검증된 컨텍스트
- 워커 렌더: `web-ai/services/insta-render/card_renderer.py``_build_pages(slate)`가 10 spec 생성(cover page_no=1 / body page_no=2~9 / cta page_no=10, 각 `page_type`/`headline`/`body`/`accent_color`/`cta`/`page_no`/`total_pages`). `CARD_TEMPLATE_DIR`(기본 `/app/templates`)에서 `{theme}/card.html.j2` 로드 → `page.goto(file://, networkidle)``screenshot(full_page=False)` @viewport 1080×1350.
- 워커 템플릿 실제 위치: `web-ai/services/insta-render/templates/default/card.html.j2` (현재 insta-lab과 동일한 55줄 기본형). **이게 렌더에 쓰이는 authoritative 파일.**
- 카피: `insta-lab/app/card_writer.py` `DEFAULT_PROMPT`(DB `slate_writer` 오버라이드 가능). 산출: cover_copy{headline,body,accent_color}/body_copies[8]{headline,body}/cta_copy{headline,body,cta}/suggested_caption/hashtags[].
- 슬레이트 PNG: 워커가 `INSTA_MEDIA_ROOT/{slate_id}/{page_no:02d}.png` 저장. NAS에서 `card_assets` 테이블 + `db.list_card_assets(slate_id)`(page_index + 파일경로)로 추적. `GET /api/insta/slates/{id}/assets/{page}`가 단일 PNG 서빙(파일경로 읽어 반환).
- 슬레이트 데이터: `db.get_card_slate(slate_id)` + `db.list_card_assets(slate_id)`. `GET /api/insta/slates/{id}`가 slate + assets 반환.
---
# Phase 1 — 모던 미니멀 디자인 시스템 템플릿 (web-ai authoritative + insta-lab 복사본)
## Task 1.1: 디자인 시스템 card.html.j2 작성
**Files:**
- Modify: `web-ai/services/insta-render/templates/default/card.html.j2` (**렌더 authoritative**)
- Modify: `web-backend/insta-lab/app/templates/default/card.html.j2` (참조 복사본 — 동일 내용 유지)
> 두 파일을 **동일 내용**으로 작성한다. 워커가 web-ai 쪽을 렌더하지만 insta-lab 복사본도 일관성 위해 갱신.
- [ ] **Step 1: 디자인 시스템 템플릿 작성** — 아래 전체 내용으로 두 파일을 교체:
```html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css');
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 1080px; height: 1350px; }
body {
font-family: 'Pretendard', 'Noto Sans KR', sans-serif;
background: #F7F7FA; color: #14171A;
-webkit-font-smoothing: antialiased;
}
.card {
position: relative; width: 1080px; height: 1350px; overflow: hidden;
padding: 96px 84px 72px;
display: flex; flex-direction: column;
background: #FFFFFF;
}
.accent-bar { position: absolute; top: 0; left: 0; width: 100%; height: 14px; background: {{ accent_color }}; }
.badge {
align-self: flex-start; padding: 10px 24px; border-radius: 999px;
background: {{ accent_color }}; color: #fff;
font-size: 30px; font-weight: 700; letter-spacing: -0.02em;
}
.idx { font-size: 120px; font-weight: 800; line-height: 1; color: {{ accent_color }}; letter-spacing: -0.04em; }
.content { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 36px; }
.headline {
font-weight: 800; line-height: 1.18; letter-spacing: -0.04em; color: #14171A;
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden;
}
.cover .headline { font-size: 104px; -webkit-line-clamp: 4; }
.body-page .headline { font-size: 76px; -webkit-line-clamp: 3; }
.cta .headline { font-size: 88px; -webkit-line-clamp: 3; }
.sub {
font-size: 42px; font-weight: 400; line-height: 1.5; color: #3A4047;
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; -webkit-line-clamp: 8;
white-space: pre-wrap;
}
.footer {
display: flex; justify-content: space-between; align-items: center;
font-size: 28px; color: #8A9099; font-weight: 600; margin-top: 40px;
}
.cta-pill {
align-self: flex-start; margin-top: 8px; padding: 18px 40px; border-radius: 16px;
background: {{ accent_color }}; color: #fff; font-size: 40px; font-weight: 700;
}
.progress { display: flex; gap: 10px; }
.progress i { width: 14px; height: 14px; border-radius: 50%; background: #D8DCE0; display: inline-block; }
.progress i.on { background: {{ accent_color }}; }
</style>
</head>
<body>
<div class="card {{ 'cover' if page_type=='cover' else ('cta' if page_type=='cta' else 'body-page') }}">
<div class="accent-bar"></div>
{% if page_type == 'cover' %}
<span class="badge">{{ category_label|default(headline[:0]) }}{{ '오늘의 이슈' if not category_label }}</span>
<div class="content">
<h1 class="headline">{{ headline }}</h1>
<p class="sub">{{ body }}</p>
</div>
{% elif page_type == 'cta' %}
<div class="content">
<h1 class="headline">{{ headline }}</h1>
<p class="sub">{{ body }}</p>
{% if cta %}<div class="cta-pill">{{ cta }}</div>{% endif %}
</div>
{% else %}
<span class="idx">{{ '%02d'|format(page_no - 1) }}</span>
<div class="content">
<h1 class="headline">{{ headline }}</h1>
<p class="sub">{{ body }}</p>
</div>
{% endif %}
<div class="footer">
{% if page_type == 'cover' or page_type == 'cta' %}
<span>{{ brand_handle|default('') }}</span><span>{{ page_no }} / {{ total_pages }}</span>
{% else %}
<div class="progress">{% for n in range(2, total_pages) %}<i class="{{ 'on' if n <= page_no }}"></i>{% endfor %}</div>
<span>{{ page_no }} / {{ total_pages }}</span>
{% endif %}
</div>
</div>
</body>
</html>
```
> 디자인 노트: 페이지 타입별 분기(cover 대형 헤드라인+서브+배지 / body 좌상단 인덱스 `01~08`(page_no-1)+헤드라인+본문+진행 점 / cta 요약+CTA pill). `-webkit-line-clamp`로 오버플로우 2차 방어(글자수 가이드가 1차). `accent_color`는 기존 데이터. `brand_handle`은 미설정 시 빈칸(추후 핸들 주입 가능). Pretendard CDN(@import) — Phase 2의 fonts.ready 대기와 짝.
- [ ] **Step 2: 렌더 스모크 확인 (web-ai)** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render && python -c "from jinja2 import Environment, FileSystemLoader; e=Environment(loader=FileSystemLoader('templates')); t=e.get_template('default/card.html.j2'); [print(pt, len(t.render(page_type=pt, page_no=n, total_pages=10, headline='테스트 헤드라인', body='본문 테스트입니다.', accent_color='#0F62FE', cta='팔로우')) > 0) for pt,n in [('cover',1),('body',3),('cta',10)]]"`
Expected: `True` 3줄 (3 페이지 타입 모두 렌더 예외 없음).
- [ ] **Step 3: Commit (2 repo 각각)**
```bash
# web-ai repo
cd /c/Users/jaeoh/Desktop/workspace/web-ai && git add services/insta-render/templates/default/card.html.j2 && git commit -m "feat(insta-render): 모던 미니멀 디자인 시스템 템플릿"
# insta-lab repo (참조 복사본)
cd /c/Users/jaeoh/Desktop/workspace/web-backend && git add insta-lab/app/templates/default/card.html.j2 && git commit -m "feat(insta-lab): default 템플릿 디자인 시스템 동기화(참조용)"
```
> 커밋 메시지 trailer 각각에 `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` 추가.
---
# Phase 2 — 렌더 견고화 (web-ai 워커, known-issue 해결)
## Task 2.1: fonts.ready 대기 + PNG 비어있음 검증
**Files:**
- Modify: `web-ai/services/insta-render/card_renderer.py` (`_render_slate_locked`)
- Test: `web-ai/services/insta-render/tests/test_worker.py` (또는 기존 테스트 파일에 추가)
- [ ] **Step 1: 실패 테스트**`tests/test_worker.py`에 추가 (실제 Chromium 렌더 + 검증). 워커 테스트 관례 확인 후 맞출 것; pytest-asyncio 사용 가정:
```python
import os
import pytest
from card_renderer import render_slate, init_browser, shutdown_browser
@pytest.mark.asyncio
async def test_render_produces_nonempty_1080x1350(tmp_path, monkeypatch):
monkeypatch.setattr("card_renderer.INSTA_MEDIA_ROOT", str(tmp_path))
await init_browser()
try:
slate = {
"cover_copy": {"headline": "헤드라인", "body": "서브", "accent_color": "#0F62FE"},
"body_copies": [{"headline": f"포인트{i}", "body": "본문"} for i in range(8)],
"cta_copy": {"headline": "요약", "body": "마무리", "cta": "팔로우"},
}
paths = await render_slate(slate, slate_id=99999)
assert len(paths) == 10
for p in paths:
assert os.path.getsize(p) > 1000 # 비어있지 않음
finally:
await shutdown_browser()
```
- [ ] **Step 2: 실패/현황 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render && python -m pytest tests/test_worker.py::test_render_produces_nonempty_1080x1350 -v`
Expected: 현재 코드로도 통과할 수 있으나(렌더 자체는 동작), 폰트/검증 보강 전이므로 FAIL이 아니면 다음 Step에서 검증 로직 추가가 의미를 갖도록 진행. (Playwright/Chromium 미설치 환경이면 `playwright install chromium` 필요 — 안 되면 DONE_WITH_CONCERNS로 보고)
- [ ] **Step 3: card_renderer 보강**`_render_slate_locked`의 페이지 루프에서 `page.goto` 직후·`screenshot` 직전에 폰트 대기 추가, screenshot 후 비어있음 검증:
```python
try:
await page.goto(f"file://{html_path}", wait_until="networkidle")
await page.evaluate("document.fonts.ready") # 웹폰트 로딩 완료까지 대기
out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png")
await page.screenshot(path=out_path, full_page=False, omit_background=False)
if os.path.getsize(out_path) < 1000: # 빈/깨진 PNG 방어
raise RuntimeError(f"rendered PNG too small: {out_path}")
paths.append(out_path)
finally:
...
```
- [ ] **Step 4: 통과 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render && python -m pytest tests/test_worker.py -v` Expected: PASS
- [ ] **Step 5: Commit (web-ai repo)**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai && git add services/insta-render/card_renderer.py services/insta-render/tests/test_worker.py && git commit -m "fix(insta-render): fonts.ready 대기 + PNG 비어있음 검증 (렌더 known-issue 해결)"
```
---
# Phase 3 — 카피 글자수 가이드 (insta-lab)
## Task 3.1: card_writer 프롬프트에 글자수 상한 추가
**Files:**
- Modify: `web-backend/insta-lab/app/card_writer.py` (`DEFAULT_PROMPT`)
- Test: `web-backend/insta-lab/app/test_card_writer_prompt.py` (NEW)
- [ ] **Step 1: 실패 테스트**
`insta-lab/app/test_card_writer_prompt.py`:
```python
from app import card_writer
def test_default_prompt_has_length_guidance():
p = card_writer.DEFAULT_PROMPT
# 글자수 가이드가 프롬프트에 포함됐는지
assert "22자" in p and "120자" in p
# 포맷 placeholder는 유지
assert "{category}" in p and "{keyword}" in p and "{articles}" in p
```
- [ ] **Step 2: 실패 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/test_card_writer_prompt.py -v` Expected: FAIL
- [ ] **Step 3: DEFAULT_PROMPT에 가이드 추가**`DEFAULT_PROMPT` 문자열의 JSON 스키마 안내 뒤(닫는 `}}` 다음)에 글자수 가이드 문단 추가:
```python
DEFAULT_PROMPT = """너는 인스타그램 카드 뉴스 카피라이터다.
카테고리: {category}
키워드: {keyword}
참고 기사:
{articles}
10페이지 인스타 카드용 카피를 다음 JSON 한 객체로만 출력해라 (코드펜스 금지):
{{
"cover_copy": {{"headline": "<훅 한 줄>", "body": "<서브카피 1~2줄>", "accent_color": "#hex"}},
"body_copies": [
{{"headline": "<포인트 헤드라인>", "body": "<2~4문장 본문>"}},
... (총 8개)
],
"cta_copy": {{"headline": "<요약 한 줄>", "body": "<마무리 1~2줄>", "cta": "팔로우/저장 등"}},
"suggested_caption": "<인스타 캡션 본문>",
"hashtags": ["#태그1", "#태그2", ...]
}}
[글자수 제약 — 카드 디자인 박스에 맞게 반드시 준수]
- cover_copy.headline: 22자 이내
- body_copies[].headline: 26자 이내
- body_copies[].body: 120자 이내 (2~4문장)
- cta_copy.headline: 22자 이내
초과하면 잘리므로 간결하고 임팩트 있게 작성한다.
"""
```
- [ ] **Step 4: 통과 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/test_card_writer_prompt.py -v` Expected: PASS
- [ ] **Step 5: Commit (insta-lab)**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-backend && git add insta-lab/app/card_writer.py insta-lab/app/test_card_writer_prompt.py && git commit -m "feat(insta-lab): card_writer 프롬프트에 글자수 가이드(오버플로우 예방)"
```
> 주의: 운영 DB에 `slate_writer` prompt_template 오버라이드가 있으면 DEFAULT_PROMPT 대신 그게 쓰임 → 배포 후 필요 시 `PUT /api/insta/templates/prompts/slate_writer`로 동일 가이드 반영(plan §검증에서 안내).
---
# Phase 4 — zip 패키지 다운로드 API (insta-lab)
## Task 4.1: GET /api/insta/slates/{id}/package
**Files:**
- Modify: `web-backend/insta-lab/app/main.py` (엔드포인트 추가)
- Test: `web-backend/insta-lab/app/test_package_api.py` (NEW)
- [ ] **Step 1: (확인됨) asset 스키마**`card_assets(slate_id, page_index, file_path, file_hash)`. `db.list_card_assets(slate_id)` → 각 row에 `file_path`·`page_index`. `db.add_card_asset(slate_id, page_index, file_path, file_hash="")`. `db.add_card_slate(row: dict)`. 기존 `/assets/{page}``FileResponse(match["file_path"], media_type="image/png")`. zip 엔드포인트는 동일하게 `a["file_path"]`를 읽는다.
- [ ] **Step 2: 실패 테스트**
`insta-lab/app/test_package_api.py`:
```python
import io, os, tempfile, zipfile, sys
from fastapi.testclient import TestClient
def _client(monkeypatch):
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from app import config, db
tmp = tempfile.mkdtemp()
monkeypatch.setattr(config, "INSTA_DATA_PATH", tmp, raising=False)
monkeypatch.setattr(db, "DB_PATH", os.path.join(tmp, "insta.db"), raising=False)
db.init_db()
from app.main import app
return TestClient(app), db, tmp
def test_package_zip_contains_pngs_and_caption(monkeypatch):
client, db, tmp = _client(monkeypatch)
# 슬레이트 + 2개 asset(실제 PNG 파일) 시드
sid = db.add_card_slate({"keyword":"k","category":"economy","status":"rendered",
"cover_copy":{"headline":"h"}, "body_copies":[{"headline":"b","body":"x"}]*8,
"cta_copy":{}, "suggested_caption":"캡션입니다", "hashtags":["#a","#b"]})
cards_dir = os.path.join(tmp, "insta_cards", str(sid)); os.makedirs(cards_dir, exist_ok=True)
for pg in (1,2):
fp = os.path.join(cards_dir, f"{pg:02d}.png")
with open(fp, "wb") as f: f.write(b"\x89PNG\r\n" + b"0"*2000)
db.add_card_asset(slate_id=sid, page_index=pg, file_path=fp)
r = client.get(f"/api/insta/slates/{sid}/package")
assert r.status_code == 200
assert r.headers["content-type"] == "application/zip"
z = zipfile.ZipFile(io.BytesIO(r.content))
names = z.namelist()
assert any(n.endswith(".png") for n in names)
assert "caption.txt" in names
cap = z.read("caption.txt").decode("utf-8")
assert "캡션입니다" in cap and "#a" in cap
```
> `db.add_card_slate`/`add_card_asset`/`list_card_assets`의 실제 시그니처·컬럼명은 db.py 확인 후 맞출 것. asset 경로 컬럼이 `path`가 아니면 테스트·구현 모두 조정.
- [ ] **Step 3: 실패 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/test_package_api.py -v` Expected: FAIL (404)
- [ ] **Step 4: 엔드포인트 구현**`insta-lab/app/main.py`에 추가 (`/assets/{page}` 엔드포인트 근처, 동일한 asset 파일경로 접근 방식 사용. `import io, zipfile`은 상단에 추가):
```python
@app.get("/api/insta/slates/{slate_id}/package")
def download_package(slate_id: int):
slate = db.get_card_slate(slate_id)
if not slate:
raise HTTPException(404, "slate not found")
assets = sorted(db.list_card_assets(slate_id), key=lambda a: a["page_index"])
if not assets:
raise HTTPException(409, "아직 렌더된 카드가 없습니다")
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
for a in assets:
fp = a["file_path"]
if os.path.exists(fp):
z.write(fp, arcname=f"{a['page_index']:02d}.png")
caption = (slate.get("suggested_caption") or "").strip()
tags = slate.get("hashtags") or []
if isinstance(tags, str):
import json as _json
try: tags = _json.loads(tags)
except Exception: tags = []
caption_full = caption + ("\n\n" + " ".join(tags) if tags else "")
z.writestr("caption.txt", caption_full)
buf.seek(0)
from fastapi.responses import StreamingResponse
return StreamingResponse(buf, media_type="application/zip", headers={
"Content-Disposition": f'attachment; filename="insta_slate_{slate_id}.zip"'})
```
> `HTTPException`/`os`는 main.py에 이미 import됨. `slate.get("hashtags")`가 JSON 문자열일 수 있어 방어 파싱.
- [ ] **Step 5: 통과 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/test_package_api.py -v` Expected: PASS
- [ ] **Step 6: Commit (insta-lab)**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-backend && git add insta-lab/app/main.py insta-lab/app/test_package_api.py && git commit -m "feat(insta-lab): 슬레이트 zip 패키지 다운로드 API (10 PNG + caption.txt)"
```
---
# Phase 5 — web-ui 패키지 다운로드 버튼 (별도 repo: web-ui)
## Task 5.1: 슬레이트 상세에 다운로드 버튼
**Files:**
- Modify: `web-ui/src/api.js` (헬퍼)
- Modify: insta 카드 페이지 (`web-ui/src/pages/insta/InstaCards.jsx` 또는 슬레이트 상세 컴포넌트)
- [ ] **Step 1: 구조 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && git checkout -b feat/insta-package-download && grep -rln "insta\|슬레이트\|slate" src/pages/insta/ src/api.js 2>/dev/null | head` 로 슬레이트 상세 UI + apiGet 패턴 확인.
- [ ] **Step 2: api.js 헬퍼 + 다운로드**`src/api.js`에 패키지 URL 헬퍼 추가(파일 다운로드는 새 탭/anchor로):
```javascript
export const instaPackageUrl = (slateId) => `/api/insta/slates/${slateId}/package`;
```
슬레이트 상세 컴포넌트에 버튼 추가 (기존 버튼 스타일 맞춤):
```jsx
<a className="insta-pkg-btn" href={instaPackageUrl(slate.id)} download>
📦 패키지 다운로드 (10 + 캡션)
</a>
```
> import에 `instaPackageUrl` 추가. 실제 슬레이트 객체의 id 필드명·버튼 클래스는 Step 1 확인 결과에 맞출 것.
- [ ] **Step 3: 빌드 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npm run build` Expected: exit 0
- [ ] **Step 4: Commit (web-ui repo)**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ui && git add src/ && git commit -m "feat: 인스타 슬레이트 패키지 다운로드 버튼"
```
---
# Phase 6 — 통합 검증
## Task 6.1: 회귀 + 배포 안내
- [ ] **Step 1: insta-lab 테스트** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/ -q` (Playwright 의존 테스트는 web-ai에만 있음). 신규 통과 + 회귀 없음. (`_shared` import로 main 로드 시 PYTHONPATH 필요하면 test에 sys.path.insert 적용 — Phase 4 test가 이미 처리)
- [ ] **Step 2: web-ai 테스트** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render && python -m pytest -q` (Chromium 필요; 미설치 시 `playwright install chromium`).
- [ ] **Step 3: 배포 안내** — 3 repo 각각 push/배포:
- insta-lab: `git push origin main` → webhook 자동배포(NAS).
- web-ai: Windows 머신에서 워커 repo pull + 재시작 (insta-render 서비스). **신규 템플릿이 워커 CARD_TEMPLATE_DIR에 반영돼야 효과 발생.**
- web-ui: `npm run release:nas`.
- 배포 후 슬레이트 1건 생성 → 카드 PNG 육안 확인(디자인 시스템 적용·폰트 정상) → `/package` zip 다운로드 확인. DB `slate_writer` 오버라이드 존재 시 글자수 가이드 반영.
---
## Self-Review 체크리스트 결과
- **Spec 커버리지**: 디자인 시스템 템플릿(Task 1.1) / 렌더 견고화 fonts.ready+검증(2.1) / 카피 글자수 가이드(3.1) / zip 패키지(4.1) / web-ui 버튼(5.1) / 검증(6.1). known-issue(폰트·오버플로우)=2.1+템플릿 clamp. 모두 매핑.
- **Placeholder**: 모든 코드 step에 실제 코드. db asset 컬럼명·web-ui 슬레이트 필드·워커 테스트 관례는 "Step에서 확인 후 맞춤" 명시(코드베이스 의존, 합리적). brand_handle 기본 빈칸(미설정 허용).
- **타입 일관성**: 템플릿이 쓰는 spec 키(page_type/page_no/total_pages/headline/body/accent_color/cta)가 워커 `_build_pages` 산출과 일치. zip 엔드포인트가 쓰는 `list_card_assets`/`get_card_slate`/`suggested_caption`/`hashtags`는 기존 db/슬레이트 스키마와 일치(Step 1에서 asset 경로 컬럼명만 확인).
- **3 repo 경로**: 각 Task에 repo별 cd + 커밋 분리 명시.