merge: 인스타 카드뉴스 품질 고도화 + zip 패키지 (Phase 1-5)

모던 미니멀 디자인 시스템 템플릿 + 카피 글자수 가이드 + zip 패키지 다운로드 API.
(렌더 견고화·템플릿 authoritative는 web-ai repo)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 13:39:35 +09:00
7 changed files with 685 additions and 29 deletions

View File

@@ -0,0 +1,408 @@
# 인스타 카드뉴스 품질 고도화 + 업로드 친화 패키지 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 + 커밋 분리 명시.

View File

@@ -0,0 +1,97 @@
# 인스타 카드뉴스 품질 고도화 + 업로드 친화 패키지 — 설계 Spec
- **작성일**: 2026-06-02
- **상태**: 설계 승인 (구현 plan 대기)
- **대상**: `insta-lab`(템플릿·카피·zip·web-ui) + `web-ai/services/insta-render`(렌더 워커, **별도 repo**)
- **사이클**: 스마트 에이전트 고도화 3종 중 **3번 인스타**. (1 로또·2 주식 배포 완료)
---
## 1. 배경 & 목표
현재 insta-lab은 뉴스→키워드→Claude 카피(cover+본문8+cta+caption+hashtags)→Redis push→**Windows insta-render 워커**가 Jinja→HTML→Playwright 스크린샷(1080×1350)→텔레그램 전달 흐름이다. 그러나 카드가 "진짜 카드뉴스" 품질에 못 미치고(메모리상 렌더 known-issue), 현재 default 템플릿은 55줄짜리 기본형(accent+headline/body/footer)이다.
CEO 목표: **진짜 카드뉴스 형식**으로 카드 품질을 끌어올리고, 완성 패키지를 **인스타에 업로드하기 쉽게** 만든다.
### 핵심 결정 (2026-06-02 brainstorming)
1. **업로드 방식 = 반자동(현행 개선)**. Instagram Graph API/Meta 앱/IG 비즈니스 계정 미사용. 완성 카드+캡션을 사용자가 인스타 앱에서 직접 업로드하되, **마찰 없는 패키지 전달**(텔레그램 + zip 다운로드)로 개선.
2. **카드 품질 = 디자인 시스템 템플릿 고도화**. 폴리시한 HTML/CSS 디자인 시스템 + Playwright 렌더, known-issue 해결. (AI 생성 비주얼·Vision import 수리 아님)
3. **비주얼 = 모던 미니멀**. 넉넉한 여백·강한 산세리프 타이포·1~2 accent·깔끔한 그리드. 단일 강한 default 테마(멀티테마 X), accent만 카테고리별.
### 기존 자산 (재사용)
- `insta-lab/app/card_writer.py` — Claude 카피 생성(cover_copy{headline,body,accent_color}, body_copies[8]{headline,body}, cta_copy{headline,body,cta}, suggested_caption, hashtags[]).
- `insta-lab/app/templates/default/card.html.j2` — 격상 대상(현 55줄 기본형).
- `web-ai/services/insta-render/`: `worker.py`(BLPOP `queue:insta-render``GET /api/insta/slates/{id}``render_slate` → webhook `/api/internal/insta/update`), `card_renderer.py`(`_build_pages`로 10페이지 spec 구성 cover/body8/cta, Jinja→HTML→`page.goto(file://, networkidle)``screenshot(full_page=False)` @viewport 1080×1350, `CARD_TEMPLATE_DIR`에서 템플릿 로드).
- nginx `/media/insta/``/data/insta_cards/`(카드 PNG 공개 서빙) — 패키지 다운로드에 활용.
### known-issue 근원 (이번 작업으로 해결)
- 웹폰트(@import Google Fonts) 로딩 전 스크린샷 → fallback 폰트 렌더.
- `full_page=False` + 콘텐츠가 1350px 초과 → 하단 잘림.
- (기존 minimal 테마) Vision-import 마스킹 좌표·background-image 경로 문제 → **신규 깨끗한 디자인 시스템 템플릿으로 경로 자체를 제거(우회)**.
---
## 2. 디자인 시스템 (모던 미니멀)
`insta-lab/app/templates/default/card.html.j2`를 페이지 타입별 레이아웃을 가진 디자인 시스템으로 재작성.
### 페이지 타입별 레이아웃 (`_build_pages`의 page_type 사용)
- **cover** (page 1): 카테고리 배지 + 대형 헤드라인(96px급) + 서브카피 + 브랜드 핸들. 시선 집중.
- **body** ×8 (page 2~9): 좌상단 번호 인덱스(02~09) + 포인트 헤드라인(72px급) + 본문(40px급, 2~4문장) + 하단 진행 인디케이터(점/바). 일관 그리드.
- **cta** (page 10): 요약 헤드라인 + 마무리 본문 + 행동유도(팔로우/저장) + 핸들.
### 디자인 토큰
- 타이포: Pretendard(우선) 또는 Noto Sans KR, weight 900/700/400, letter-spacing 음수, line-height 1.15~1.55.
- 레이아웃: 1080×1350 고정, safe-margin(예: 좌우/상하 ~80px), 그리드 정렬.
- 컬러: 라이트 배경(#F7F7FA 계열) + `accent_color`(카테고리별, 데이터 기존: economy #0F62FE / psychology #A66CFF / celebrity #FF5C8A 등) 포인트.
- 푸터: `{page_no} / {total_pages}` + 브랜드 핸들. body는 진행 인디케이터.
### 제약
- 각 페이지 = 정확히 1080×1350 고정 박스, `overflow:hidden`. 긴 본문 대비 본문 컨테이너 `max-height` + 줄수 clamp(말줄임 또는 폰트 축소).
- 단일 default 테마. accent만 카테고리 차등(추가 테마 디렉토리 안 만듦).
---
## 3. 렌더 견고화 (web-ai 워커, known-issue 해결)
`web-ai/services/insta-render/card_renderer.py` 보강:
- **폰트 보장**: `page.goto` 후 screenshot 전에 `await page.evaluate('document.fonts.ready')` 대기 추가. (가능하면 Pretendard를 워커에 self-host/번들해 네트워크 의존 제거 — 폴백으로 fonts.ready 대기.)
- **정확한 1080×1350**: 템플릿이 `.card{width:1080px;height:1350px;overflow:hidden}`을 보장. `full_page=False` + viewport 1080×1350 유지. 콘텐츠 오버플로우는 템플릿 CSS(clamp/max-height)로 차단.
- **PNG 검증**: 렌더 후 각 PNG가 1080×1350인지 + 0바이트/빈 페이지 아닌지 확인. 실패 시 webhook `failed`.
- **템플릿 sync (open item)**: 워커의 `CARD_TEMPLATE_DIR`가 신규 디자인 템플릿을 받는 경로 확인·정립. (insta-lab 템플릿 → 워커로 어떻게 전달되는지 plan에서 확인: web-ai repo 복사본인지 별도 sync인지. 신규 템플릿이 워커에 반영돼야 효과 발생.)
---
## 4. 카피 정합 + 업로드 친화 패키지
- **카피 글자수 가이드**: `card_writer.py`의 프롬프트에 헤드라인/본문 글자수 상한 명시(디자인 박스에 맞게) → 오버플로우 예방. 시작 기준값(템플릿 박스 확정 시 ±조정): cover headline ≤ 22자 / body headline ≤ 26자 / body ≤ 120자 / cta headline ≤ 22자. CSS clamp가 2차 방어이므로 가이드는 근사치여도 안전.
- **업로드 친화 패키지 (신규)**: 기존 텔레그램 미디어그룹(10장)+캡션/해시태그 유지 + **zip 다운로드** 추가:
- 신규 API `GET /api/insta/slates/{id}/package` → 10 PNG + `caption.txt`(suggested_caption + hashtags) 묶은 zip 반환.
- web-ui 슬레이트 상세에 "패키지 다운로드" 버튼.
- 사용자가 zip 받아 인스타 앱에 캐러셀 업로드 + caption 붙여넣기.
- **승인 게이트 유지**: 키워드 후보 푸시 → 사용자 선택 → 렌더 → 전달. 자동 게시 없음(반자동).
---
## 5. 에러·테스트·리스크·스코프
- **2 repo 배포 경로**: insta-lab = git push → Gitea webhook 자동배포. web-ai 워커 = Windows 머신에서 별도 갱신(repo: ai-trade.git). 템플릿·렌더 변경이 양쪽에 반영돼야 함.
- **테스트**:
- insta-lab: card_writer 글자수 제약, zip 패키지 구성(10 PNG + caption.txt), package API.
- web-ai: 페이지 타입별 템플릿 렌더 HTML 스냅샷, PNG 1080×1350 크기 검증, fonts.ready 대기, 오버플로우 clamp (web-ai `tests/test_worker` 확장).
- **리스크**:
- 템플릿 sync 누락 → 워커가 구 템플릿 렌더(효과 없음). plan에서 sync 경로 확정.
- 긴 카피 오버플로우 → 글자수 가이드 + CSS clamp 이중 방어.
- 폰트 로딩 타이밍 → fonts.ready 대기(+self-host).
- known-issue는 깨끗한 디자인 시스템 + 렌더 견고화로 **근본 해결**(Vision-import 경로 제거).
---
## 6. 결정 로그 (2026-06-02)
1. 업로드 = 반자동(현행 개선, Graph API 미사용)
2. 카드 품질 = 디자인 시스템 템플릿 고도화
3. 비주얼 = 모던 미니멀, 단일 default 테마
## 7. 스코프 밖 / 향후
- Instagram Graph API 자동 게시, 멀티 테마, AI 생성 비주얼, Vision design_importer 수리, 카테고리별 차별 테마 — 향후.
- 9:30 자동 슬레이트(auto_select) 흐름 자체는 변경 안 함(품질·패키지만 개선).

View File

@@ -35,6 +35,13 @@ DEFAULT_PROMPT = """너는 인스타그램 카드 뉴스 카피라이터다.
"suggested_caption": "<인스타 캡션 본문>",
"hashtags": ["#태그1", "#태그2", ...]
}}
[글자수 제약 — 카드 디자인 박스에 맞게 반드시 준수]
- cover_copy.headline: 22자 이내
- body_copies[].headline: 26자 이내
- body_copies[].body: 120자 이내 (2~4문장)
- cta_copy.headline: 22자 이내
초과하면 잘리므로 간결하고 임팩트 있게 작성한다.
"""

View File

@@ -1,14 +1,16 @@
"""FastAPI entrypoint for insta-lab."""
import asyncio
import io
import json
import logging
import os
import zipfile
from typing import Optional
from fastapi import FastAPI, HTTPException, BackgroundTasks, Body, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel
from _shared.access_log import install as install_access_log
@@ -247,6 +249,39 @@ def get_asset(slate_id: int, page: int):
return FileResponse(match["file_path"], media_type="image/png")
@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()
written = 0
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")
written += 1
caption = (slate.get("suggested_caption") or "").strip()
tags = slate.get("hashtags") or []
if isinstance(tags, str):
try:
tags = json.loads(tags)
except Exception:
tags = []
caption_full = caption + ("\n\n" + " ".join(tags) if tags else "")
z.writestr("caption.txt", caption_full)
if written == 0:
raise HTTPException(409, "렌더된 카드 파일이 없습니다")
buf.seek(0)
return StreamingResponse(buf, media_type="application/zip", headers={
"Content-Disposition": f'attachment; filename="insta_slate_{slate_id}.zip"'
})
@app.delete("/api/insta/slates/{slate_id}")
def delete_slate(slate_id: int):
if not db.get_card_slate(slate_id):

View File

@@ -3,52 +3,85 @@
<head>
<meta charset="UTF-8">
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700;900&display=swap');
@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;
font-family: 'Noto Sans KR', sans-serif;
html, body { width: 1080px; height: 1350px; }
body {
font-family: 'Pretendard', 'Noto Sans KR', sans-serif;
background: #F7F7FA; color: #14171A;
-webkit-font-smoothing: antialiased;
}
.card {
width: 1080px; height: 1350px;
padding: 80px 72px;
display: flex; flex-direction: column; justify-content: space-between;
background: linear-gradient(180deg, #FFFFFF 0%, #F7F7FA 100%);
border-top: 16px solid {{ accent_color }};
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 | safe }}; }
.badge {
display: inline-block; padding: 8px 20px; border-radius: 999px;
background: {{ accent_color }}; color: #fff;
font-size: 28px; font-weight: 700; letter-spacing: -0.02em;
align-self: flex-start; padding: 10px 24px; border-radius: 999px;
background: {{ accent_color | safe }}; 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 | safe }}; letter-spacing: -0.04em; }
.content { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 36px; }
.headline {
font-size: {{ 96 if page_type == 'cover' else 72 }}px;
font-weight: 900; line-height: 1.15; letter-spacing: -0.04em;
margin-top: 32px;
font-weight: 800; line-height: 1.18; letter-spacing: -0.04em; color: #14171A;
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden;
}
.body {
font-size: 40px; font-weight: 400; line-height: 1.55;
margin-top: 40px; color: #2A2F35;
.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;
}
.cover .sub { -webkit-line-clamp: 5; }
.footer {
display: flex; justify-content: space-between; align-items: center;
font-size: 28px; color: #6B7280; font-weight: 500;
font-size: 28px; color: #8A9099; font-weight: 600; margin-top: 40px;
}
.cta { font-weight: 700; color: {{ accent_color }}; }
.cta-pill {
align-self: flex-start; margin-top: 8px; padding: 18px 40px; border-radius: 16px;
background: {{ accent_color | safe }}; 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 | safe }}; }
</style>
</head>
<body>
<div class="card">
<div>
<span class="badge">{{ page_type|upper }}</span>
<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('') or '오늘의 이슈' }}</span>
<div class="content">
<h1 class="headline">{{ headline }}</h1>
<p class="body">{{ body }}</p>
<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>
{% if cta %}<span class="cta">{{ cta }}</span>{% endif %}
{% endif %}
</div>
</div>
</body>

View File

@@ -0,0 +1,9 @@
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

View File

@@ -0,0 +1,67 @@
import io, os, tempfile, zipfile, sys
from fastapi.testclient import TestClient
def _client(monkeypatch):
# Insert web-backend root (3 levels up from this file) so _shared is importable
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
def test_package_unknown_slate_404(monkeypatch):
client, db, tmp = _client(monkeypatch)
r = client.get("/api/insta/slates/999999/package")
assert r.status_code == 404
def test_package_no_assets_409(monkeypatch):
client, db, tmp = _client(monkeypatch)
sid = db.add_card_slate({
"keyword": "k",
"category": "economy",
"status": "draft",
"cover_copy": {"headline": "h"},
"body_copies": [{"headline": "b", "body": "x"}] * 8,
"cta_copy": {},
"suggested_caption": "c",
"hashtags": [],
})
r = client.get(f"/api/insta/slates/{sid}/package")
assert r.status_code == 409