10개 Task, 3 Phase 구조의 상세 구현 계획. Phase 1(생성 강화), Phase 2(후처리), Phase 3(리믹스). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2595 lines
103 KiB
Markdown
2595 lines
103 KiB
Markdown
# 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 (
|
|
<div className="ms-audio-player" style={{ '--player-accent': accentColor }}>
|
|
{!isFake && (
|
|
<audio
|
|
ref={audioRef}
|
|
src={audioUrl}
|
|
onLoadedMetadata={(e) => setDuration(e.target.duration)}
|
|
onTimeUpdate={(e) => setElapsed(e.target.currentTime)}
|
|
onPlay={() => setPlaying(true)}
|
|
onPause={() => setPlaying(false)}
|
|
onEnded={() => { setPlaying(false); setElapsed(0); }}
|
|
/>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className={`ms-player__play ${playing ? 'is-playing' : ''}`}
|
|
onClick={togglePlay}
|
|
aria-label={playing ? '일시정지' : '재생'}
|
|
>
|
|
{playing ? (
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
<rect x="3" y="2" width="4" height="12" rx="1" />
|
|
<rect x="9" y="2" width="4" height="12" rx="1" />
|
|
</svg>
|
|
) : (
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
<path d="M4 2l10 6-10 6V2z" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
|
|
<div className="ms-player__timeline">
|
|
<div className="ms-player__bar" onClick={handleSeek} role="slider"
|
|
aria-label="재생 위치" aria-valuenow={Math.round(elapsed)} aria-valuemin={0} aria-valuemax={Math.round(total)}>
|
|
<div className="ms-player__fill" style={{ width: `${progress}%` }} />
|
|
<div className="ms-player__thumb" style={{ left: `${progress}%` }} />
|
|
</div>
|
|
<div className="ms-player__times">
|
|
<span>{fmtTime(elapsed)}</span>
|
|
<span>{fmtTime(total)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ms-volume">
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden>
|
|
<path d="M2 5h2.5l3-3v10l-3-3H2V5zm8.5-1.5a4.5 4.5 0 010 7" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round"/>
|
|
</svg>
|
|
<input
|
|
type="range" min={0} max={1} step={0.02} value={volume}
|
|
onChange={handleVolumeChange}
|
|
className="ms-volume__slider"
|
|
aria-label="볼륨"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<div className={`ms-credits-badge ${isLow ? 'is-low' : ''}`}>
|
|
<span className="ms-credits-badge__icon">⚡</span>
|
|
<span className="ms-credits-badge__value">{remaining}</span>
|
|
<span className="ms-credits-badge__label">credits</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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)를 `<CreditsBadge />` 로 교체:
|
|
|
|
```jsx
|
|
<div className="ms-header__right">
|
|
<CreditsBadge />
|
|
<SonicRadar isGenerating={isGenerating} accentColor={accentColor} />
|
|
</div>
|
|
```
|
|
|
|
기존 `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) 뒤, `</section>` 닫기 전에 추가:
|
|
|
|
```jsx
|
|
{/* Vocal Gender (Suno only) */}
|
|
{provider === 'suno' && (
|
|
<div className="ms-param-group">
|
|
<label className="ms-param-label">Vocal Gender</label>
|
|
<div className="ms-gender-toggle">
|
|
{[
|
|
{ value: null, label: 'Auto', icon: '🎵' },
|
|
{ value: 'm', label: 'Male', icon: '♂' },
|
|
{ value: 'f', label: 'Female', icon: '♀' },
|
|
].map((opt) => (
|
|
<button
|
|
key={opt.label}
|
|
type="button"
|
|
className={`ms-gender-btn ${vocalGender === opt.value ? 'is-active' : ''} ${opt.value === 'm' ? 'is-male' : opt.value === 'f' ? 'is-female' : ''}`}
|
|
onClick={() => setVocalGender(opt.value)}
|
|
>
|
|
<span className="ms-gender-btn__icon">{opt.icon}</span>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Negative Tags (Suno only) */}
|
|
{provider === 'suno' && (
|
|
<div className="ms-param-group">
|
|
<label className="ms-param-label">Exclude Styles</label>
|
|
<div className="ms-negative-tags">
|
|
<div className="ms-negative-tags__presets">
|
|
{['screaming', 'autotune', 'distortion', 'whisper', 'falsetto', 'rap'].map((tag) => (
|
|
<button
|
|
key={tag}
|
|
type="button"
|
|
className={`ms-neg-chip ${negativeTags.includes(tag) ? 'is-active' : ''}`}
|
|
onClick={() => {
|
|
setNegativeTags((prev) => {
|
|
const tags = prev.split(',').map(t => t.trim()).filter(Boolean);
|
|
if (tags.includes(tag)) return tags.filter(t => t !== tag).join(', ');
|
|
return [...tags, tag].join(', ');
|
|
});
|
|
}}
|
|
>
|
|
{tag}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<input
|
|
type="text"
|
|
className="ms-negative-tags__input"
|
|
placeholder="추가로 제외할 스타일을 입력..."
|
|
value={negativeTags}
|
|
onChange={(e) => setNegativeTags(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Style Weight / Audio Weight (Suno only) */}
|
|
{provider === 'suno' && (
|
|
<div className="ms-param-grid">
|
|
<div className="ms-param-group">
|
|
<div className="ms-param-row">
|
|
<label className="ms-param-label">Style Weight</label>
|
|
<span className="ms-param-value">{styleWeight}%</span>
|
|
</div>
|
|
<p className="ms-param-hint ms-param-hint--inline">Prompt ↔ Style 밸런스</p>
|
|
<input
|
|
type="range" min={0} max={100} value={styleWeight}
|
|
onChange={(e) => setStyleWeight(Number(e.target.value))}
|
|
className="ms-bpm-slider"
|
|
aria-label="Style Weight"
|
|
/>
|
|
</div>
|
|
<div className="ms-param-group">
|
|
<div className="ms-param-row">
|
|
<label className="ms-param-label">Audio Weight</label>
|
|
<span className="ms-param-value">{audioWeight}%</span>
|
|
</div>
|
|
<p className="ms-param-hint ms-param-hint--inline">Original ↔ AI 밸런스</p>
|
|
<input
|
|
type="range" min={0} max={100} value={audioWeight}
|
|
onChange={(e) => setAudioWeight(Number(e.target.value))}
|
|
className="ms-bpm-slider"
|
|
aria-label="Audio Weight"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
- [ ] **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 (
|
|
<div className="ms-modal-overlay" onClick={onClose}>
|
|
<div className="ms-modal" onClick={(e) => e.stopPropagation()}>
|
|
<div className="ms-modal__header">
|
|
<h3 className="ms-modal__title">Cover Art 선택</h3>
|
|
<button type="button" className="ms-modal__close" onClick={onClose}>✕</button>
|
|
</div>
|
|
<div className="ms-cover-grid">
|
|
{images.map((url, idx) => (
|
|
<button
|
|
key={idx}
|
|
type="button"
|
|
className={`ms-cover-option ${selected === idx ? 'is-selected' : ''}`}
|
|
onClick={() => setSelected(idx)}
|
|
>
|
|
<img src={url} alt={`Cover option ${idx + 1}`} className="ms-cover-option__img" />
|
|
<span className="ms-cover-option__label">Option {idx + 1}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="ms-modal__actions">
|
|
<button
|
|
type="button"
|
|
className="ms-btn ms-btn--accent"
|
|
disabled={selected === null}
|
|
onClick={() => { if (selected !== null) onSelect(images[selected]); }}
|
|
>
|
|
이 이미지 사용
|
|
</button>
|
|
<button type="button" className="ms-btn ms-btn--ghost" onClick={onClose}>
|
|
취소
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<div
|
|
className={`ms-lib-card ${isPlaying ? 'is-playing' : ''}`}
|
|
style={{ '--lib-accent': genre?.color ?? '#f5a623' }}
|
|
>
|
|
<div className="ms-lib-card__header">
|
|
<span className="ms-lib-card__icon">{genre?.icon ?? '🎵'}</span>
|
|
{track.cover_images?.[0] && (
|
|
<img src={track.cover_images[0]} alt="" className="ms-lib-card__thumb" />
|
|
)}
|
|
<p className="ms-lib-card__title">{track.title}</p>
|
|
<div className="ms-lib-card__controls">
|
|
<button
|
|
type="button"
|
|
className={`ms-btn--icon ${isPlaying ? 'is-active' : ''}`}
|
|
onClick={() => onPlay(track)}
|
|
aria-label={isPlaying ? '정지' : '재생'}
|
|
>
|
|
{isPlaying ? '■' : '▶'}
|
|
</button>
|
|
{track.audio_url && (
|
|
<a href={track.audio_url} download className="ms-btn--icon" aria-label="다운로드">↓</a>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="ms-btn--icon ms-btn--danger"
|
|
onClick={() => onDelete(track.id)}
|
|
aria-label="삭제"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="ms-lib-card__sub">
|
|
<p className="ms-lib-card__filename">{filename}</p>
|
|
<p className="ms-lib-card__meta">
|
|
{totalSec != null ? fmtTime(totalSec) : '--:--'} · {track.bpm ? `${track.bpm} BPM` : ''} {track.key} {track.scale}
|
|
</p>
|
|
</div>
|
|
{isPlaying && (
|
|
<AudioPlayer
|
|
audioUrl={track.audio_url}
|
|
totalSec={totalSec}
|
|
accentColor={genre?.color ?? '#f5a623'}
|
|
/>
|
|
)}
|
|
<div className="ms-lib-card__tags">
|
|
{track.provider && (
|
|
<span className={`ms-result__tag ms-result__tag--provider ${track.provider === 'suno' ? 'is-suno' : 'is-local'}`}>
|
|
{track.provider === 'suno' ? '🎙️ Suno' : '🤖 MusicGen'}
|
|
</span>
|
|
)}
|
|
{(track.instruments ?? []).slice(0, 3).map((i) => (
|
|
<span key={i} className="ms-result__tag">{i}</span>
|
|
))}
|
|
{(track.moods ?? []).slice(0, 2).map((m) => (
|
|
<span key={m} className="ms-result__tag">{m}</span>
|
|
))}
|
|
</div>
|
|
{hasSunoId && (
|
|
<div className="ms-lib-card__actions">
|
|
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
|
|
onClick={() => onExtend(track)} disabled={isGenerating} title="이 곡을 이어서 연장합니다">
|
|
⏩ Extend
|
|
</button>
|
|
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
|
|
onClick={() => onVocalRemoval(track)} disabled={isGenerating} title="보컬과 인스트루멘탈을 분리합니다">
|
|
🎤 Vocal Split
|
|
</button>
|
|
{/* 더보기 메뉴 */}
|
|
<div className="ms-more-menu">
|
|
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
|
|
onClick={() => setMenuOpen(!menuOpen)}>
|
|
•••
|
|
</button>
|
|
{menuOpen && (
|
|
<div className="ms-more-menu__dropdown">
|
|
<button type="button" onClick={() => { onCoverArt(track); setMenuOpen(false); }}
|
|
disabled={isGenerating}>
|
|
🖼 Cover Art
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<p className="ms-lib-card__date">
|
|
{track.created_at ? new Date(track.created_at).toLocaleDateString('ko-KR') : ''}
|
|
</p>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
- [ ] **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 && (
|
|
<CoverArtModal
|
|
images={coverArtModal.images}
|
|
onSelect={handleCoverSelect}
|
|
onClose={() => setCoverArtModal(null)}
|
|
/>
|
|
)}
|
|
|
|
// Library 컴포넌트에 onCoverArt prop 전달
|
|
<Library
|
|
tracks={library}
|
|
loading={libLoading}
|
|
onDelete={handleDeleteFromLibrary}
|
|
onRefresh={loadLibrary}
|
|
onExtend={handleExtend}
|
|
onVocalRemoval={handleVocalRemoval}
|
|
onCoverArt={handleCoverArt}
|
|
isGenerating={isGenerating}
|
|
/>
|
|
```
|
|
|
|
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 (
|
|
<div className="ms-modal-overlay" onClick={onClose}>
|
|
<div className="ms-modal ms-modal--wide" onClick={(e) => e.stopPropagation()}>
|
|
<div className="ms-modal__header">
|
|
<h3 className="ms-modal__title">12 Stems</h3>
|
|
<span className="ms-modal__subtitle">각 스템을 개별 재생 및 다운로드할 수 있습니다</span>
|
|
<button type="button" className="ms-modal__close" onClick={onClose}>✕</button>
|
|
</div>
|
|
<div className="ms-stem-grid">
|
|
{Object.entries(stems).map(([name, url]) => {
|
|
if (!url) return null;
|
|
const isPlaying = playingStem === name;
|
|
return (
|
|
<div key={name} className={`ms-stem-card ${isPlaying ? 'is-playing' : ''}`}>
|
|
<span className="ms-stem-card__icon">{STEM_ICONS[name] || '🎵'}</span>
|
|
<span className="ms-stem-card__name">{name.replace(/_/g, ' ')}</span>
|
|
<div className="ms-stem-card__actions">
|
|
<button
|
|
type="button"
|
|
className="ms-btn--icon"
|
|
onClick={() => setPlayingStem(isPlaying ? null : name)}
|
|
>
|
|
{isPlaying ? '■' : '▶'}
|
|
</button>
|
|
<a href={url} download className="ms-btn--icon" aria-label="다운로드">↓</a>
|
|
</div>
|
|
{isPlaying && (
|
|
<audio src={url} autoPlay onEnded={() => setPlayingStem(null)} />
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="ms-modal__actions">
|
|
<button type="button" className="ms-btn ms-btn--ghost" onClick={onClose}>닫기</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<div className="ms-synced-player" style={{ '--synced-accent': accentColor }}>
|
|
<div className="ms-synced-player__header">
|
|
<h4 className="ms-synced-player__title">Synced Lyrics</h4>
|
|
<button type="button" className="ms-modal__close" onClick={onClose}>✕</button>
|
|
</div>
|
|
<audio
|
|
ref={audioRef}
|
|
src={audioUrl}
|
|
onPlay={() => setPlaying(true)}
|
|
onPause={() => setPlaying(false)}
|
|
onEnded={() => setPlaying(false)}
|
|
controls
|
|
className="ms-synced-player__audio"
|
|
/>
|
|
<div className="ms-synced-player__lyrics">
|
|
{alignedWords.map((word, idx) => {
|
|
const isActive = currentTime >= word.startS && currentTime < word.endS;
|
|
const isPast = currentTime >= word.endS;
|
|
return (
|
|
<span
|
|
key={idx}
|
|
className={`ms-synced-word ${isActive ? 'is-active' : ''} ${isPast ? 'is-past' : ''}`}
|
|
>
|
|
{word.word}{' '}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SyncedLyricsPlayer;
|
|
```
|
|
|
|
- [ ] **Step 4: LibraryCard 더보기 메뉴에 Phase 2 액션 추가 + 스타일 부스트 버튼**
|
|
|
|
MusicStudio.jsx 더보기 메뉴 dropdown에 추가:
|
|
|
|
```jsx
|
|
<button type="button" onClick={() => { onWavConvert(track); setMenuOpen(false); }}
|
|
disabled={isGenerating}>
|
|
📀 WAV Download
|
|
</button>
|
|
<button type="button" onClick={() => { onStemSplit(track); setMenuOpen(false); }}
|
|
disabled={isGenerating}>
|
|
🎛 12 Stems (50cr)
|
|
</button>
|
|
<button type="button" onClick={() => { onSyncedLyrics(track); setMenuOpen(false); }}
|
|
disabled={isGenerating || !track.lyrics}>
|
|
📝 Synced Lyrics
|
|
</button>
|
|
```
|
|
|
|
Create 탭 Step 1 (Genre) 제목 옆에 Style Boost 버튼:
|
|
|
|
```jsx
|
|
<div className="ms-section__head">
|
|
<span className="ms-section__step">01</span>
|
|
<h2 className="ms-section__title">Genre</h2>
|
|
<span className="ms-section__hint">장르를 선택하세요</span>
|
|
{provider === 'suno' && (
|
|
<button
|
|
type="button"
|
|
className={`ms-btn ms-btn--ghost ms-btn--sm ms-style-boost-btn ${styleBoostLoading ? 'is-loading' : ''}`}
|
|
onClick={handleStyleBoost}
|
|
disabled={styleBoostLoading || !genre}
|
|
title="현재 설정으로 최적 스타일 프롬프트 생성"
|
|
>
|
|
{styleBoostLoading ? '생성 중...' : '✨ Style Boost'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
```
|
|
|
|
핸들러:
|
|
|
|
```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 (
|
|
<div className="ms-remix-tab">
|
|
<div className="ms-remix-tab__header">
|
|
<h2 className="ms-remix-tab__title">Remix Studio</h2>
|
|
<p className="ms-remix-tab__desc">외부 음원을 AI로 리메이크, 확장, 보컬/반주 추가</p>
|
|
</div>
|
|
|
|
<div className="ms-param-group">
|
|
<label className="ms-param-label">Audio URL</label>
|
|
<input
|
|
type="url"
|
|
className="ms-negative-tags__input"
|
|
placeholder="리믹스할 오디오 파일 URL (예: /media/music/track.mp3)"
|
|
value={uploadUrl}
|
|
onChange={(e) => setUploadUrl(e.target.value)}
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="ms-remix-actions">
|
|
{REMIX_ACTIONS.map((action) => (
|
|
<button
|
|
key={action.id}
|
|
type="button"
|
|
className={`ms-remix-card ${activeAction === action.id ? 'is-active' : ''}`}
|
|
onClick={() => setActiveAction(activeAction === action.id ? null : action.id)}
|
|
>
|
|
<span className="ms-remix-card__icon">{action.icon}</span>
|
|
<span className="ms-remix-card__label">{action.label}</span>
|
|
<span className="ms-remix-card__desc">{action.desc}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{activeAction && (
|
|
<div className="ms-remix-params">
|
|
{/* 공통 파라미터 */}
|
|
<div className="ms-param-group">
|
|
<label className="ms-param-label">Title</label>
|
|
<input type="text" className="ms-negative-tags__input" value={title}
|
|
onChange={(e) => setTitle(e.target.value)} placeholder="곡 제목" style={{ width: '100%' }} />
|
|
</div>
|
|
|
|
{(activeAction === 'cover' || activeAction === 'extend' || activeAction === 'add-vocals') && (
|
|
<div className="ms-param-group">
|
|
<label className="ms-param-label">Prompt / Lyrics</label>
|
|
<textarea className="ms-prompt" value={prompt}
|
|
onChange={(e) => setPrompt(e.target.value)} rows={3}
|
|
placeholder="가사 또는 스타일 설명" />
|
|
</div>
|
|
)}
|
|
|
|
{(activeAction === 'cover' || activeAction === 'extend' || activeAction === 'add-vocals') && (
|
|
<div className="ms-param-group">
|
|
<label className="ms-param-label">Style</label>
|
|
<input type="text" className="ms-negative-tags__input" value={style}
|
|
onChange={(e) => setStyle(e.target.value)} placeholder="예: Pop, Energetic, Piano" style={{ width: '100%' }} />
|
|
</div>
|
|
)}
|
|
|
|
{activeAction === 'add-instrumental' && (
|
|
<div className="ms-param-group">
|
|
<label className="ms-param-label">Tags (스타일/특성)</label>
|
|
<input type="text" className="ms-negative-tags__input" value={tags}
|
|
onChange={(e) => setTags(e.target.value)} placeholder="예: acoustic, warm, dreamy" style={{ width: '100%' }} />
|
|
</div>
|
|
)}
|
|
|
|
{activeAction === 'extend' && (
|
|
<div className="ms-param-group">
|
|
<label className="ms-param-label">Continue At (초)</label>
|
|
<input type="number" className="ms-negative-tags__input" value={continueAt}
|
|
onChange={(e) => setContinueAt(Number(e.target.value))} min={0} style={{ width: '120px' }} />
|
|
</div>
|
|
)}
|
|
|
|
<div className="ms-param-group">
|
|
<label className="ms-param-label">Exclude Styles</label>
|
|
<input type="text" className="ms-negative-tags__input" value={negativeTags}
|
|
onChange={(e) => setNegativeTags(e.target.value)} placeholder="제외할 스타일" style={{ width: '100%' }} />
|
|
</div>
|
|
|
|
<div className="ms-param-group">
|
|
<label className="ms-param-label">Vocal Gender</label>
|
|
<div className="ms-gender-toggle">
|
|
{[{ value: null, label: 'Auto' }, { value: 'm', label: 'Male' }, { value: 'f', label: 'Female' }].map((opt) => (
|
|
<button key={opt.label} type="button"
|
|
className={`ms-gender-btn ${vocalGender === opt.value ? 'is-active' : ''}`}
|
|
onClick={() => setVocalGender(opt.value)}>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className="ms-btn ms-btn--accent ms-remix-submit"
|
|
disabled={!uploadUrl || isGenerating}
|
|
onClick={handleSubmit}
|
|
>
|
|
{isGenerating ? 'Processing...' : `Start ${REMIX_ACTIONS.find(a => a.id === activeAction)?.label}`}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default RemixTab;
|
|
```
|
|
|
|
- [ ] **Step 3: MusicStudio.jsx에 Remix 탭 + Video 액션 통합**
|
|
|
|
```jsx
|
|
import RemixTab from './components/RemixTab';
|
|
import { generateVideo } from '../../api';
|
|
|
|
// 탭 네비게이션에 Remix 추가
|
|
<button
|
|
type="button"
|
|
className={`ms-tab ${tab === 'remix' ? 'is-active' : ''}`}
|
|
onClick={() => setTab('remix')}
|
|
>
|
|
<span className="ms-tab__icon">🔄</span> Remix
|
|
</button>
|
|
|
|
// 탭 내용에 Remix 추가
|
|
{tab === 'remix' && (
|
|
<RemixTab
|
|
onTaskStarted={(taskId, title) => {
|
|
setTab('create');
|
|
setIsGenerating(true);
|
|
setTrack(null);
|
|
setGenProgress(0);
|
|
setGenStep(`${title} 처리 중…`);
|
|
setGenError(null);
|
|
taskIdRef.current = taskId;
|
|
startPolling(taskId, title);
|
|
}}
|
|
model={model}
|
|
isGenerating={isGenerating}
|
|
/>
|
|
)}
|
|
|
|
// LibraryCard 더보기 메뉴에 Video 추가
|
|
<button type="button" onClick={() => { onVideoGenerate(track); setMenuOpen(false); }}
|
|
disabled={isGenerating}>
|
|
🎬 Music Video
|
|
</button>
|
|
```
|
|
|
|
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 신규 엔드포인트 반영"
|
|
```
|