Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
262 changes: 262 additions & 0 deletions docs/superpowers/specs/2026-05-14-content-studio-pipeline-v2-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
---
title: Content Studio Pipeline v2 Design
owner: human
status: draft
updated: 2026-05-14
tags: [ui, api]
---

# Content Studio Pipeline v2 Design

Issue: #530

## Summary

Content Studio 파이프라인을 개선하여 post ID 수동 입력 대신 **검색/인기순 선택 → 키워드 추출 → 마케팅 프롬프트 생성 → 3종 채널별 썸네일 자동 생성**까지 하나의 플로우로 완성한다. 동시에 Research/Firecrawl 외부 의존성을 전체 제거한다.

## Motivation

- 현재 post ID를 직접 입력하는 UX가 불편하고 오류 유발 (500 에러 포함)
- `packet_${postId}` prefix가 UUID 컬럼에 들어가면서 `invalid input syntax for type uuid` 에러 발생
- Firecrawl 기반 Research는 post 내부 데이터로 충분히 대체 가능 — 불필요한 외부 의존성
- 마케팅 콘텐츠(YouTube/Instagram/X) 제작 썸네일 자동화 부재

## Approach

**순차 스텝 파이프라인 (Approach A)**. 각 스텝이 명확히 분리되어 스텝별 에러 복구가 간단하며, `gpt-image-2` 이미지 생성 비용을 어드민이 컨트롤할 수 있다. 기존 `page.tsx`의 순차 플로우와 일관성 유지.

## Pipeline Flow

```
[PostPickerModal] Meilisearch 검색 + 인기순 리스트 → Post 선택
[Packet 생성] crypto.randomUUID() + post 데이터 수집
[통합 LLM 호출] 규칙 키워드 + structured output
→ { keywords, imagePrompts (YT/IG feed/IG story), variants (YT/IG/X) }
[어드민 프리뷰] 키워드 + 프롬프트 + variant 텍스트 확인/수정
[썸네일 생성] "Generate Thumbnails" 버튼 → Promise.all 4종 병렬
→ gpt-image-2 edit 모드 (post 원본 이미지 reference)
[저장] Supabase Storage (content-studio/{packetId}/)
[Governance] celebrity risk, disclosure 등 체크 (research 규칙 제외)
[어드민 리뷰] 썸네일 + 텍스트 확인/수정 → approve/reject
```

## Section 1: Post 선택 Modal

### Component

`PostPickerModal` — 새 컴포넌트, `app/admin/content-studio/` 하위에 생성.

### 구조

- 상단: Meilisearch 검색 input (debounce 300ms, 기존 한글 검색 인프라 활용)
- 검색 미입력 시: Supabase에서 `view_count + like_count` 기준 인기순 상위 20개 표시
- 검색 입력 시: Meilisearch `posts` 인덱스 검색 결과 표시
- 각 행: 썸네일(40×40) + post title + artist/group name + view_count + like_count
- 행 클릭 시: Modal 닫힘 → 선택된 postId로 Packet 생성 자동 트리거

### 데이터 흐름

- 인기순 초기 리스트: Supabase 직접 쿼리 (`posts` 테이블, `view_count + like_count` 내림차순, limit 20)
- 검색: 기존 Meilisearch 클라이언트 재사용

### 기존 코드 변경

- `page.tsx`: postId 텍스트 입력 폼 → "Select Post" 버튼 + PostPickerModal로 교체
- 수동 ID 입력은 Modal 내 하단에 "Enter ID manually" 링크로 유지 (fallback)

## Section 2: Packet ID 수정

### 버그 수정

`packet-builder.ts`:

- `packetId()`: `packet_${postId}` → `crypto.randomUUID()`
- `variantId()`: `${packetIdValue}_${format}` → `crypto.randomUUID()`
- `post_id` FK가 이미 `content_packets` 테이블에서 unique constraint이므로 중복 방지는 DB가 담당

### API

변경 없음. `POST /api/v1/content/packets { postId }` 그대로 유지. 내부에서 UUID 생성만 변경.

### DB 호환성

- `db.ts`의 `upsertPacket`: `onConflict: "post_id"` 유지 — 같은 post로 재생성하면 기존 packet 업데이트
- 기존 DB 데이터: `packet_${uuid}` 형식의 기존 레코드는 그대로 두고, 신규만 순수 UUID

## Section 3: 통합 LLM 호출

### 새 스키마

`llm-schemas.ts`에 `unifiedContentResponseSchema` 추가:

```typescript
{
keywords: string[],
imagePrompts: {
youtube: string,
instagram_feed: string,
instagram_story: string,
},
variants: {
youtube: { title: string, body: string, hashtags: string[] },
instagram: { title: string, body: string, hashtags: string[] },
x: { title: string, body: string, hashtags: string[] },
}
}
```

### 호출 플로우

1. `packet-builder.ts`에서 규칙 기반 키워드 추출: brand, artist, group, solution categories를 post 데이터에서 수집
2. 새 함수 `generateUnifiedContent(packet, ruleKeywords)` — 단일 OpenAI structured output 호출
3. 입력: packet 전체 데이터 + 규칙 키워드 배열
4. 출력: 위 스키마. LLM이 규칙 키워드를 포함하되 추가 마케팅 키워드도 생성

### 기존 코드 정리

- `llm-generation.ts`의 `generateVariantsWithMode()` → 새 `generateUnifiedContent()`로 교체
- `generateChannelVariants()` (템플릿 기반) → template fallback으로 유지
- `llm-prompts.ts` → 새 통합 프롬프트로 교체
- 기존 `contentVariantLLMResponseSchema` → 새 `unifiedContentResponseSchema`로 교체

### Template fallback

- `CONTENT_STUDIO_LLM_ENABLED=false`이면 기존 `generateChannelVariants()` + 빈 keywords/imagePrompts 반환
- LLM 호출 실패 시에도 동일하게 fallback

## Section 4: 3종 채널별 썸네일 생성

### 채널별 사이즈

| 채널 | 포맷 키 | 사이즈 | 용도 |
| --------------- | ----------------- | --------- | ----------- |
| YouTube | `youtube` | 1536×1024 | 썸네일 |
| X (Twitter) | `x` | 1536×1024 | 카드 이미지 |
| Instagram Feed | `instagram_feed` | 1024×1024 | 피드 정사각 |
| Instagram Story | `instagram_story` | 1024×1536 | 스토리 세로 |

### 생성 플로우

1. 통합 LLM 결과에서 채널별 `imagePrompts` 추출
2. post 원본 이미지 URL을 reference image로 사용
3. `gpt-image-2` edit 모드로 4종 `Promise.all` 병렬 호출
4. edit 실패 시 → 순수 generate fallback (reference 없이)
5. 생성된 이미지 → Supabase Storage `content-studio/{packetId}/` 경로에 업로드
6. 업로드 실패 시 → data URL (base64) fallback

### 기존 코드 활용

- `openai-client.ts`의 `fetchImageBytesFromOpenAIEdit` + `uploadToStorage` 재사용
- 이미지 모델 기본값: `gpt-image-1` → `gpt-image-2`로 업데이트 (`CONTENT_STUDIO_IMAGE_MODEL` 환경변수)
- 새 함수 `generateChannelThumbnails(packet, imagePrompts)` 추가

### API

새 엔드포인트: `POST /api/v1/content/packets/{id}/generate-thumbnails`

- 요청: `{ imagePrompts: { youtube, instagram_feed, instagram_story } }`
- 응답: `{ thumbnails: { youtube: url, x: url, instagram_feed: url, instagram_story: url }, failures: [] }`
- YouTube와 X는 동일 사이즈(1536×1024)이므로 imagePrompt는 3개이나 출력은 4개 (X는 youtube prompt 재사용)

### UI

- LLM 생성 결과 아래에 "Generate Thumbnails" 버튼 추가
- 이미지 프롬프트를 어드민이 수정할 수 있는 textarea 3개 (YT, IG Feed, IG Story)
- 생성 완료 후 4종 썸네일 프리뷰 그리드 (2×2)

## Section 5: Research/Firecrawl 전체 제거

### 삭제 대상 파일 (13개)

| 경로 | 유형 |
| ----------------------------------------------------------- | ------------- |
| `lib/content-studio/research/index.ts` | 배럴 |
| `lib/content-studio/research/firecrawl-client.ts` | Firecrawl API |
| `lib/content-studio/research/domain-policy.ts` | 도메인 정책 |
| `lib/content-studio/research/normalization.ts` | 정규화 |
| `lib/content-studio/research/query-suggestions.ts` | 쿼리 제안 |
| `lib/content-studio/research/recommendations.ts` | 추천 |
| `lib/content-studio/research/service.ts` | 서비스 |
| `lib/content-studio/__tests__/research.test.ts` | 테스트 |
| `app/admin/content-studio/ResearchPanel.tsx` | UI 패널 |
| `app/admin/content-studio/__tests__/ResearchPanel.test.tsx` | UI 테스트 |
| `app/api/v1/content/research/route.ts` | API 라우트 |
| `app/api/v1/content/research/__tests__/route.test.ts` | API 테스트 |
| `research/` 디렉토리 전체 | — |

### 스키마 정리 (`schemas.ts`)

삭제 대상:

- `researchSourceSchema`, `researchInsightSchema`, `researchRecommendationsSchema`, `researchRunSchema`, `runResearchRequestSchema` + 관련 타입 export
- `contentPacketSchema`의 `externalEvidence` optional 필드
- `contentVariantSchema`의 `researchProvenance`, `claims`, `missingFacts` optional 필드
- `generateVariantsRequestSchema`의 `researchContext`, `useResearchInCopy` 필드
- `assetPlanRequestSchema`, `shortFormPlanRequestSchema`의 `researchRun`, `useResearchInCopy` 필드

### 코드 참조 정리

- `page.tsx`: ResearchPanel import + 관련 state 제거
- `llm-generation.ts`: `researchContext` 관련 로직 제거
- `openai-client.ts`: research 파라미터 제거
- `governance-check.ts`: research 관련 규칙만 제거

### Governance 유지 항목

- celebrity risk check (artist/group name 기반)
- disclosure flags (AI generated, synthetic media, sponsored, rights risk)
- LLM governance check (`contentGovernanceLLMSchema`)

## Environment Variables

| 변수 | 용도 | 기본값 |
| ---------------------------- | ---------------- | ------------- |
| `OPENAI_API_KEY` | OpenAI API 키 | — (필수) |
| `CONTENT_STUDIO_LLM_ENABLED` | LLM 호출 활성화 | `false` |
| `CONTENT_STUDIO_MODEL` | 텍스트 LLM 모델 | `gpt-4.1` |
| `CONTENT_STUDIO_IMAGE_MODEL` | 이미지 생성 모델 | `gpt-image-2` |

## Out of Scope

- Grok AI 비디오 파이프라인
- ai-server ARQ 마이그레이션 + R2 저장소 전환
- 원클릭 자동 파이프라인 모드 (v2 안정화 후 별도 이슈)

## File Change Summary

### 새 파일

- `app/admin/content-studio/PostPickerModal.tsx`
- `app/api/v1/content/packets/[id]/generate-thumbnails/route.ts`

### 수정 파일

- `lib/content-studio/packet-builder.ts` — UUID 생성 + 규칙 키워드 추출
- `lib/content-studio/llm-schemas.ts` — `unifiedContentResponseSchema` 추가, 기존 research 스키마 정리
- `lib/content-studio/llm-generation.ts` — `generateUnifiedContent()` 교체
- `lib/content-studio/llm-prompts.ts` — 통합 프롬프트 교체
- `lib/content-studio/schemas.ts` — research 필드 삭제
- `lib/content-studio/assets/openai-client.ts` — `generateChannelThumbnails()` 추가, 기본 모델 변경
- `lib/content-studio/index.ts` — export 정리
- `app/admin/content-studio/page.tsx` — PostPickerModal 통합, ResearchPanel 제거, 썸네일 UI 추가
- `app/admin/content-studio/AssetPanel.tsx` — research 참조 제거
- `app/admin/content-studio/ShortFormPanel.tsx` — research 참조 제거
- `lib/content-studio/governance-check.ts` — research 규칙 제거
- `lib/content-studio/__tests__/llm-generation.test.ts` — research 컨텍스트 테스트 정리
- `lib/content-studio/__tests__/content-studio.test.ts` — 통합 테스트 업데이트

### 삭제 파일

- `lib/content-studio/research/` 디렉토리 전체 (7개)
- `lib/content-studio/__tests__/research.test.ts`
- `app/admin/content-studio/ResearchPanel.tsx`
- `app/admin/content-studio/__tests__/ResearchPanel.test.tsx`
- `app/api/v1/content/research/route.ts`
- `app/api/v1/content/research/__tests__/route.test.ts`
16 changes: 15 additions & 1 deletion packages/web/app/api/v1/vton/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,21 @@ async function callVtonApi(
const prediction = data.predictions?.[0];

if (!prediction?.bytesBase64Encoded) {
throw new Error("No result from VTON API");
if (process.env.NODE_ENV === "development") {
console.error(
"[VTON] Empty prediction. Full response:",
JSON.stringify(data, null, 2)
);
}
const raiReason = prediction?.raiFilteredReason;
const reason = raiReason
? "RAI_FILTERED"
: prediction?.safetyAttributes?.blocked
? "RAI_FILTERED"
: data.predictions?.length === 0
? "Model returned no predictions — check input images"
: "No result from VTON API";
throw new Error(reason);
}

return {
Expand Down
6 changes: 5 additions & 1 deletion packages/web/lib/components/vton/VtonModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,11 @@ export function VtonModal() {
completeBackgroundJob(resultDataUrl, latencyMs),
onTryOnError: (message) => {
failBackgroundJob(message);
setError(message);
setError(
message === "RAI_FILTERED"
? "This image combination was filtered by the AI safety system. Try a different photo or item."
: message
);
},
onTryOnFinally: () => {
if (stageInterval.current) clearInterval(stageInterval.current);
Expand Down
Loading