diff --git a/docs/superpowers/plans/2026-04-08-music-lab-suno-enhancement.md b/docs/superpowers/plans/2026-04-08-music-lab-suno-enhancement.md
new file mode 100644
index 0000000..ac952bf
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-08-music-lab-suno-enhancement.md
@@ -0,0 +1,2594 @@
+# Music Lab Suno API 전체 기능 확장 구현 계획
+
+> **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:** Suno API의 미사용 기능을 전부 활용하여 music-lab을 완전한 AI 음악 프로덕션 스튜디오로 업그레이드한다.
+
+**Architecture:** 백엔드(music-lab)에 Suno API 신규 엔드포인트를 추가하고, 공통 폴링 헬퍼를 추출하여 중복을 제거한다. 프론트엔드(web-ui)는 1,725줄 단일 파일을 컴포넌트별로 분할하고 Phase별 UI를 추가한다.
+
+**Tech Stack:** Python 3.12, FastAPI, SQLite, React 18, Vanilla CSS, Vite
+
+**Spec:** `docs/superpowers/specs/2026-04-08-music-lab-suno-enhancement-design.md`
+
+---
+
+## 파일 구조
+
+### 백엔드 (web-backend/music-lab/)
+
+| 파일 | 작업 |
+|------|------|
+| `app/suno_provider.py` | 수정: V5_5 모델 추가, 공통 폴링 헬퍼 추출, 신규 파라미터 매핑, Phase 1~3 함수 추가 |
+| `app/main.py` | 수정: GenerateRequest 확장, Phase 1~3 엔드포인트 추가 |
+| `app/db.py` | 수정: 마이그레이션 컬럼 추가, 트랙 업데이트 함수 추가 |
+
+### 프론트엔드 (web-ui/src/)
+
+| 파일 | 작업 |
+|------|------|
+| `api.js` | 수정: Phase 1~3 API 함수 추가 |
+| `pages/music/MusicStudio.jsx` | 수정: 컴포넌트 분할 후 메인 셸 역할 |
+| `pages/music/MusicStudio.css` | 수정: 신규 컴포넌트 스타일 추가 |
+| `pages/music/components/CreditsBadge.jsx` | 생성: 크레딧 잔액 배지 |
+| `pages/music/components/CreateTab.jsx` | 생성: 생성 폼 (기존 코드 이동 + Phase 1 확장) |
+| `pages/music/components/LyricsTab.jsx` | 생성: 가사 관리 (기존 코드 이동) |
+| `pages/music/components/LibraryTab.jsx` | 생성: 라이브러리 (기존 코드 이동 + 더보기 메뉴) |
+| `pages/music/components/AudioPlayer.jsx` | 생성: 오디오 플레이어 (기존 코드 이동) |
+| `pages/music/components/CoverArtModal.jsx` | 생성: 커버 이미지 선택 모달 |
+| `pages/music/components/StemModal.jsx` | 생성: 12스템 결과 모달 |
+| `pages/music/components/SyncedLyricsPlayer.jsx` | 생성: 타임스탬프 가사 오버레이 |
+| `pages/music/components/RemixTab.jsx` | 생성: Phase 3 업로드/리믹스 탭 |
+
+---
+
+## Phase 1: 핵심 생성 강화
+
+### Task 1: 백엔드 — suno_provider 리팩토링 + V5_5 + 신규 파라미터
+
+**Files:**
+- Modify: `music-lab/app/suno_provider.py`
+
+- [ ] **Step 1: V5_5 모델 추가 + 공통 폴링 헬퍼 추출**
+
+`music-lab/app/suno_provider.py` 상단의 SUNO_MODELS에 V5_5를 추가하고, 기존 `_poll_until_complete`를 범용 헬퍼로 리팩토링:
+
+```python
+# SUNO_MODELS 리스트 끝에 추가 (line 31 뒤)
+ {"id": "V5_5", "name": "V5.5", "max_duration": "8분", "description": "커스텀 모델, 최신 음악성"},
+```
+
+`_poll_until_complete` 함수를 범용화하여 다른 Suno 작업(WAV, 스템, 커버이미지 등)에도 재사용:
+
+```python
+def _poll_suno_record(
+ record_info_path: str,
+ suno_task_id: str,
+ task_id: str,
+ max_attempts: int = POLL_MAX_ATTEMPTS,
+ interval: int = POLL_INTERVAL,
+ progress_msg_map: dict = None,
+) -> Optional[dict]:
+ """범용 Suno 작업 폴링. SUCCESS 시 response 객체 반환.
+
+ record_info_path: 예) "/generate/record-info", "/wav/record-info"
+ progress_msg_map: 상태별 메시지 오버라이드 (예: {"PENDING": "WAV 변환 대기 중..."})
+ """
+ error_statuses = {
+ "CREATE_TASK_FAILED", "GENERATE_AUDIO_FAILED",
+ "CALLBACK_EXCEPTION", "SENSITIVE_WORD_ERROR",
+ }
+ default_msgs = {
+ "PENDING": "대기열에서 대기 중...",
+ "TEXT_SUCCESS": "가사 생성 완료, 음악 생성 중...",
+ "FIRST_SUCCESS": "첫 번째 트랙 완료, 두 번째 생성 중...",
+ "GENERATING": "생성 중...",
+ }
+ msgs = {**default_msgs, **(progress_msg_map or {})}
+
+ for attempt in range(max_attempts):
+ time.sleep(interval)
+ try:
+ resp = requests.get(
+ f"{SUNO_BASE_URL}{record_info_path}",
+ headers=_headers(),
+ params={"taskId": suno_task_id},
+ timeout=15,
+ )
+ if resp.status_code != 200:
+ continue
+
+ body = resp.json()
+ if body.get("code") != 200:
+ continue
+
+ data = body.get("data", {})
+ status = data.get("status", "")
+ progress = min(15 + int((attempt / max_attempts) * 65), 79)
+
+ if status == "SUCCESS":
+ return data.get("response", data)
+ elif status in error_statuses:
+ error_msg = data.get("errorMessage") or data.get("msg") or f"Suno 작업 실패 ({status})"
+ update_task(task_id, "failed", 0, "", error=error_msg)
+ return None
+ else:
+ msg = msgs.get(status, f"처리 중... ({status})")
+ if status == "FIRST_SUCCESS":
+ progress = max(progress, 60)
+ update_task(task_id, "processing", progress, msg)
+
+ except Exception as e:
+ logger.warning("Suno poll error (attempt %d): %s", attempt, e)
+ continue
+
+ update_task(task_id, "failed", 0, "", error="Suno 작업 타임아웃")
+ return None
+```
+
+기존 `_poll_until_complete`를 호출하는 곳(`run_suno_generation`, `run_suno_extend`, `run_vocal_removal`)을 `_poll_suno_record` 호출로 교체:
+
+```python
+# run_suno_generation 내부 (기존: completed_tracks = _poll_until_complete(task_id, suno_task_id))
+response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
+if not response:
+ return
+completed_tracks = response.get("sunoData") or []
+if not completed_tracks:
+ update_task(task_id, "failed", 0, "", error="Suno 생성 완료했으나 트랙 데이터 없음")
+ return
+```
+
+`run_suno_extend`와 `run_vocal_removal`도 동일 패턴으로 교체.
+
+- [ ] **Step 2: _build_suno_payload에 신규 파라미터 매핑 추가**
+
+```python
+def _build_suno_payload(params: dict) -> dict:
+ """프론트엔드 params → sunoapi.org 요청 형식으로 변환."""
+ # ... 기존 로직 유지 ...
+
+ # 신규 파라미터 매핑 (None이 아닌 경우에만 포함)
+ if params.get("vocal_gender"):
+ payload["vocalGender"] = params["vocal_gender"]
+ if params.get("negative_tags"):
+ payload["negativeTags"] = params["negative_tags"]
+ if params.get("style_weight") is not None:
+ payload["styleWeight"] = params["style_weight"]
+ if params.get("audio_weight") is not None:
+ payload["audioWeight"] = params["audio_weight"]
+
+ return payload
+```
+
+이 4줄을 `_build_suno_payload` 함수의 `return payload` 직전에 추가.
+
+- [ ] **Step 3: 커버 이미지 생성 함수 추가**
+
+`suno_provider.py` 하단에 추가:
+
+```python
+# ── 커버 이미지 생성 ─────────────────────────────────────────────────────────
+
+def run_cover_image(task_id: str, params: dict) -> None:
+ """Suno 곡의 커버 이미지 2장을 생성."""
+ try:
+ if not SUNO_API_KEY:
+ update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
+ return
+
+ update_task(task_id, "processing", 5, "커버 이미지 생성 요청 중...")
+
+ suno_task_id = params.get("suno_task_id", "")
+ if not suno_task_id:
+ update_task(task_id, "failed", 0, "", error="suno_task_id가 필요합니다")
+ return
+
+ payload = {
+ "taskId": suno_task_id,
+ "callBackUrl": "https://example.com/noop",
+ }
+
+ resp = requests.post(
+ f"{SUNO_BASE_URL}/suno/cover/generate",
+ headers=_headers(),
+ json=payload,
+ timeout=30,
+ )
+
+ if resp.status_code != 200:
+ update_task(task_id, "failed", 0, "", error=f"커버 이미지 API 오류: {resp.text[:300]}")
+ return
+
+ body = resp.json()
+ if body.get("code") != 200:
+ update_task(task_id, "failed", 0, "", error=f"커버 이미지 거부: {body.get('msg', 'unknown')}")
+ return
+
+ cover_task_id = body.get("data", {}).get("taskId", suno_task_id)
+ update_task(task_id, "processing", 15, "커버 이미지 생성 중...")
+
+ response = _poll_suno_record(
+ "/suno/cover/record-info", cover_task_id, task_id,
+ max_attempts=30, interval=5,
+ progress_msg_map={"PENDING": "이미지 생성 대기 중...", "GENERATING": "이미지 생성 중..."},
+ )
+ if not response:
+ return
+
+ images = response.get("images") or response.get("sunoData") or []
+ image_urls = []
+ if isinstance(images, list):
+ for img in images:
+ if isinstance(img, str):
+ image_urls.append(img)
+ elif isinstance(img, dict):
+ image_urls.append(img.get("imageUrl") or img.get("image_url", ""))
+
+ update_task(task_id, "succeeded", 100, "커버 이미지 생성 완료",
+ audio_url=json.dumps(image_urls))
+
+ except Exception as e:
+ logger.exception("Cover image generation error for task %s", task_id)
+ update_task(task_id, "failed", 0, "", error=str(e))
+```
+
+suno_provider.py 상단에 `import json` 추가 필요.
+
+- [ ] **Step 4: 크레딧 조회 폴백 로직**
+
+```python
+def get_credits() -> Optional[dict]:
+ """Suno API 잔여 크레딧 조회. 두 엔드포인트 폴백."""
+ if not SUNO_API_KEY:
+ return None
+ # 신규 엔드포인트 먼저 시도
+ for path in ["/generate/credit", "/get-credits"]:
+ try:
+ resp = requests.get(
+ f"{SUNO_BASE_URL}{path}",
+ headers=_headers(),
+ timeout=15,
+ )
+ if resp.status_code == 200:
+ body = resp.json()
+ data = body.get("data", body)
+ # /generate/credit은 정수 반환
+ if isinstance(data, (int, float)):
+ return {"credits_left": int(data)}
+ return data
+ except Exception as e:
+ logger.warning("Suno credits API error (%s): %s", path, e)
+ return None
+```
+
+- [ ] **Step 5: 커밋**
+
+```bash
+git add music-lab/app/suno_provider.py
+git commit -m "refactor(music-lab): 공통 폴링 헬퍼 추출 + V5_5 모델 + 신규 파라미터 + 커버이미지"
+```
+
+---
+
+### Task 2: 백엔드 — DB 마이그레이션 + main.py 엔드포인트
+
+**Files:**
+- Modify: `music-lab/app/db.py`
+- Modify: `music-lab/app/main.py`
+
+- [ ] **Step 1: db.py 마이그레이션 + 업데이트 함수 추가**
+
+`db.py`의 `init_db()` 함수, 기존 마이그레이션 루프(line 72~) 뒤에 추가:
+
+```python
+ # Phase 1~3 신규 컬럼 마이그레이션
+ for col, default in [
+ ("cover_images", "'[]'"),
+ ("wav_url", "''"),
+ ("video_url", "''"),
+ ("stem_urls", "'{}'"),
+ ]:
+ try:
+ conn.execute(f"ALTER TABLE music_library ADD COLUMN {col} TEXT NOT NULL DEFAULT {default}")
+ except sqlite3.OperationalError:
+ pass
+```
+
+`_track_row_to_dict` 함수에 신규 컬럼 매핑 추가 (line 164 뒤):
+
+```python
+ "cover_images": json.loads(r["cover_images"]) if "cover_images" in keys and r["cover_images"] else [],
+ "wav_url": r["wav_url"] if "wav_url" in keys else "",
+ "video_url": r["video_url"] if "video_url" in keys else "",
+ "stem_urls": json.loads(r["stem_urls"]) if "stem_urls" in keys and r["stem_urls"] else {},
+```
+
+파일 하단에 업데이트 함수 추가:
+
+```python
+def update_track_cover_images(track_id: int, images: list) -> None:
+ with _conn() as conn:
+ conn.execute(
+ "UPDATE music_library SET cover_images=? WHERE id=?",
+ (json.dumps(images), track_id),
+ )
+
+
+def update_track_wav_url(track_id: int, wav_url: str) -> None:
+ with _conn() as conn:
+ conn.execute(
+ "UPDATE music_library SET wav_url=? WHERE id=?",
+ (wav_url, track_id),
+ )
+
+
+def update_track_video_url(track_id: int, video_url: str) -> None:
+ with _conn() as conn:
+ conn.execute(
+ "UPDATE music_library SET video_url=? WHERE id=?",
+ (video_url, track_id),
+ )
+
+
+def update_track_stem_urls(track_id: int, stems: dict) -> None:
+ with _conn() as conn:
+ conn.execute(
+ "UPDATE music_library SET stem_urls=? WHERE id=?",
+ (json.dumps(stems), track_id),
+ )
+```
+
+- [ ] **Step 2: main.py — GenerateRequest 스키마 확장**
+
+`main.py`의 `GenerateRequest` 클래스(line 90~)에 필드 추가:
+
+```python
+class GenerateRequest(BaseModel):
+ provider: str = "suno"
+ model: str = "V4"
+ title: str = ""
+ genre: str = ""
+ moods: List[str] = []
+ instruments: List[str] = []
+ duration_sec: Optional[int] = None
+ bpm: Optional[int] = None
+ key: str = ""
+ scale: str = ""
+ prompt: str = ""
+ # Suno 전용
+ lyrics: str = ""
+ instrumental: bool = False
+ # Phase 1 신규
+ vocal_gender: Optional[str] = None # "m" | "f"
+ negative_tags: Optional[str] = None # 제외 스타일
+ style_weight: Optional[float] = None # 0.0~1.0
+ audio_weight: Optional[float] = None # 0.0~1.0
+```
+
+- [ ] **Step 3: main.py — 커버 이미지 엔드포인트 추가**
+
+`main.py` import에 `run_cover_image` 추가, 보컬분리 API 뒤에 엔드포인트 추가:
+
+```python
+from .suno_provider import (
+ run_suno_generation, run_suno_extend, run_vocal_removal,
+ run_cover_image,
+ generate_lyrics, get_credits,
+ SUNO_API_KEY, SUNO_MODELS,
+)
+
+
+# ── 커버 이미지 생성 API ────────────────────────────────────────────────────
+
+class CoverImageRequest(BaseModel):
+ suno_task_id: str # Suno 생성 task ID
+ track_id: Optional[int] = None # 라이브러리 트랙 ID (결과 저장용)
+
+
+@app.post("/api/music/cover-image")
+def cover_image(req: CoverImageRequest, background_tasks: BackgroundTasks):
+ """Suno 곡의 커버 이미지 2장 생성."""
+ if not SUNO_API_KEY:
+ raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
+
+ task_id = str(uuid.uuid4())
+ params = req.model_dump()
+ create_task(task_id, params, provider="suno")
+ background_tasks.add_task(run_cover_image, task_id, params)
+ return {"task_id": task_id, "provider": "suno"}
+```
+
+- [ ] **Step 4: 커밋**
+
+```bash
+git add music-lab/app/db.py music-lab/app/main.py
+git commit -m "feat(music-lab): Phase 1 DB 마이그레이션 + GenerateRequest 확장 + 커버이미지 엔드포인트"
+```
+
+---
+
+### Task 3: 프론트엔드 — 컴포넌트 분할
+
+**Files:**
+- Modify: `web-ui/src/pages/music/MusicStudio.jsx`
+- Create: `web-ui/src/pages/music/components/AudioPlayer.jsx`
+- Create: `web-ui/src/pages/music/components/LyricsTab.jsx`
+- Create: `web-ui/src/pages/music/components/CreditsBadge.jsx`
+
+이 태스크는 기존 MusicStudio.jsx에서 독립적인 컴포넌트를 별도 파일로 추출한다. 기능 변경 없이 구조만 변경.
+
+- [ ] **Step 1: AudioPlayer 컴포넌트 추출**
+
+`web-ui/src/pages/music/components/AudioPlayer.jsx` 생성:
+
+```jsx
+import React, { useEffect, useRef, useState } from 'react';
+
+const pad = (n) => String(Math.floor(n)).padStart(2, '0');
+export const fmtTime = (s) => `${pad(s / 60)}:${pad(s % 60)}`;
+
+const AudioPlayer = ({ audioUrl, totalSec, accentColor }) => {
+ const audioRef = useRef(null);
+ const [playing, setPlaying] = useState(false);
+ const [elapsed, setElapsed] = useState(0);
+ const [duration, setDuration] = useState(totalSec ?? 0);
+ const [volume, setVolume] = useState(1);
+
+ const isFake = !audioUrl;
+ const timerRef = useRef(null);
+ const total = duration || totalSec || 60;
+
+ const togglePlay = () => {
+ if (isFake) {
+ if (playing) {
+ clearInterval(timerRef.current);
+ setPlaying(false);
+ } else {
+ setPlaying(true);
+ timerRef.current = setInterval(() => {
+ setElapsed((e) => {
+ if (e >= total - 1) {
+ clearInterval(timerRef.current);
+ setPlaying(false);
+ return 0;
+ }
+ return e + 1;
+ });
+ }, 1000);
+ }
+ return;
+ }
+ const el = audioRef.current;
+ if (!el) return;
+ playing ? el.pause() : el.play();
+ };
+
+ const handleSeek = (e) => {
+ const rect = e.currentTarget.getBoundingClientRect();
+ const ratio = (e.clientX - rect.left) / rect.width;
+ const newTime = ratio * total;
+ if (!isFake && audioRef.current) {
+ audioRef.current.currentTime = newTime;
+ }
+ setElapsed(newTime);
+ };
+
+ const handleVolumeChange = (e) => {
+ const v = Number(e.target.value);
+ setVolume(v);
+ if (!isFake && audioRef.current) audioRef.current.volume = v;
+ };
+
+ useEffect(() => () => clearInterval(timerRef.current), []);
+
+ const progress = (elapsed / total) * 100;
+
+ return (
+
+ {!isFake && (
+
+ );
+};
+
+export default AudioPlayer;
+```
+
+- [ ] **Step 2: CreditsBadge 컴포넌트 생성**
+
+`web-ui/src/pages/music/components/CreditsBadge.jsx` 생성:
+
+```jsx
+import React, { useEffect, useState, useCallback } from 'react';
+import { getMusicCredits } from '../../../api';
+
+const CreditsBadge = () => {
+ const [credits, setCredits] = useState(null);
+
+ const fetchCredits = useCallback(async () => {
+ try {
+ const data = await getMusicCredits();
+ setCredits(data);
+ } catch {}
+ }, []);
+
+ useEffect(() => {
+ fetchCredits();
+ const interval = setInterval(fetchCredits, 30000);
+ return () => clearInterval(interval);
+ }, [fetchCredits]);
+
+ if (!credits) return null;
+
+ const remaining = credits.credits_left ?? credits.remaining ?? credits.data ?? null;
+ if (remaining == null) return null;
+
+ const isLow = remaining <= 10;
+
+ return (
+
+ ⚡
+ {remaining}
+ credits
+
+ );
+};
+
+export default CreditsBadge;
+```
+
+- [ ] **Step 3: LyricsTab 추출**
+
+`web-ui/src/pages/music/components/LyricsTab.jsx` 생성 — 기존 MusicStudio.jsx의 `LyricsTab` 컴포넌트(line 617~847)를 그대로 이동. import 경로를 상대경로로 변경:
+
+```jsx
+import React, { useEffect, useState } from 'react';
+import {
+ generateMusicLyrics,
+ getSavedLyrics,
+ saveLyrics,
+ updateLyrics,
+ deleteLyrics,
+} from '../../../api';
+
+const LyricsTab = ({ onUseInCreate }) => {
+ // ... 기존 LyricsTab 코드 전체 (line 618~847) 그대로 이동 ...
+};
+
+export default LyricsTab;
+```
+
+- [ ] **Step 4: MusicStudio.jsx에서 추출된 컴포넌트 import로 교체**
+
+```jsx
+// MusicStudio.jsx 상단 import 변경
+import AudioPlayer from './components/AudioPlayer';
+import { fmtTime } from './components/AudioPlayer';
+import CreditsBadge from './components/CreditsBadge';
+import LyricsTab from './components/LyricsTab';
+```
+
+기존 인라인 `AudioPlayer`, `LyricsTab`, `fmtTime`, `pad` 정의를 삭제.
+
+헤더 영역의 크레딧 표시(line 1191~1198)를 `` 로 교체:
+
+```jsx
+
+
+
+
+```
+
+기존 `credits` 상태 변수와 관련 로직 제거:
+- `const [credits, setCredits] = useState(null);` 삭제
+- `getMusicCredits().then(...)` 호출 삭제
+
+- [ ] **Step 5: 커밋**
+
+```bash
+git add web-ui/src/pages/music/
+git commit -m "refactor(music-lab): 컴포넌트 분할 — AudioPlayer, LyricsTab, CreditsBadge 추출"
+```
+
+---
+
+### Task 4: 프론트엔드 — Create 탭 Phase 1 확장 (보컬 성별, negativeTags, weight 슬라이더)
+
+**Files:**
+- Modify: `web-ui/src/pages/music/MusicStudio.jsx`
+- Modify: `web-ui/src/pages/music/MusicStudio.css`
+
+- [ ] **Step 1: 상태 변수 추가**
+
+MusicStudio.jsx의 상태 선언 영역(line 870~) 뒤에 추가:
+
+```jsx
+ /* ── Phase 1: 신규 파라미터 ── */
+ const [vocalGender, setVocalGender] = useState(null); // "m" | "f" | null
+ const [negativeTags, setNegativeTags] = useState('');
+ const [styleWeight, setStyleWeight] = useState(50); // UI: 0~100, API: 0~1
+ const [audioWeight, setAudioWeight] = useState(50);
+```
+
+- [ ] **Step 2: handleGenerate 페이로드에 신규 파라미터 포함**
+
+기존 payload 조립(line 1063~) 수정:
+
+```jsx
+ const payload = {
+ provider,
+ model,
+ title,
+ genre,
+ moods,
+ instruments: instList,
+ duration_sec: durSec,
+ bpm,
+ key: musicalKey,
+ scale,
+ prompt: prompt || undefined,
+ ...(provider === 'suno' ? {
+ lyrics: lyrics || undefined,
+ instrumental,
+ vocal_gender: vocalGender || undefined,
+ negative_tags: negativeTags || undefined,
+ style_weight: styleWeight !== 50 ? styleWeight / 100 : undefined,
+ audio_weight: audioWeight !== 50 ? audioWeight / 100 : undefined,
+ } : {}),
+ };
+```
+
+- [ ] **Step 3: Step 4 (Parameters) 섹션에 UI 추가**
+
+Key+Scale 그리드(line 1489~1531) 뒤, `` 닫기 전에 추가:
+
+```jsx
+ {/* Vocal Gender (Suno only) */}
+ {provider === 'suno' && (
+
+
+
+ {[
+ { value: null, label: 'Auto', icon: '🎵' },
+ { value: 'm', label: 'Male', icon: '♂' },
+ { value: 'f', label: 'Female', icon: '♀' },
+ ].map((opt) => (
+
+ ))}
+
+
+ )}
+
+ {/* Negative Tags (Suno only) */}
+ {provider === 'suno' && (
+
+
+
+
+ {['screaming', 'autotune', 'distortion', 'whisper', 'falsetto', 'rap'].map((tag) => (
+
+ ))}
+
+
setNegativeTags(e.target.value)}
+ />
+
+
+ )}
+
+ {/* Style Weight / Audio Weight (Suno only) */}
+ {provider === 'suno' && (
+
+
+
+
+ {styleWeight}%
+
+
Prompt ↔ Style 밸런스
+
setStyleWeight(Number(e.target.value))}
+ className="ms-bpm-slider"
+ aria-label="Style Weight"
+ />
+
+
+
+
+ {audioWeight}%
+
+
Original ↔ AI 밸런스
+
setAudioWeight(Number(e.target.value))}
+ className="ms-bpm-slider"
+ aria-label="Audio Weight"
+ />
+
+
+ )}
+```
+
+- [ ] **Step 4: CSS 스타일 추가**
+
+`web-ui/src/pages/music/MusicStudio.css` 하단에 추가:
+
+```css
+/* ── Phase 1: Credits Badge ─────────────────────────────── */
+.ms-credits-badge {
+ display: inline-flex; align-items: center; gap: 6px;
+ padding: 6px 14px; border-radius: 20px;
+ background: rgba(245, 166, 35, 0.1);
+ border: 1px solid rgba(245, 166, 35, 0.25);
+ font-family: 'Courier Prime', monospace;
+ font-size: 0.85rem; color: var(--ms-accent);
+}
+.ms-credits-badge__icon { font-size: 1rem; }
+.ms-credits-badge__value { font-weight: 700; font-size: 1.1rem; }
+.ms-credits-badge__label { color: var(--ms-muted); font-size: 0.75rem; text-transform: uppercase; }
+.ms-credits-badge.is-low {
+ background: rgba(231, 76, 60, 0.15);
+ border-color: rgba(231, 76, 60, 0.4);
+ color: #e74c3c;
+ animation: pulse-badge 1.5s ease-in-out infinite;
+}
+@keyframes pulse-badge {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.6; }
+}
+
+/* ── Phase 1: Vocal Gender Toggle ───────────────────────── */
+.ms-gender-toggle {
+ display: flex; gap: 6px;
+}
+.ms-gender-btn {
+ flex: 1; padding: 8px 12px; border-radius: 8px;
+ background: var(--ms-surface); border: 1px solid var(--ms-line);
+ color: var(--ms-muted); font-family: 'Syne', sans-serif;
+ font-size: 0.82rem; cursor: pointer; transition: all 0.2s;
+ display: flex; align-items: center; gap: 6px; justify-content: center;
+}
+.ms-gender-btn:hover { border-color: var(--ms-accent); color: var(--ms-text); }
+.ms-gender-btn.is-active { background: rgba(245, 166, 35, 0.12); border-color: var(--ms-accent); color: var(--ms-text); }
+.ms-gender-btn.is-active.is-male { background: rgba(74, 158, 255, 0.12); border-color: #4a9eff; color: #4a9eff; }
+.ms-gender-btn.is-active.is-female { background: rgba(255, 107, 157, 0.12); border-color: #ff6b9d; color: #ff6b9d; }
+.ms-gender-btn__icon { font-size: 1.1rem; }
+
+/* ── Phase 1: Negative Tags ─────────────────────────────── */
+.ms-negative-tags { display: flex; flex-direction: column; gap: 8px; }
+.ms-negative-tags__presets { display: flex; flex-wrap: wrap; gap: 6px; }
+.ms-neg-chip {
+ padding: 4px 12px; border-radius: 14px;
+ background: var(--ms-surface); border: 1px solid var(--ms-line);
+ color: var(--ms-muted); font-size: 0.78rem; cursor: pointer;
+ font-family: 'Syne', sans-serif; transition: all 0.2s;
+}
+.ms-neg-chip:hover { border-color: #e74c3c; color: var(--ms-text); }
+.ms-neg-chip.is-active {
+ background: rgba(231, 76, 60, 0.12); border-color: #e74c3c; color: #e74c3c;
+ text-decoration: line-through;
+}
+.ms-negative-tags__input {
+ padding: 8px 12px; border-radius: 8px;
+ background: var(--ms-surface); border: 1px solid var(--ms-line);
+ color: var(--ms-text); font-family: 'Syne', sans-serif; font-size: 0.82rem;
+}
+.ms-negative-tags__input::placeholder { color: var(--ms-dim); }
+
+/* ── Phase 1: Param hint inline ─────────────────────────── */
+.ms-param-hint--inline {
+ font-size: 0.72rem; color: var(--ms-dim); margin: 0 0 4px;
+ font-family: 'Courier Prime', monospace;
+}
+```
+
+- [ ] **Step 5: 커밋**
+
+```bash
+git add web-ui/src/pages/music/
+git commit -m "feat(music-lab): Phase 1 UI — 보컬 성별, 제외 스타일, weight 슬라이더, 크레딧 배지"
+```
+
+---
+
+### Task 5: 프론트엔드 — LibraryCard 더보기 메뉴 + CoverArtModal
+
+**Files:**
+- Modify: `web-ui/src/pages/music/MusicStudio.jsx` (LibraryCard 수정)
+- Create: `web-ui/src/pages/music/components/CoverArtModal.jsx`
+- Modify: `web-ui/src/pages/music/MusicStudio.css`
+- Modify: `web-ui/src/api.js`
+
+- [ ] **Step 1: api.js에 커버 이미지 API 함수 추가**
+
+`web-ui/src/api.js`의 음악 API 섹션(line 313 뒤)에 추가:
+
+```javascript
+// POST /api/music/cover-image body: { suno_task_id, track_id }
+// → { task_id, provider }
+export function generateCoverImage(payload) {
+ return apiPost('/api/music/cover-image', payload);
+}
+```
+
+- [ ] **Step 2: CoverArtModal 컴포넌트 생성**
+
+`web-ui/src/pages/music/components/CoverArtModal.jsx` 생성:
+
+```jsx
+import React, { useState } from 'react';
+
+const CoverArtModal = ({ images, onSelect, onClose }) => {
+ const [selected, setSelected] = useState(null);
+
+ if (!images || images.length === 0) return null;
+
+ return (
+
+
e.stopPropagation()}>
+
+
Cover Art 선택
+
+
+
+ {images.map((url, idx) => (
+
+ ))}
+
+
+
+
+
+
+
+ );
+};
+
+export default CoverArtModal;
+```
+
+- [ ] **Step 3: LibraryCard에 더보기 메뉴 + 커버아트 핸들러 추가**
+
+MusicStudio.jsx의 `LibraryCard` 컴포넌트를 수정. props에 `onCoverArt` 추가:
+
+```jsx
+const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, isGenerating }) => {
+ const [menuOpen, setMenuOpen] = useState(false);
+ const genre = GENRES.find((g) => g.id === track.genre);
+ const totalSec = track.duration_sec ?? null;
+ const filename = track.audio_url ? track.audio_url.split('/').pop() : '';
+ const hasSunoId = !!track.suno_id;
+
+ return (
+
+
+
{genre?.icon ?? '🎵'}
+ {track.cover_images?.[0] && (
+

+ )}
+
{track.title}
+
+
+ {track.audio_url && (
+
↓
+ )}
+
+
+
+
+
{filename}
+
+ {totalSec != null ? fmtTime(totalSec) : '--:--'} · {track.bpm ? `${track.bpm} BPM` : ''} {track.key} {track.scale}
+
+
+ {isPlaying && (
+
+ )}
+
+ {track.provider && (
+
+ {track.provider === 'suno' ? '🎙️ Suno' : '🤖 MusicGen'}
+
+ )}
+ {(track.instruments ?? []).slice(0, 3).map((i) => (
+ {i}
+ ))}
+ {(track.moods ?? []).slice(0, 2).map((m) => (
+ {m}
+ ))}
+
+ {hasSunoId && (
+
+
+
+ {/* 더보기 메뉴 */}
+
+
+ {menuOpen && (
+
+
+
+ )}
+
+
+ )}
+
+ {track.created_at ? new Date(track.created_at).toLocaleDateString('ko-KR') : ''}
+
+
+ );
+};
+```
+
+- [ ] **Step 4: MusicStudio 메인에 커버아트 핸들러 + 모달 추가**
+
+```jsx
+import CoverArtModal from './components/CoverArtModal';
+import { generateCoverImage } from '../../api';
+
+// 상태 추가
+const [coverArtModal, setCoverArtModal] = useState(null); // { trackId, images }
+
+// 핸들러 추가
+const handleCoverArt = async (track) => {
+ if (!track.task_id || isGenerating) return;
+ setTab('create');
+ setIsGenerating(true);
+ setTrack(null);
+ setGenProgress(0);
+ setGenStep('커버 이미지 생성 요청 중…');
+ setGenError(null);
+ try {
+ const res = await generateCoverImage({
+ suno_task_id: track.task_id,
+ track_id: track.id,
+ });
+ if (res?.task_id) {
+ taskIdRef.current = res.task_id;
+ setGenStep('AI가 커버 이미지를 생성하고 있습니다…');
+ setGenProgress(5);
+ // 커버 이미지용 폴링 — 완료 시 이미지 URL 배열이 audio_url에 JSON으로 들어옴
+ clearInterval(pollRef.current);
+ pollRef.current = setInterval(async () => {
+ try {
+ const status = await getMusicStatus(res.task_id);
+ setGenProgress(status.progress ?? 0);
+ setGenStep(status.message ?? '처리 중…');
+ if (status.status === 'succeeded') {
+ clearInterval(pollRef.current);
+ setIsGenerating(false);
+ const images = JSON.parse(status.audio_url || '[]');
+ setCoverArtModal({ trackId: track.id, images });
+ } else if (status.status === 'failed') {
+ clearInterval(pollRef.current);
+ setIsGenerating(false);
+ setGenError(`커버 이미지 생성 실패: ${status.error ?? '알 수 없는 오류'}`);
+ }
+ } catch {
+ clearInterval(pollRef.current);
+ setIsGenerating(false);
+ setGenError('커버 이미지 상태 조회 실패');
+ }
+ }, 3000);
+ }
+ } catch {
+ setIsGenerating(false);
+ setGenError('커버 이미지 생성에 실패했습니다');
+ }
+};
+
+const handleCoverSelect = (imageUrl) => {
+ if (coverArtModal?.trackId) {
+ setLibrary((prev) => prev.map((t) =>
+ t.id === coverArtModal.trackId
+ ? { ...t, cover_images: [imageUrl, ...(coverArtModal.images || []).filter(u => u !== imageUrl)] }
+ : t
+ ));
+ }
+ setCoverArtModal(null);
+};
+
+// JSX에 모달 추가 (최하단 닫기 div 전)
+{coverArtModal && (
+ setCoverArtModal(null)}
+ />
+)}
+
+// Library 컴포넌트에 onCoverArt prop 전달
+
+```
+
+Library 컴포넌트에도 `onCoverArt` prop을 받아서 LibraryCard에 전달하도록 수정.
+
+- [ ] **Step 5: 더보기 메뉴 + 모달 CSS**
+
+```css
+/* ── More Menu ──────────────────────────────────────────── */
+.ms-more-menu { position: relative; }
+.ms-more-menu__dropdown {
+ position: absolute; bottom: 100%; right: 0;
+ background: var(--ms-surface2); border: 1px solid var(--ms-line);
+ border-radius: 8px; padding: 4px; min-width: 160px; z-index: 20;
+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
+}
+.ms-more-menu__dropdown button {
+ display: block; width: 100%; padding: 8px 12px; border: none;
+ background: none; color: var(--ms-text); font-size: 0.82rem;
+ font-family: 'Syne', sans-serif; cursor: pointer; text-align: left;
+ border-radius: 6px;
+}
+.ms-more-menu__dropdown button:hover { background: rgba(245,166,35,0.1); }
+.ms-more-menu__dropdown button:disabled { opacity: 0.4; cursor: not-allowed; }
+
+/* ── Modal Overlay ──────────────────────────────────────── */
+.ms-modal-overlay {
+ position: fixed; inset: 0; background: rgba(0,0,0,0.7);
+ display: flex; align-items: center; justify-content: center; z-index: 100;
+}
+.ms-modal {
+ background: var(--ms-surface); border: 1px solid var(--ms-line);
+ border-radius: 16px; padding: 24px; max-width: 520px; width: 90%;
+ max-height: 90vh; overflow-y: auto;
+}
+.ms-modal__header {
+ display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;
+}
+.ms-modal__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.3rem; color: var(--ms-text); }
+.ms-modal__close { background: none; border: none; color: var(--ms-muted); font-size: 1.2rem; cursor: pointer; }
+.ms-modal__actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
+
+/* ── Cover Art Grid ─────────────────────────────────────── */
+.ms-cover-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
+.ms-cover-option {
+ border: 2px solid var(--ms-line); border-radius: 12px; overflow: hidden;
+ cursor: pointer; background: none; padding: 0; transition: border-color 0.2s;
+}
+.ms-cover-option:hover { border-color: var(--ms-accent); }
+.ms-cover-option.is-selected { border-color: var(--ms-accent); box-shadow: 0 0 12px rgba(245,166,35,0.3); }
+.ms-cover-option__img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
+.ms-cover-option__label {
+ display: block; padding: 8px; text-align: center;
+ font-family: 'Courier Prime', monospace; font-size: 0.78rem; color: var(--ms-muted);
+}
+
+/* ── Library Card Thumb ─────────────────────────────────── */
+.ms-lib-card__thumb {
+ width: 28px; height: 28px; border-radius: 6px; object-fit: cover;
+ margin-right: 4px; flex-shrink: 0;
+}
+```
+
+- [ ] **Step 6: 커밋**
+
+```bash
+git add web-ui/src/pages/music/ web-ui/src/api.js
+git commit -m "feat(music-lab): Phase 1 UI — LibraryCard 더보기 메뉴 + CoverArtModal"
+```
+
+---
+
+## Phase 2: 후처리 파워업
+
+### Task 6: 백엔드 — WAV 변환 + 12스템 분리 + 타임스탬프 가사 + 스타일 부스트
+
+**Files:**
+- Modify: `music-lab/app/suno_provider.py`
+- Modify: `music-lab/app/main.py`
+
+- [ ] **Step 1: suno_provider.py에 Phase 2 함수 추가**
+
+```python
+# ── WAV 변환 ─────────────────────────────────────────────────────────────────
+
+def run_wav_convert(task_id: str, params: dict) -> None:
+ """곡을 WAV 포맷으로 변환."""
+ try:
+ if not SUNO_API_KEY:
+ update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
+ return
+
+ update_task(task_id, "processing", 5, "WAV 변환 요청 중...")
+
+ payload = {
+ "taskId": params["suno_task_id"],
+ "audioId": params["suno_id"],
+ "callBackUrl": "https://example.com/noop",
+ }
+
+ resp = requests.post(
+ f"{SUNO_BASE_URL}/wav/generate",
+ headers=_headers(),
+ json=payload,
+ timeout=30,
+ )
+
+ if resp.status_code == 409:
+ # 이미 WAV 변환됨 — 기존 결과 조회
+ body = resp.json()
+ wav_url = body.get("data", {}).get("audioWavUrl", "")
+ if wav_url:
+ update_task(task_id, "succeeded", 100, "WAV 변환 완료 (캐시)", audio_url=wav_url)
+ return
+
+ if resp.status_code != 200:
+ update_task(task_id, "failed", 0, "", error=f"WAV API 오류: {resp.text[:300]}")
+ return
+
+ body = resp.json()
+ if body.get("code") != 200:
+ update_task(task_id, "failed", 0, "", error=f"WAV 변환 거부: {body.get('msg', 'unknown')}")
+ return
+
+ wav_task_id = body.get("data", {}).get("taskId", params["suno_task_id"])
+ update_task(task_id, "processing", 15, "WAV 변환 처리 중...")
+
+ response = _poll_suno_record(
+ "/wav/record-info", wav_task_id, task_id,
+ max_attempts=30, interval=5,
+ progress_msg_map={"PENDING": "WAV 변환 대기 중...", "GENERATING": "WAV 변환 중..."},
+ )
+ if not response:
+ return
+
+ wav_url = ""
+ suno_data = response.get("sunoData") or []
+ if suno_data and isinstance(suno_data, list):
+ wav_url = suno_data[0].get("audioWavUrl", "") if isinstance(suno_data[0], dict) else ""
+ if not wav_url:
+ wav_url = response.get("audioWavUrl", "")
+
+ update_task(task_id, "succeeded", 100, "WAV 변환 완료", audio_url=wav_url)
+
+ except Exception as e:
+ logger.exception("WAV convert error for task %s", task_id)
+ update_task(task_id, "failed", 0, "", error=str(e))
+
+
+# ── 12스템 분리 ──────────────────────────────────────────────────────────────
+
+def run_stem_split(task_id: str, params: dict) -> None:
+ """곡을 12개 스템으로 분리 (50 크레딧 소모)."""
+ try:
+ if not SUNO_API_KEY:
+ update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
+ return
+
+ update_task(task_id, "processing", 5, "12스템 분리 요청 중...")
+
+ payload = {
+ "taskId": params["suno_task_id"],
+ "audioId": params["suno_id"],
+ "type": "split_stem",
+ "callBackUrl": "https://example.com/noop",
+ }
+
+ resp = requests.post(
+ f"{SUNO_BASE_URL}/vocal-removal/generate",
+ headers=_headers(),
+ json=payload,
+ timeout=30,
+ )
+
+ if resp.status_code != 200:
+ update_task(task_id, "failed", 0, "", error=f"스템 분리 API 오류: {resp.text[:300]}")
+ return
+
+ body = resp.json()
+ if body.get("code") != 200:
+ update_task(task_id, "failed", 0, "", error=f"스템 분리 거부: {body.get('msg', 'unknown')}")
+ return
+
+ stem_task_id = body.get("data", {}).get("taskId", "")
+ if not stem_task_id:
+ update_task(task_id, "failed", 0, "", error="스템 분리 응답에 taskId 없음")
+ return
+
+ update_task(task_id, "processing", 15, "12스템 분리 처리 중 (약 2~3분)...")
+
+ response = _poll_suno_record(
+ "/vocal-removal/record-info", stem_task_id, task_id,
+ max_attempts=40, interval=8,
+ progress_msg_map={"PENDING": "스템 분리 대기 중...", "GENERATING": "스템 분리 중..."},
+ )
+ if not response:
+ return
+
+ suno_data = response.get("sunoData") or []
+ stems = {}
+ stem_names = ["vocal", "backing_vocals", "drums", "bass", "guitar", "keyboard",
+ "strings", "brass", "woodwinds", "percussion", "synth", "fx"]
+ for i, item in enumerate(suno_data):
+ if isinstance(item, dict):
+ name = stem_names[i] if i < len(stem_names) else f"stem_{i}"
+ stems[name] = item.get("audioUrl") or item.get("audio_url", "")
+
+ update_task(task_id, "succeeded", 100, "12스템 분리 완료",
+ audio_url=json.dumps(stems))
+
+ except Exception as e:
+ logger.exception("Stem split error for task %s", task_id)
+ update_task(task_id, "failed", 0, "", error=str(e))
+
+
+# ── 타임스탬프 가사 ──────────────────────────────────────────────────────────
+
+def get_timestamped_lyrics(suno_task_id: str, suno_id: str) -> Optional[dict]:
+ """타임스탬프가 포함된 가사 데이터 조회 (동기)."""
+ if not SUNO_API_KEY:
+ return None
+ try:
+ resp = requests.post(
+ f"{SUNO_BASE_URL}/generate/get-timestamped-lyrics",
+ headers=_headers(),
+ json={"taskId": suno_task_id, "audioId": suno_id},
+ timeout=30,
+ )
+ if resp.status_code == 200:
+ body = resp.json()
+ return body.get("data", body)
+ except Exception as e:
+ logger.warning("Timestamped lyrics error: %s", e)
+ return None
+
+
+# ── 스타일 부스트 ────────────────────────────────────────────────────────────
+
+def generate_style_boost(content: str) -> Optional[dict]:
+ """AI로 최적 스타일 텍스트 생성 (동기)."""
+ if not SUNO_API_KEY:
+ return None
+ try:
+ resp = requests.post(
+ f"{SUNO_BASE_URL}/style/generate",
+ headers=_headers(),
+ json={"content": content},
+ timeout=30,
+ )
+ if resp.status_code == 200:
+ body = resp.json()
+ return body.get("data", body)
+ except Exception as e:
+ logger.warning("Style boost error: %s", e)
+ return None
+```
+
+- [ ] **Step 2: main.py에 Phase 2 엔드포인트 추가**
+
+import 업데이트:
+
+```python
+from .suno_provider import (
+ run_suno_generation, run_suno_extend, run_vocal_removal,
+ run_cover_image, run_wav_convert, run_stem_split,
+ generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost,
+ SUNO_API_KEY, SUNO_MODELS,
+)
+```
+
+엔드포인트 추가:
+
+```python
+# ── WAV 변환 API ────────────────────────────────────────────────────────────
+
+class WavRequest(BaseModel):
+ suno_task_id: str
+ suno_id: str
+ track_id: Optional[int] = None
+
+
+@app.post("/api/music/wav")
+def wav_convert(req: WavRequest, background_tasks: BackgroundTasks):
+ """곡을 WAV 포맷으로 변환."""
+ if not SUNO_API_KEY:
+ raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
+ task_id = str(uuid.uuid4())
+ params = req.model_dump()
+ create_task(task_id, params, provider="suno")
+ background_tasks.add_task(run_wav_convert, task_id, params)
+ return {"task_id": task_id, "provider": "suno"}
+
+
+# ── 12스템 분리 API ─────────────────────────────────────────────────────────
+
+class StemSplitRequest(BaseModel):
+ suno_task_id: str
+ suno_id: str
+ track_id: Optional[int] = None
+
+
+@app.post("/api/music/stem-split")
+def stem_split(req: StemSplitRequest, background_tasks: BackgroundTasks):
+ """곡을 12개 스템으로 분리 (50 크레딧). 보컬, 드럼, 베이스, 기타 등."""
+ if not SUNO_API_KEY:
+ raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
+ task_id = str(uuid.uuid4())
+ params = req.model_dump()
+ create_task(task_id, params, provider="suno")
+ background_tasks.add_task(run_stem_split, task_id, params)
+ return {"task_id": task_id, "provider": "suno"}
+
+
+# ── 타임스탬프 가사 API ─────────────────────────────────────────────────────
+
+@app.get("/api/music/timestamped-lyrics")
+def timestamped_lyrics(task_id: str, suno_id: str):
+ """타임스탬프 가사 조회 (가라오케 스타일 싱크용)."""
+ if not SUNO_API_KEY:
+ raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
+ result = get_timestamped_lyrics(task_id, suno_id)
+ if not result:
+ raise HTTPException(status_code=502, detail="타임스탬프 가사 조회 실패")
+ return result
+
+
+# ── 스타일 부스트 API ───────────────────────────────────────────────────────
+
+class StyleBoostRequest(BaseModel):
+ content: str
+
+
+@app.post("/api/music/style-boost")
+def style_boost(req: StyleBoostRequest):
+ """AI로 최적 스타일 프롬프트 생성."""
+ if not SUNO_API_KEY:
+ raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
+ result = generate_style_boost(req.content)
+ if not result:
+ raise HTTPException(status_code=502, detail="스타일 부스트 생성 실패")
+ return result
+```
+
+- [ ] **Step 3: 커밋**
+
+```bash
+git add music-lab/app/suno_provider.py music-lab/app/main.py
+git commit -m "feat(music-lab): Phase 2 백엔드 — WAV 변환, 12스템 분리, 타임스탬프 가사, 스타일 부스트"
+```
+
+---
+
+### Task 7: 프론트엔드 — Phase 2 UI (StemModal, 타임스탬프 가사, 스타일 부스트)
+
+**Files:**
+- Modify: `web-ui/src/api.js`
+- Create: `web-ui/src/pages/music/components/StemModal.jsx`
+- Create: `web-ui/src/pages/music/components/SyncedLyricsPlayer.jsx`
+- Modify: `web-ui/src/pages/music/MusicStudio.jsx`
+- Modify: `web-ui/src/pages/music/MusicStudio.css`
+
+- [ ] **Step 1: api.js Phase 2 함수 추가**
+
+```javascript
+// ── Phase 2 API ─────────────────────────────────────────────────────────────
+
+// POST /api/music/wav body: { suno_task_id, suno_id, track_id }
+export function convertToWav(payload) {
+ return apiPost('/api/music/wav', payload);
+}
+
+// POST /api/music/stem-split body: { suno_task_id, suno_id, track_id }
+export function splitStems(payload) {
+ return apiPost('/api/music/stem-split', payload);
+}
+
+// GET /api/music/timestamped-lyrics?task_id=...&suno_id=...
+export function getTimestampedLyrics(taskId, sunoId) {
+ return apiGet(`/api/music/timestamped-lyrics?task_id=${encodeURIComponent(taskId)}&suno_id=${encodeURIComponent(sunoId)}`);
+}
+
+// POST /api/music/style-boost body: { content }
+export function generateStyleBoost(content) {
+ return apiPost('/api/music/style-boost', { content });
+}
+```
+
+- [ ] **Step 2: StemModal 컴포넌트**
+
+`web-ui/src/pages/music/components/StemModal.jsx`:
+
+```jsx
+import React, { useState } from 'react';
+
+const STEM_ICONS = {
+ vocal: '🎤', backing_vocals: '🎶', drums: '🥁', bass: '🎸',
+ guitar: '🎸', keyboard: '🎹', strings: '🎻', brass: '🎺',
+ woodwinds: '🪈', percussion: '🪘', synth: '🎛', fx: '✨',
+};
+
+const StemModal = ({ stems, onClose }) => {
+ const [playingStem, setPlayingStem] = useState(null);
+
+ if (!stems || Object.keys(stems).length === 0) return null;
+
+ return (
+
+
e.stopPropagation()}>
+
+
12 Stems
+ 각 스템을 개별 재생 및 다운로드할 수 있습니다
+
+
+
+ {Object.entries(stems).map(([name, url]) => {
+ if (!url) return null;
+ const isPlaying = playingStem === name;
+ return (
+
+
{STEM_ICONS[name] || '🎵'}
+
{name.replace(/_/g, ' ')}
+
+
+
↓
+
+ {isPlaying && (
+
+ );
+ })}
+
+
+
+
+
+
+ );
+};
+
+export default StemModal;
+```
+
+- [ ] **Step 3: SyncedLyricsPlayer 컴포넌트**
+
+`web-ui/src/pages/music/components/SyncedLyricsPlayer.jsx`:
+
+```jsx
+import React, { useEffect, useRef, useState } from 'react';
+
+const SyncedLyricsPlayer = ({ audioUrl, alignedWords, onClose, accentColor }) => {
+ const audioRef = useRef(null);
+ const [playing, setPlaying] = useState(false);
+ const [currentTime, setCurrentTime] = useState(0);
+
+ useEffect(() => {
+ const el = audioRef.current;
+ if (!el) return;
+ const handler = () => setCurrentTime(el.currentTime);
+ el.addEventListener('timeupdate', handler);
+ return () => el.removeEventListener('timeupdate', handler);
+ }, []);
+
+ if (!alignedWords || alignedWords.length === 0) return null;
+
+ return (
+
+
+
Synced Lyrics
+
+
+
+ );
+};
+
+export default SyncedLyricsPlayer;
+```
+
+- [ ] **Step 4: LibraryCard 더보기 메뉴에 Phase 2 액션 추가 + 스타일 부스트 버튼**
+
+MusicStudio.jsx 더보기 메뉴 dropdown에 추가:
+
+```jsx
+
+
+
+```
+
+Create 탭 Step 1 (Genre) 제목 옆에 Style Boost 버튼:
+
+```jsx
+
+ 01
+
Genre
+ 장르를 선택하세요
+ {provider === 'suno' && (
+
+ )}
+
+```
+
+핸들러:
+
+```jsx
+const [styleBoostLoading, setStyleBoostLoading] = useState(false);
+
+const handleStyleBoost = async () => {
+ if (!genre || styleBoostLoading) return;
+ setStyleBoostLoading(true);
+ try {
+ const content = [
+ GENRES.find(g => g.id === genre)?.label,
+ ...moods.map(id => MOODS.find(m => m.id === id)?.label).filter(Boolean),
+ ].join(', ');
+ const result = await generateStyleBoost(content);
+ if (result?.result) {
+ setPrompt(result.result);
+ }
+ } catch {}
+ finally { setStyleBoostLoading(false); }
+};
+```
+
+- [ ] **Step 5: Phase 2 CSS**
+
+```css
+/* ── Stem Modal ─────────────────────────────────────────── */
+.ms-modal--wide { max-width: 680px; }
+.ms-modal__subtitle { font-size: 0.78rem; color: var(--ms-muted); font-family: 'Courier Prime', monospace; }
+.ms-stem-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
+.ms-stem-card {
+ display: flex; flex-direction: column; align-items: center; gap: 6px;
+ padding: 12px 8px; border-radius: 10px;
+ background: var(--ms-surface2); border: 1px solid var(--ms-line);
+ transition: border-color 0.2s;
+}
+.ms-stem-card.is-playing { border-color: var(--ms-accent); background: rgba(245,166,35,0.08); }
+.ms-stem-card__icon { font-size: 1.4rem; }
+.ms-stem-card__name {
+ font-family: 'Courier Prime', monospace; font-size: 0.72rem;
+ color: var(--ms-muted); text-transform: capitalize;
+}
+.ms-stem-card__actions { display: flex; gap: 6px; }
+
+/* ── Synced Lyrics Player ───────────────────────────────── */
+.ms-synced-player {
+ background: var(--ms-surface); border: 1px solid var(--ms-line);
+ border-radius: 12px; padding: 16px; margin-top: 12px;
+}
+.ms-synced-player__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
+.ms-synced-player__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; color: var(--ms-text); }
+.ms-synced-player__audio { width: 100%; margin-bottom: 12px; }
+.ms-synced-player__lyrics { line-height: 1.8; font-family: 'Syne', sans-serif; font-size: 0.95rem; }
+.ms-synced-word { color: var(--ms-dim); transition: color 0.15s; }
+.ms-synced-word.is-active { color: var(--synced-accent, var(--ms-accent)); font-weight: 600; }
+.ms-synced-word.is-past { color: var(--ms-muted); }
+
+/* ── Style Boost Button ─────────────────────────────────── */
+.ms-style-boost-btn { margin-left: auto; }
+.ms-style-boost-btn.is-loading { opacity: 0.6; }
+```
+
+- [ ] **Step 6: 커밋**
+
+```bash
+git add web-ui/src/pages/music/ web-ui/src/api.js
+git commit -m "feat(music-lab): Phase 2 UI — StemModal, SyncedLyricsPlayer, Style Boost, WAV 변환"
+```
+
+---
+
+## Phase 3: 고급 크리에이티브
+
+### Task 8: 백엔드 — Phase 3 엔드포인트 (업로드, 보컬/인스트 추가, 뮤직비디오)
+
+**Files:**
+- Modify: `music-lab/app/suno_provider.py`
+- Modify: `music-lab/app/main.py`
+
+- [ ] **Step 1: suno_provider.py Phase 3 함수 추가**
+
+```python
+# ── 오디오 업로드 + 커버 ─────────────────────────────────────────────────────
+
+def run_upload_cover(task_id: str, params: dict) -> None:
+ """외부 오디오를 Suno 스타일로 리메이크."""
+ try:
+ if not SUNO_API_KEY:
+ update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
+ return
+
+ update_task(task_id, "processing", 5, "AI Cover 요청 중...")
+
+ payload = {
+ "uploadUrl": params["upload_url"],
+ "customMode": params.get("custom_mode", True),
+ "instrumental": params.get("instrumental", False),
+ "model": params.get("model", "V4"),
+ "callBackUrl": "https://example.com/noop",
+ }
+ for key, api_key in [("prompt", "prompt"), ("style", "style"), ("title", "title"),
+ ("vocal_gender", "vocalGender"), ("negative_tags", "negativeTags"),
+ ("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]:
+ if params.get(key):
+ payload[api_key] = params[key]
+
+ resp = requests.post(f"{SUNO_BASE_URL}/generate/upload-cover", headers=_headers(), json=payload, timeout=30)
+ if resp.status_code != 200:
+ update_task(task_id, "failed", 0, "", error=f"Upload Cover API 오류: {resp.text[:300]}")
+ return
+ body = resp.json()
+ if body.get("code") != 200:
+ update_task(task_id, "failed", 0, "", error=f"Upload Cover 거부: {body.get('msg', 'unknown')}")
+ return
+
+ suno_task_id = body.get("data", {}).get("taskId", "")
+ if not suno_task_id:
+ update_task(task_id, "failed", 0, "", error="Upload Cover 응답에 taskId 없음")
+ return
+ update_task(task_id, "processing", 15, "AI Cover 생성 중...")
+
+ response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
+ if not response:
+ return
+ completed_tracks = response.get("sunoData") or []
+ if not completed_tracks:
+ update_task(task_id, "failed", 0, "", error="AI Cover 생성 완료했으나 트랙 없음")
+ return
+
+ track = _download_and_register(task_id=task_id, song=completed_tracks[0], params=params, filename_suffix="")
+ if track:
+ update_task(task_id, "succeeded", 100, "AI Cover 완료", audio_url=track["audio_url"])
+
+ except Exception as e:
+ logger.exception("Upload cover error for task %s", task_id)
+ update_task(task_id, "failed", 0, "", error=str(e))
+
+
+# ── 오디오 업로드 + 확장 ─────────────────────────────────────────────────────
+
+def run_upload_extend(task_id: str, params: dict) -> None:
+ """외부 오디오를 이어서 확장."""
+ try:
+ if not SUNO_API_KEY:
+ update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
+ return
+
+ update_task(task_id, "processing", 5, "Upload Extend 요청 중...")
+
+ payload = {
+ "uploadUrl": params["upload_url"],
+ "defaultParamFlag": params.get("default_param_flag", True),
+ "model": params.get("model", "V4"),
+ "callBackUrl": "https://example.com/noop",
+ }
+ for key, api_key in [("prompt", "prompt"), ("style", "style"), ("title", "title"),
+ ("continue_at", "continueAt"), ("instrumental", "instrumental"),
+ ("vocal_gender", "vocalGender"), ("negative_tags", "negativeTags")]:
+ if params.get(key) is not None:
+ payload[api_key] = params[key]
+
+ resp = requests.post(f"{SUNO_BASE_URL}/generate/upload-extend", headers=_headers(), json=payload, timeout=30)
+ if resp.status_code != 200:
+ update_task(task_id, "failed", 0, "", error=f"Upload Extend API 오류: {resp.text[:300]}")
+ return
+ body = resp.json()
+ if body.get("code") != 200:
+ update_task(task_id, "failed", 0, "", error=f"Upload Extend 거부: {body.get('msg', 'unknown')}")
+ return
+
+ suno_task_id = body.get("data", {}).get("taskId", "")
+ if not suno_task_id:
+ update_task(task_id, "failed", 0, "", error="Upload Extend 응답에 taskId 없음")
+ return
+ update_task(task_id, "processing", 15, "Upload Extend 생성 중...")
+
+ response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
+ if not response:
+ return
+ completed_tracks = response.get("sunoData") or []
+ if not completed_tracks:
+ update_task(task_id, "failed", 0, "", error="Upload Extend 완료했으나 트랙 없음")
+ return
+
+ track = _download_and_register(task_id=task_id, song=completed_tracks[0], params=params, filename_suffix="")
+ if track:
+ update_task(task_id, "succeeded", 100, "Upload Extend 완료", audio_url=track["audio_url"])
+
+ except Exception as e:
+ logger.exception("Upload extend error for task %s", task_id)
+ update_task(task_id, "failed", 0, "", error=str(e))
+
+
+# ── 보컬 추가 ────────────────────────────────────────────────────────────────
+
+def run_add_vocals(task_id: str, params: dict) -> None:
+ """인스트루멘탈에 AI 보컬을 추가."""
+ try:
+ if not SUNO_API_KEY:
+ update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
+ return
+
+ update_task(task_id, "processing", 5, "보컬 추가 요청 중...")
+
+ payload = {
+ "uploadUrl": params["upload_url"],
+ "prompt": params.get("prompt", ""),
+ "title": params.get("title", ""),
+ "style": params.get("style", ""),
+ "negativeTags": params.get("negative_tags", ""),
+ "callBackUrl": "https://example.com/noop",
+ }
+ for key, api_key in [("vocal_gender", "vocalGender"), ("model", "model"),
+ ("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]:
+ if params.get(key) is not None:
+ payload[api_key] = params[key]
+
+ resp = requests.post(f"{SUNO_BASE_URL}/generate/add-vocals", headers=_headers(), json=payload, timeout=30)
+ if resp.status_code != 200:
+ update_task(task_id, "failed", 0, "", error=f"Add Vocals API 오류: {resp.text[:300]}")
+ return
+ body = resp.json()
+ if body.get("code") != 200:
+ update_task(task_id, "failed", 0, "", error=f"Add Vocals 거부: {body.get('msg', 'unknown')}")
+ return
+
+ suno_task_id = body.get("data", {}).get("taskId", "")
+ if not suno_task_id:
+ update_task(task_id, "failed", 0, "", error="Add Vocals 응답에 taskId 없음")
+ return
+ update_task(task_id, "processing", 15, "AI 보컬 생성 중...")
+
+ response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
+ if not response:
+ return
+ completed_tracks = response.get("sunoData") or []
+ if not completed_tracks:
+ update_task(task_id, "failed", 0, "", error="보컬 추가 완료했으나 트랙 없음")
+ return
+
+ track = _download_and_register(task_id=task_id, song=completed_tracks[0], params=params, filename_suffix="")
+ if track:
+ update_task(task_id, "succeeded", 100, "보컬 추가 완료", audio_url=track["audio_url"])
+
+ except Exception as e:
+ logger.exception("Add vocals error for task %s", task_id)
+ update_task(task_id, "failed", 0, "", error=str(e))
+
+
+# ── 인스트루멘탈 추가 ────────────────────────────────────────────────────────
+
+def run_add_instrumental(task_id: str, params: dict) -> None:
+ """보컬에 AI 반주를 추가."""
+ try:
+ if not SUNO_API_KEY:
+ update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
+ return
+
+ update_task(task_id, "processing", 5, "인스트루멘탈 추가 요청 중...")
+
+ payload = {
+ "uploadUrl": params["upload_url"],
+ "title": params.get("title", ""),
+ "tags": params.get("tags", ""),
+ "negativeTags": params.get("negative_tags", ""),
+ "callBackUrl": "https://example.com/noop",
+ }
+ for key, api_key in [("vocal_gender", "vocalGender"), ("model", "model"),
+ ("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]:
+ if params.get(key) is not None:
+ payload[api_key] = params[key]
+
+ resp = requests.post(f"{SUNO_BASE_URL}/generate/add-instrumental", headers=_headers(), json=payload, timeout=30)
+ if resp.status_code != 200:
+ update_task(task_id, "failed", 0, "", error=f"Add Instrumental API 오류: {resp.text[:300]}")
+ return
+ body = resp.json()
+ if body.get("code") != 200:
+ update_task(task_id, "failed", 0, "", error=f"Add Instrumental 거부: {body.get('msg', 'unknown')}")
+ return
+
+ suno_task_id = body.get("data", {}).get("taskId", "")
+ if not suno_task_id:
+ update_task(task_id, "failed", 0, "", error="Add Instrumental 응답에 taskId 없음")
+ return
+ update_task(task_id, "processing", 15, "AI 반주 생성 중...")
+
+ response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
+ if not response:
+ return
+ completed_tracks = response.get("sunoData") or []
+ if not completed_tracks:
+ update_task(task_id, "failed", 0, "", error="인스트루멘탈 추가 완료했으나 트랙 없음")
+ return
+
+ track = _download_and_register(task_id=task_id, song=completed_tracks[0], params=params, filename_suffix="")
+ if track:
+ update_task(task_id, "succeeded", 100, "인스트루멘탈 추가 완료", audio_url=track["audio_url"])
+
+ except Exception as e:
+ logger.exception("Add instrumental error for task %s", task_id)
+ update_task(task_id, "failed", 0, "", error=str(e))
+
+
+# ── 뮤직비디오 생성 ──────────────────────────────────────────────────────────
+
+def run_video_generate(task_id: str, params: dict) -> None:
+ """곡의 뮤직비디오(MP4) 생성."""
+ try:
+ if not SUNO_API_KEY:
+ update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
+ return
+
+ update_task(task_id, "processing", 5, "뮤직비디오 생성 요청 중...")
+
+ payload = {
+ "taskId": params["suno_task_id"],
+ "audioId": params["suno_id"],
+ "callBackUrl": "https://example.com/noop",
+ }
+ if params.get("author"):
+ payload["author"] = params["author"][:50]
+ if params.get("domain_name"):
+ payload["domainName"] = params["domain_name"][:50]
+
+ resp = requests.post(f"{SUNO_BASE_URL}/mp4/generate", headers=_headers(), json=payload, timeout=30)
+ if resp.status_code != 200:
+ update_task(task_id, "failed", 0, "", error=f"Video API 오류: {resp.text[:300]}")
+ return
+ body = resp.json()
+ if body.get("code") != 200:
+ update_task(task_id, "failed", 0, "", error=f"Video 생성 거부: {body.get('msg', 'unknown')}")
+ return
+
+ video_task_id = body.get("data", {}).get("taskId", params.get("suno_task_id", ""))
+ update_task(task_id, "processing", 15, "뮤직비디오 렌더링 중...")
+
+ response = _poll_suno_record(
+ "/mp4/record-info", video_task_id, task_id,
+ max_attempts=60, interval=10,
+ progress_msg_map={"PENDING": "비디오 렌더링 대기 중...", "GENERATING": "비디오 렌더링 중..."},
+ )
+ if not response:
+ return
+
+ video_url = ""
+ suno_data = response.get("sunoData") or []
+ if suno_data and isinstance(suno_data, list) and isinstance(suno_data[0], dict):
+ video_url = suno_data[0].get("videoUrl") or suno_data[0].get("video_url", "")
+ if not video_url:
+ video_url = response.get("video_url") or response.get("videoUrl", "")
+
+ update_task(task_id, "succeeded", 100, "뮤직비디오 생성 완료", audio_url=video_url)
+
+ except Exception as e:
+ logger.exception("Video generate error for task %s", task_id)
+ update_task(task_id, "failed", 0, "", error=str(e))
+```
+
+- [ ] **Step 2: main.py Phase 3 엔드포인트 추가**
+
+import 업데이트:
+
+```python
+from .suno_provider import (
+ run_suno_generation, run_suno_extend, run_vocal_removal,
+ run_cover_image, run_wav_convert, run_stem_split,
+ run_upload_cover, run_upload_extend, run_add_vocals, run_add_instrumental, run_video_generate,
+ generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost,
+ SUNO_API_KEY, SUNO_MODELS,
+)
+```
+
+엔드포인트:
+
+```python
+# ── Phase 3: 업로드 + 커버 ──────────────────────────────────────────────────
+
+class UploadCoverRequest(BaseModel):
+ upload_url: str
+ model: str = "V4"
+ custom_mode: bool = True
+ instrumental: bool = False
+ prompt: str = ""
+ style: str = ""
+ title: str = ""
+ vocal_gender: Optional[str] = None
+ negative_tags: Optional[str] = None
+ style_weight: Optional[float] = None
+ audio_weight: Optional[float] = None
+
+
+@app.post("/api/music/upload-cover")
+def upload_cover(req: UploadCoverRequest, background_tasks: BackgroundTasks):
+ """외부 오디오를 Suno 스타일로 리메이크."""
+ if not SUNO_API_KEY:
+ raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
+ task_id = str(uuid.uuid4())
+ params = req.model_dump()
+ create_task(task_id, params, provider="suno")
+ background_tasks.add_task(run_upload_cover, task_id, params)
+ return {"task_id": task_id, "provider": "suno"}
+
+
+# ── Phase 3: 업로드 + 확장 ──────────────────────────────────────────────────
+
+class UploadExtendRequest(BaseModel):
+ upload_url: str
+ model: str = "V4"
+ default_param_flag: bool = True
+ continue_at: Optional[float] = None
+ prompt: str = ""
+ style: str = ""
+ title: str = ""
+ instrumental: bool = False
+ vocal_gender: Optional[str] = None
+ negative_tags: Optional[str] = None
+
+
+@app.post("/api/music/upload-extend")
+def upload_extend(req: UploadExtendRequest, background_tasks: BackgroundTasks):
+ """외부 오디오를 이어서 확장."""
+ if not SUNO_API_KEY:
+ raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
+ task_id = str(uuid.uuid4())
+ params = req.model_dump()
+ create_task(task_id, params, provider="suno")
+ background_tasks.add_task(run_upload_extend, task_id, params)
+ return {"task_id": task_id, "provider": "suno"}
+
+
+# ── Phase 3: 보컬 추가 ──────────────────────────────────────────────────────
+
+class AddVocalsRequest(BaseModel):
+ upload_url: str
+ prompt: str
+ title: str
+ style: str
+ negative_tags: str = ""
+ vocal_gender: Optional[str] = None
+ model: str = "V4_5PLUS"
+ style_weight: Optional[float] = None
+ audio_weight: Optional[float] = None
+
+
+@app.post("/api/music/add-vocals")
+def add_vocals(req: AddVocalsRequest, background_tasks: BackgroundTasks):
+ """인스트루멘탈에 AI 보컬 추가."""
+ if not SUNO_API_KEY:
+ raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
+ task_id = str(uuid.uuid4())
+ params = req.model_dump()
+ create_task(task_id, params, provider="suno")
+ background_tasks.add_task(run_add_vocals, task_id, params)
+ return {"task_id": task_id, "provider": "suno"}
+
+
+# ── Phase 3: 인스트루멘탈 추가 ──────────────────────────────────────────────
+
+class AddInstrumentalRequest(BaseModel):
+ upload_url: str
+ title: str
+ tags: str
+ negative_tags: str = ""
+ vocal_gender: Optional[str] = None
+ model: str = "V4_5PLUS"
+ style_weight: Optional[float] = None
+ audio_weight: Optional[float] = None
+
+
+@app.post("/api/music/add-instrumental")
+def add_instrumental(req: AddInstrumentalRequest, background_tasks: BackgroundTasks):
+ """보컬에 AI 반주 추가."""
+ if not SUNO_API_KEY:
+ raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
+ task_id = str(uuid.uuid4())
+ params = req.model_dump()
+ create_task(task_id, params, provider="suno")
+ background_tasks.add_task(run_add_instrumental, task_id, params)
+ return {"task_id": task_id, "provider": "suno"}
+
+
+# ── Phase 3: 뮤직비디오 생성 ────────────────────────────────────────────────
+
+class VideoRequest(BaseModel):
+ suno_task_id: str
+ suno_id: str
+ author: str = ""
+ domain_name: str = ""
+ track_id: Optional[int] = None
+
+
+@app.post("/api/music/video")
+def video_generate(req: VideoRequest, background_tasks: BackgroundTasks):
+ """뮤직비디오(MP4) 생성."""
+ if not SUNO_API_KEY:
+ raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
+ task_id = str(uuid.uuid4())
+ params = req.model_dump()
+ create_task(task_id, params, provider="suno")
+ background_tasks.add_task(run_video_generate, task_id, params)
+ return {"task_id": task_id, "provider": "suno"}
+```
+
+- [ ] **Step 3: 커밋**
+
+```bash
+git add music-lab/app/suno_provider.py music-lab/app/main.py
+git commit -m "feat(music-lab): Phase 3 백엔드 — 업로드커버, 업로드확장, 보컬추가, 인스트추가, 뮤직비디오"
+```
+
+---
+
+### Task 9: 프론트엔드 — Phase 3 UI (RemixTab + 뮤직비디오)
+
+**Files:**
+- Modify: `web-ui/src/api.js`
+- Create: `web-ui/src/pages/music/components/RemixTab.jsx`
+- Modify: `web-ui/src/pages/music/MusicStudio.jsx`
+- Modify: `web-ui/src/pages/music/MusicStudio.css`
+
+- [ ] **Step 1: api.js Phase 3 함수 추가**
+
+```javascript
+// ── Phase 3 API ─────────────────────────────────────────────────────────────
+
+// POST /api/music/upload-cover
+export function uploadAndCover(payload) {
+ return apiPost('/api/music/upload-cover', payload);
+}
+
+// POST /api/music/upload-extend
+export function uploadAndExtend(payload) {
+ return apiPost('/api/music/upload-extend', payload);
+}
+
+// POST /api/music/add-vocals
+export function addVocals(payload) {
+ return apiPost('/api/music/add-vocals', payload);
+}
+
+// POST /api/music/add-instrumental
+export function addInstrumental(payload) {
+ return apiPost('/api/music/add-instrumental', payload);
+}
+
+// POST /api/music/video
+export function generateVideo(payload) {
+ return apiPost('/api/music/video', payload);
+}
+```
+
+- [ ] **Step 2: RemixTab 컴포넌트**
+
+`web-ui/src/pages/music/components/RemixTab.jsx`:
+
+```jsx
+import React, { useState } from 'react';
+import { uploadAndCover, uploadAndExtend, addVocals, addInstrumental, getMusicStatus } from '../../../api';
+
+const REMIX_ACTIONS = [
+ { id: 'cover', label: 'AI Cover', icon: '🎨', desc: '외부 음원을 Suno AI 스타일로 리메이크' },
+ { id: 'extend', label: 'Extend', icon: '⏩', desc: '외부 음원을 이어서 확장' },
+ { id: 'add-vocals', label: 'Add Vocals', icon: '🎤', desc: '인스트루멘탈에 AI 보컬 입히기' },
+ { id: 'add-instrumental', label: 'Add Instrumental', icon: '🎹', desc: '보컬에 AI 반주 입히기' },
+];
+
+const RemixTab = ({ onTaskStarted, model, isGenerating }) => {
+ const [uploadUrl, setUploadUrl] = useState('');
+ const [activeAction, setActiveAction] = useState(null);
+
+ // 각 액션별 파라미터
+ const [title, setTitle] = useState('');
+ const [style, setStyle] = useState('');
+ const [prompt, setPrompt] = useState('');
+ const [tags, setTags] = useState('');
+ const [negativeTags, setNegativeTags] = useState('');
+ const [vocalGender, setVocalGender] = useState(null);
+ const [continueAt, setContinueAt] = useState(0);
+ const [instrumental, setInstrumental] = useState(false);
+
+ const handleSubmit = async () => {
+ if (!uploadUrl || !activeAction || isGenerating) return;
+
+ let apiCall;
+ let payload = {};
+
+ switch (activeAction) {
+ case 'cover':
+ apiCall = uploadAndCover;
+ payload = {
+ upload_url: uploadUrl, model, custom_mode: true,
+ instrumental, prompt, style, title,
+ vocal_gender: vocalGender || undefined,
+ negative_tags: negativeTags || undefined,
+ };
+ break;
+ case 'extend':
+ apiCall = uploadAndExtend;
+ payload = {
+ upload_url: uploadUrl, model,
+ default_param_flag: !!prompt,
+ continue_at: continueAt || undefined,
+ prompt, style, title, instrumental,
+ vocal_gender: vocalGender || undefined,
+ negative_tags: negativeTags || undefined,
+ };
+ break;
+ case 'add-vocals':
+ apiCall = addVocals;
+ payload = {
+ upload_url: uploadUrl, prompt, title, style,
+ negative_tags: negativeTags,
+ vocal_gender: vocalGender || undefined,
+ model: 'V4_5PLUS',
+ };
+ break;
+ case 'add-instrumental':
+ apiCall = addInstrumental;
+ payload = {
+ upload_url: uploadUrl, title, tags,
+ negative_tags: negativeTags,
+ vocal_gender: vocalGender || undefined,
+ model: 'V4_5PLUS',
+ };
+ break;
+ default:
+ return;
+ }
+
+ try {
+ const res = await apiCall(payload);
+ if (res?.task_id) {
+ onTaskStarted(res.task_id, `Remix: ${REMIX_ACTIONS.find(a => a.id === activeAction)?.label}`);
+ }
+ } catch (e) {
+ // 에러는 부모 컴포넌트에서 처리
+ }
+ };
+
+ return (
+
+
+
Remix Studio
+
외부 음원을 AI로 리메이크, 확장, 보컬/반주 추가
+
+
+
+
+ setUploadUrl(e.target.value)}
+ style={{ width: '100%' }}
+ />
+
+
+
+ {REMIX_ACTIONS.map((action) => (
+
+ ))}
+
+
+ {activeAction && (
+
+ {/* 공통 파라미터 */}
+
+
+ setTitle(e.target.value)} placeholder="곡 제목" style={{ width: '100%' }} />
+
+
+ {(activeAction === 'cover' || activeAction === 'extend' || activeAction === 'add-vocals') && (
+
+
+
+ )}
+
+ {(activeAction === 'cover' || activeAction === 'extend' || activeAction === 'add-vocals') && (
+
+
+ setStyle(e.target.value)} placeholder="예: Pop, Energetic, Piano" style={{ width: '100%' }} />
+
+ )}
+
+ {activeAction === 'add-instrumental' && (
+
+
+ setTags(e.target.value)} placeholder="예: acoustic, warm, dreamy" style={{ width: '100%' }} />
+
+ )}
+
+ {activeAction === 'extend' && (
+
+
+ setContinueAt(Number(e.target.value))} min={0} style={{ width: '120px' }} />
+
+ )}
+
+
+
+ setNegativeTags(e.target.value)} placeholder="제외할 스타일" style={{ width: '100%' }} />
+
+
+
+
+
+ {[{ value: null, label: 'Auto' }, { value: 'm', label: 'Male' }, { value: 'f', label: 'Female' }].map((opt) => (
+
+ ))}
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default RemixTab;
+```
+
+- [ ] **Step 3: MusicStudio.jsx에 Remix 탭 + Video 액션 통합**
+
+```jsx
+import RemixTab from './components/RemixTab';
+import { generateVideo } from '../../api';
+
+// 탭 네비게이션에 Remix 추가
+
+
+// 탭 내용에 Remix 추가
+{tab === 'remix' && (
+ {
+ setTab('create');
+ setIsGenerating(true);
+ setTrack(null);
+ setGenProgress(0);
+ setGenStep(`${title} 처리 중…`);
+ setGenError(null);
+ taskIdRef.current = taskId;
+ startPolling(taskId, title);
+ }}
+ model={model}
+ isGenerating={isGenerating}
+ />
+)}
+
+// LibraryCard 더보기 메뉴에 Video 추가
+
+```
+
+Video 핸들러:
+
+```jsx
+const handleVideoGenerate = async (track) => {
+ if (!track.task_id || !track.suno_id || isGenerating) return;
+ setTab('create');
+ setIsGenerating(true);
+ setTrack(null);
+ setGenProgress(0);
+ setGenStep('뮤직비디오 생성 요청 중…');
+ setGenError(null);
+ try {
+ const res = await generateVideo({
+ suno_task_id: track.task_id,
+ suno_id: track.suno_id,
+ track_id: track.id,
+ });
+ if (res?.task_id) {
+ taskIdRef.current = res.task_id;
+ startPolling(res.task_id, `${track.title} (Video)`);
+ }
+ } catch {
+ setIsGenerating(false);
+ setGenError('뮤직비디오 생성에 실패했습니다');
+ }
+};
+```
+
+- [ ] **Step 4: Phase 3 CSS**
+
+```css
+/* ── Remix Tab ──────────────────────────────────────────── */
+.ms-remix-tab { display: flex; flex-direction: column; gap: 20px; }
+.ms-remix-tab__header { }
+.ms-remix-tab__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.8rem; color: var(--ms-text); }
+.ms-remix-tab__desc { font-size: 0.85rem; color: var(--ms-muted); }
+
+.ms-remix-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
+.ms-remix-card {
+ display: flex; flex-direction: column; align-items: center; gap: 6px;
+ padding: 20px 12px; border-radius: 12px; cursor: pointer;
+ background: var(--ms-surface); border: 1px solid var(--ms-line);
+ transition: all 0.2s; text-align: center;
+}
+.ms-remix-card:hover { border-color: var(--ms-accent); background: var(--ms-surface2); }
+.ms-remix-card.is-active { border-color: var(--ms-accent); background: rgba(245,166,35,0.08); }
+.ms-remix-card__icon { font-size: 2rem; }
+.ms-remix-card__label { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; color: var(--ms-text); }
+.ms-remix-card__desc { font-size: 0.72rem; color: var(--ms-muted); font-family: 'Courier Prime', monospace; }
+
+.ms-remix-params {
+ display: flex; flex-direction: column; gap: 12px;
+ padding: 16px; border-radius: 12px;
+ background: var(--ms-surface); border: 1px solid var(--ms-line);
+}
+.ms-remix-submit { align-self: flex-start; margin-top: 8px; }
+```
+
+- [ ] **Step 5: 커밋**
+
+```bash
+git add web-ui/src/pages/music/ web-ui/src/api.js
+git commit -m "feat(music-lab): Phase 3 UI — RemixTab + 뮤직비디오 생성"
+```
+
+---
+
+### Task 10: 최종 통합 검증 + CLAUDE.md 업데이트
+
+**Files:**
+- Modify: `web-backend/CLAUDE.md`
+
+- [ ] **Step 1: CLAUDE.md music-lab API 목록 업데이트**
+
+`web-backend/CLAUDE.md`의 music-lab API 목록 테이블을 업데이트:
+
+```markdown
+**music-lab API 목록**
+
+| 메서드 | 경로 | 설명 |
+|--------|------|------|
+| GET | `/api/music/providers` | 사용 가능한 프로바이더 목록 |
+| GET | `/api/music/models` | Suno 모델 목록 (V4~V5.5) |
+| GET | `/api/music/credits` | Suno 크레딧 조회 |
+| POST | `/api/music/generate` | 음악 생성 (provider, model, vocal_gender, negative_tags, style_weight, audio_weight) |
+| GET | `/api/music/status/{task_id}` | 생성 상태 폴링 |
+| POST | `/api/music/lyrics` | Suno AI 가사 생성 |
+| GET | `/api/music/library` | 라이브러리 전체 조회 |
+| POST | `/api/music/library` | 트랙 수동 추가 |
+| DELETE | `/api/music/library/{id}` | 트랙 삭제 |
+| POST | `/api/music/extend` | 곡 연장 |
+| POST | `/api/music/vocal-removal` | 보컬/인스트 분리 (2트랙) |
+| POST | `/api/music/cover-image` | 커버 이미지 2장 생성 |
+| POST | `/api/music/wav` | WAV 고음질 변환 |
+| POST | `/api/music/stem-split` | 12스템 분리 (50cr) |
+| GET | `/api/music/timestamped-lyrics` | 타임스탬프 가사 (가라오케) |
+| POST | `/api/music/style-boost` | AI 스타일 프롬프트 생성 |
+| POST | `/api/music/upload-cover` | 외부 음원 AI Cover |
+| POST | `/api/music/upload-extend` | 외부 음원 확장 |
+| POST | `/api/music/add-vocals` | 인스트에 AI 보컬 추가 |
+| POST | `/api/music/add-instrumental` | 보컬에 AI 반주 추가 |
+| POST | `/api/music/video` | 뮤직비디오 MP4 생성 |
+| GET | `/api/music/lyrics/library` | 저장된 가사 목록 |
+| POST | `/api/music/lyrics/library` | 가사 저장 |
+| PUT | `/api/music/lyrics/library/{id}` | 가사 수정 |
+| DELETE | `/api/music/lyrics/library/{id}` | 가사 삭제 |
+```
+
+- [ ] **Step 2: 전체 빌드 확인**
+
+```bash
+cd web-ui && npm run build
+```
+
+빌드 에러가 있으면 수정.
+
+- [ ] **Step 3: 커밋**
+
+```bash
+git add web-backend/CLAUDE.md
+git commit -m "docs: music-lab API 목록 업데이트 — Phase 1~3 신규 엔드포인트 반영"
+```