diff --git a/docs/superpowers/specs/2026-05-09-essential-mix-pipeline-design.md b/docs/superpowers/specs/2026-05-09-essential-mix-pipeline-design.md new file mode 100644 index 0000000..7f51ead --- /dev/null +++ b/docs/superpowers/specs/2026-05-09-essential-mix-pipeline-design.md @@ -0,0 +1,706 @@ +# Essential Mix 파이프라인 — 1시간 mix + essential 시각 스타일 + UX 강화 설계 + +> 작성일: 2026-05-09 +> 관련 spec: +> - `2026-05-07-music-youtube-pipeline-design.md` (본 파이프라인의 베이스) +> - `2026-05-09-gpu-video-offload-design.md` (Windows GPU 인코딩) + +--- + +## 1. 배경 + +현재 파이프라인은 **단일 트랙 → 단일 영상**(커버 + 가장자리 파형)만 지원. 사용자는 YouTube essential 채널처럼 **1시간 이상의 음악 mix + 차분한 배경 + 중앙 비주얼라이저** 영상을 원함. + +또한 진행 중 산출물(커버·썸네일·영상)을 NAS 파일시스템에서 직접 확인하는 게 번거로워, 진행 탭에서도 미리보기 가능했으면 함. + +--- + +## 2. 비목표 + +- 사용자 직접 업로드 사진/영상 (P3로 미룸) +- 360° 정확한 방사형 비주얼라이저 (ffmpeg 단독으로 한계 — `showfreqs` + ring overlay로 근사) +- Mix 자동 큐레이션(곡 자동 선택) — 기존 컴파일 탭의 수동 선택 그대로 활용 +- AI 검토 가중치 자동 튜닝 (Mix와 단일 트랙의 다른 기준 등 — P3) +- 텔레그램 사진 첨부 — 본 작업의 PipelineDetailModal로 우선 해결, 차후 P3 + +--- + +## 3. 사용자 흐름 + +### 3-1. Mix 영상 만들기 + +``` +[사용자] Compile 탭에서 트랙 N개 선택 → crossfade 설정 → 컴파일 시작 + → 컴파일 완료 (1시간+ mp3 생성, 기존 흐름) + → 컴파일 카드에 [🎬 영상 만들기] 버튼 클릭 + → 백엔드: POST /api/music/pipeline { compile_job_id, visual_style: 'essential' } + → 진행 탭으로 자동 이동, 새 카드 생성 + → 단계별 텔레그램 승인 (기존과 동일): + cover (또는 background_video) → video → thumbnail → metadata → AI 검토 → 발행 + → YouTube 비공개 영상 1편 +``` + +### 3-2. 단일 트랙 영상 만들기 (기존) + +진행 탭 모달에 라디오 "단일 트랙 / Mix" 추가. 단일 선택 시 기존 흐름 그대로. + +### 3-3. 산출물 미리보기 + +진행 탭 카드의 cover/thumbnail 미니 썸네일 → 카드 클릭 → 상세 모달 → 큰 이미지 + 영상 플레이어 + 메타·검토 JSON. + +--- + +## 4. 데이터 모델 변경 + +### 4-1. `video_pipelines` 테이블 확장 + +신규 컬럼: +```sql +ALTER TABLE video_pipelines ADD COLUMN compile_job_id INTEGER NULL REFERENCES compile_jobs(id); +ALTER TABLE video_pipelines ADD COLUMN visual_style TEXT NOT NULL DEFAULT 'essential'; +ALTER TABLE video_pipelines ADD COLUMN background_mode TEXT NOT NULL DEFAULT 'static'; +ALTER TABLE video_pipelines ADD COLUMN background_keyword TEXT; +``` + +| 컬럼 | 의미 | +|------|------| +| `track_id` (기존) | 단일 트랙 입력 시 | +| `compile_job_id` (신규) | Mix 입력 시 — `track_id` XOR `compile_job_id` | +| `visual_style` | `single` / `essential` | +| `background_mode` | `static` (사진) / `video_loop` (영상) | +| `background_keyword` | Pexels 검색용 (예: "rainy window cafe"). 비어있으면 장르 기반 자동 | + +마이그레이션: `ADD COLUMN`은 SQLite에서 안전. 기존 행은 NULL 또는 default 값 부여. + +### 4-2. `youtube_setup.visual_defaults` JSON 확장 + +기존: +```json +{"resolution": "1920x1080", "style": "visualizer", "background": "ai_cover"} +``` + +신규: +```json +{ + "resolution": "1920x1080", + "default_visual_style": "essential", + "default_background_mode": "static", + "default_background_keyword": "", + "background_image_source": "ai", // ai | pexels (Mix는 default ai) + "subtitle_track_titles": true // Mix에서 곡명 자막 표시 +} +``` + +기존 클라이언트 호환을 위해 미설정 키는 default로 fallback. + +--- + +## 5. API 변경 + +### 5-1. `POST /api/music/pipeline` 요청 body 확장 + +```json +{ + "track_id": 13, + // 또는 + "compile_job_id": 5, + // 옵션 (default는 setup에서) + "visual_style": "essential", // single | essential + "background_mode": "static", // static | video_loop + "background_keyword": "rainy cafe" +} +``` + +검증: +- `track_id` XOR `compile_job_id` 정확히 하나만 — 둘 다거나 둘 다 없으면 400 +- `compile_job_id`인 경우 `compile_jobs` 테이블에서 status='succeeded' 확인 — 아니면 400 +- `visual_style` 미지정 시 `youtube_setup.visual_defaults.default_visual_style` +- `background_mode` 미지정 시 `youtube_setup.visual_defaults.default_background_mode` + +응답: +```json +{ + "id": 7, + "track_id": null, + "compile_job_id": 5, + "visual_style": "essential", + "background_mode": "static", + "state": "created", + ... +} +``` + +### 5-2. `GET /api/music/pipeline/{id}` 응답 확장 + +신규 필드: `compile_job_id`, `visual_style`, `background_mode`, `background_keyword`, `tracks` (Mix면 트랙 리스트, 단일이면 단일 트랙 1개) + +`tracks` 형식: +```json +[ + {"id": 13, "title": "Lo-Fi Drive", "start_offset_sec": 0, "duration_sec": 176}, + {"id": 14, "title": "Midnight Cafe", "start_offset_sec": 173, "duration_sec": 200}, + ... +] +``` + +`start_offset_sec`은 컴파일 시 acrossfade 적용을 고려한 누적 시작 시각 (=영상 자막 트리거 타이밍). + +### 5-3. 변경 없음 + +`/feedback`, `/cancel`, `/publish`, `/setup`, `/youtube/*` 모두 그대로. + +--- + +## 6. 백엔드 — NAS music-lab + +### 6-1. `pipeline/orchestrator.py` 변경 + +`run_step`에 입력 audio 결정 로직 추가: + +```python +def _resolve_input(p: dict) -> dict: + """파이프라인 입력 = 단일 트랙 또는 컴파일 결과. + + 반환: {"audio_path": str, "duration_sec": int, "tracks": list[dict], + "title": str, "genre": str, "moods": list, ...} + """ + if p.get("compile_job_id"): + job = db.get_compile_job(p["compile_job_id"]) + if not job or job["status"] != "succeeded": + raise ValueError(f"compile job {p['compile_job_id']} not ready") + # 누적 offset 계산 (acrossfade 고려) + tracks = [] + offset = 0.0 + crossfade = job["crossfade_sec"] + for tid in job["track_ids"]: + t = db.get_track_by_id(tid) + tracks.append({ + "id": tid, "title": t["title"], + "start_offset_sec": offset, + "duration_sec": t["duration_sec"], + }) + offset += t["duration_sec"] - crossfade # acrossfade overlap만큼 차감 + return { + "audio_path": job["audio_path"], # /app/data/compiles/{id}.mp3 + "duration_sec": int(offset + crossfade), # 마지막 트랙은 풀 길이 + "tracks": tracks, + "title": job["title"] or "Mix", + "genre": "mix", + "moods": [], + } + else: + t = db.get_track_by_id(p["track_id"]) + return { + "audio_path": t["file_path"], + "duration_sec": t["duration_sec"], + "tracks": [{"id": t["id"], "title": t["title"], + "start_offset_sec": 0, "duration_sec": t["duration_sec"]}], + "title": t["title"], "genre": t["genre"], "moods": t.get("moods", []), + } +``` + +각 step runner는 `_resolve_input(p)` 결과를 사용: +- `_run_cover`: `genre`, `moods`, `title` 활용 (Mix면 `genre="mix"` → "mix" 키 prompt 또는 default) +- `_run_video`: `audio_path`, `duration_sec`, `tracks` 모두 Windows로 전달 +- `_run_meta`: `tracks` 리스트를 메타 prompt에 포함 +- `_run_review`: `tracks` 리스트를 검토 prompt에 포함 (트랙 수, 다양한 장르 등) + +### 6-2. `pipeline/cover.py` Pexels 폴백/대안 + +```python +async def generate(*, pipeline_id: int, genre: str, prompt_template: str, + mood: str = "", track_title: str = "", feedback: str = "", + image_source: str = "ai") -> dict: + """image_source: 'ai' (DALL·E) | 'pexels' (스톡 검색).""" + if image_source == "pexels": + return await _generate_with_pexels(pipeline_id, genre, mood, track_title) + # 기존 AI 흐름 그대로 + ... + # AI 실패 시 — 그라데이션 폴백 대신 Pexels 시도 (config 옵션) + ... +``` + +신규 `_generate_with_pexels`: +- Pexels API: `GET https://api.pexels.com/v1/search?query={keyword}&per_page=10` +- 결과 1번째 큰 사진 다운로드 → `/app/data/videos/{id}/cover.jpg` +- API key 미설정/실패 시 그라데이션 폴백 + +### 6-3. 신규 `pipeline/background.py` (video_loop 모드) + +```python +async def fetch_video_loop(pipeline_id: int, keyword: str) -> dict: + """Pexels Video API로 5–15초 루프 영상 받아옴. + + /app/data/videos/{id}/loop.mp4 저장. + """ + # GET https://api.pexels.com/videos/search?query=...&per_page=5 + # SD/HD 720p 중에서 골라 다운로드 + ... + return {"path": "/app/data/videos/{id}/loop.mp4", "duration_sec": ...} +``` + +오케스트레이터에서 `background_mode == "video_loop"` 분기 시 cover step 대신 또는 보조로 호출 (디자인 결정: cover step을 두 모드의 공통 입력 준비 단계로 통합 — 정적이면 cover.jpg, 영상이면 loop.mp4). + +### 6-4. `pipeline/metadata.py` Mix 지원 + +`generate(*, track, template, trend_keywords, feedback="", tracks=None)` 시그니처 확장. `tracks` 있으면 Claude prompt에 다음 추가: + +``` +이 영상은 {len(tracks)}개 트랙의 mix입니다. 트랙 리스트: +1. [00:00] Lo-Fi Drive — lo-fi +2. [03:00] Midnight Cafe — lo-fi +... +설명에는 트랙 리스트를 타임스탬프와 함께 포함하세요. +``` + +응답 description은 자동으로 트랙리스트 포함됨. 이는 YouTube에서 챕터로 자동 인식. + +### 6-5. `pipeline/video.py` (NAS측, 변경 작음) + +기존 함수에 추가 파라미터 전달: + +```python +def generate(*, pipeline_id, audio_path, cover_path, genre, duration_sec, + resolution="1920x1080", style="essential", + background_mode="static", background_path=None, + tracks=None) -> dict: + payload = { + "audio_path_nas": ..., "cover_path_nas": ..., + "output_path_nas": ..., + "resolution": resolution, + "duration_sec": duration_sec, + "style": style, # NEW: single | essential + "background_mode": background_mode, # NEW: static | video_loop + "background_path_nas": ..., # NEW: video_loop일 때 loop.mp4 경로 + "tracks": tracks, # NEW: Mix면 트랙 리스트 (자막용) + } + ... +``` + +### 6-6. `db.py` 변경 + +신규 컬럼 추가 마이그레이션 + `get_compile_job(id)` (없으면 추가) + `get_track_by_id(id)` 활용. + +--- + +## 7. 백엔드 — Windows music_ai + +### 7-1. `/encode_video` 요청 확장 + +```json +{ + "audio_path_nas": "...", + "cover_path_nas": "...", + "output_path_nas": "...", + "resolution": "1920x1080", + "duration_sec": 3600, + "style": "essential", // NEW + "background_mode": "static", // NEW + "background_path_nas": "...", // NEW: video_loop면 loop.mp4 + "tracks": [ // NEW: 자막용 + {"start_offset_sec": 0, "title": "Lo-Fi Drive"}, + {"start_offset_sec": 173, "title": "Midnight Cafe"} + ] +} +``` + +### 7-2. `video_encoder.py` 분기 로직 + +```python +def encode_video(*, ..., style="essential", background_mode="static", + background_path_nas=None, tracks=None): + if style == "single": + cmd = build_single_track_cmd(...) + else: # essential + if background_mode == "static": + cmd = build_essential_static_cmd(cover, audio, out, w, h, tracks) + else: + bg = translate_path(background_path_nas) + cmd = build_essential_video_loop_cmd(bg, audio, out, w, h, tracks) + ... +``` + +### 7-3. Essential 정적 ffmpeg 명령 + +핵심 filter_complex 구조: + +``` +[0:v]scale=1920:1080,format=yuv420p[bg]; # 정적 배경 사진 +[1:a]showfreqs=s=400x200:mode=bar:cmode=combined:colors=0xFFFFFF@0.9[bars]; # 중앙 막대 +[2:v]format=rgba[ring]; # 데코 ring PNG (사전 제작 1장) +[bg][bars]overlay=(W-w)/2:(H-h)/2[mid]; # 막대 정중앙 배치 +[mid][ring]overlay=(W-w)/2:(H-h)/2[viz]; # ring 데코 같은 위치 +[viz]drawtext=...:enable='between(t,0,5)+between(t,173,178)+...'[final] +``` + +- `showfreqs s=400x200 mode=bar` — 가로 막대 (방사형 근사 1차 버전) +- `ring.png` — 사전 제작된 투명 PNG (`music_ai/assets/visualizer_ring.png`, 단순 흰색 원 + 외곽 점선) +- `drawtext` — 트랙 리스트 순회하며 enable expression 동적 생성 + +향후(V2): `showcqt`나 `showspectrum` 시도 + 진짜 360° 방사형은 외부 도구(예: SuperCollider, butterchurn) 검토. + +### 7-4. Essential 영상 루프 ffmpeg 명령 + +``` +[0:v]scale=1920:1080,setpts=PTS-STARTPTS[bg_loop]; +loop=loop=-1:size=N # 루프 영상 무한 반복 +[1:a]showfreqs=...[bars]; +[bg_loop][bars]overlay=center[mid]; +[mid][ring]overlay=center[viz]; +... drawtext 동일 +``` + +루프는 `-stream_loop -1 -i loop.mp4` 입력 옵션 + `-shortest` 출력으로 audio 길이만큼 반복. + +### 7-5. 자막(곡명) drawtext + +```python +def build_drawtext_filter(tracks, total_duration): + expressions = [] + for tr in tracks: + start = tr["start_offset_sec"] + end = start + 5 # 5초 표시 + # alpha fade in/out + text = tr["title"].replace(":", r"\:").replace("'", r"\'") + expressions.append( + f"drawtext=fontfile='Arial Bold':text='{text}'" + f":fontcolor=white:fontsize=36:x=(w-text_w)/2:y=h-100" + f":alpha='if(between(t,{start},{end})," + f" if(lt(t-{start},1), t-{start}," # 0~1s fade in + f" if(gt(t-{start},4), {end}-t, 1)), 0)'" # 4~5s fade out + ) + return ",".join(expressions) # 체인으로 연결 +``` + +폰트는 Windows에 기본 설치된 Arial 또는 NanumGothic 사용. 한글 트랙명 지원 위해 NanumGothic 권장. + +### 7-6. 신규 자산 파일 + +`music_ai/assets/visualizer_ring.png` — 1920×1080 캔버스 정중앙 400×400 영역에 그려진 흰색 원형 (외곽선 + 옅은 inner glow). 사전 제작 1장 — Pillow로 자동 생성도 가능 (서버 시작 시 없으면 생성). + +--- + +## 8. 프론트엔드 변경 + +### 8-1. `CompileTab.jsx` — 영상 만들기 버튼 + +완료된 compile job 카드에 버튼 추가: + +```jsx +{job.status === 'succeeded' && ( + +)} +``` + +`handleVideoFromCompile`: +```js +async (compileJobId) => { + const p = await createPipeline({ compile_job_id: compileJobId }); + await startPipeline(p.id); + // 진행 탭으로 이동 (router push 또는 setTab + setOpenPipelineFor 패턴) +}; +``` + +### 8-2. `PipelineStartModal.jsx` 확장 + +```jsx +const [inputType, setInputType] = useState('track'); // 'track' | 'compile' +const [compileJobs, setCompileJobs] = useState([]); + +useEffect(() => { + if (inputType === 'compile') getCompileJobs().then(setCompileJobs); +}, [inputType]); + +return ( +
제목: {pipeline.metadata.title}
+{pipeline.metadata.description}
+ 태그: {pipeline.metadata.tags?.join(', ')}
+| 메타데이터 품질 | {pipeline.review.metadata_quality.score} |
| 콘텐츠 정책 | {pipeline.review.policy_compliance.score} |
| 시청 경험 | {pipeline.review.viewer_experience.score} |
| 트렌드 정렬 | {pipeline.review.trend_alignment.score} |
{pipeline.review.summary}
+[{f.step}] {f.feedback_text}
+ {f.received_at}
+