Compare commits
9 Commits
262c088c8a
...
eb9bd65033
| Author | SHA1 | Date | |
|---|---|---|---|
| eb9bd65033 | |||
| a6fd44c697 | |||
| ad939dde40 | |||
| 26997a7dc7 | |||
| 94969f97a8 | |||
| 3e46cc41ca | |||
| 214eb320fa | |||
| c8ee3bb95b | |||
| 6ffa04f847 |
33
CLAUDE.md
33
CLAUDE.md
@@ -259,12 +259,30 @@ docker compose up -d
|
|||||||
| 메서드 | 경로 | 설명 |
|
| 메서드 | 경로 | 설명 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| GET | `/api/music/providers` | 사용 가능한 프로바이더 목록 |
|
| GET | `/api/music/providers` | 사용 가능한 프로바이더 목록 |
|
||||||
| POST | `/api/music/generate` | 음악 생성 시작 (provider, lyrics, instrumental 지원) |
|
| GET | `/api/music/models` | Suno 모델 목록 (V4~V5.5) |
|
||||||
| GET | `/api/music/status/{task_id}` | 생성 상태 폴링 (queued→processing→succeeded/failed) |
|
| GET | `/api/music/credits` | Suno 크레딧 조회 |
|
||||||
| POST | `/api/music/lyrics` | Suno AI 가사 생성 (곡 생성 전 미리보기용) |
|
| 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` | 라이브러리 전체 조회 |
|
| GET | `/api/music/library` | 라이브러리 전체 조회 |
|
||||||
| POST | `/api/music/library` | 트랙 수동 추가 (201) |
|
| POST | `/api/music/library` | 트랙 수동 추가 |
|
||||||
| DELETE | `/api/music/library/{id}` | 트랙 삭제 (로컬 파일 포함) |
|
| 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}` | 가사 삭제 |
|
||||||
|
|
||||||
**환경변수**
|
**환경변수**
|
||||||
- `SUNO_API_KEY`: Suno API 키 (미설정 시 Suno provider 비활성화)
|
- `SUNO_API_KEY`: Suno API 키 (미설정 시 Suno provider 비활성화)
|
||||||
@@ -277,6 +295,11 @@ docker compose up -d
|
|||||||
- `lyrics`: Suno 생성 가사 텍스트
|
- `lyrics`: Suno 생성 가사 텍스트
|
||||||
- `image_url`: Suno 생성 커버 이미지 URL
|
- `image_url`: Suno 생성 커버 이미지 URL
|
||||||
- `suno_id`: Suno 곡 ID (CDN 참조용)
|
- `suno_id`: Suno 곡 ID (CDN 참조용)
|
||||||
|
- `file_hash`: MD5 해시 (rename 감지용)
|
||||||
|
- `cover_images`: JSON 배열 — 커버 이미지 URL 목록
|
||||||
|
- `wav_url`: WAV 변환 URL
|
||||||
|
- `video_url`: 뮤직비디오 URL
|
||||||
|
- `stem_urls`: JSON 객체 — 12스템 URL 맵
|
||||||
|
|
||||||
**Suno 생성 특이사항**
|
**Suno 생성 특이사항**
|
||||||
- 1회 생성 시 2개 변형(variation) 반환 → 둘 다 라이브러리에 저장
|
- 1회 생성 시 2개 변형(variation) 반환 → 둘 다 라이브러리에 저장
|
||||||
|
|||||||
2594
docs/superpowers/plans/2026-04-08-music-lab-suno-enhancement.md
Normal file
2594
docs/superpowers/plans/2026-04-08-music-lab-suno-enhancement.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,398 @@
|
|||||||
|
# Music Lab Suno API 전체 기능 확장 설계
|
||||||
|
|
||||||
|
> 작성일: 2026-04-08
|
||||||
|
> 범위: 백엔드 (web-backend/music-lab) + 프론트엔드 (web-ui/src/pages/music)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
Suno API의 미사용 기능을 전부 활용하여 music-lab을 완전한 AI 음악 프로덕션 스튜디오로 업그레이드한다. 실용도 기준 3단계로 점진 확장.
|
||||||
|
|
||||||
|
## 2. 단계별 기능 목록
|
||||||
|
|
||||||
|
### Phase 1: 핵심 생성 강화
|
||||||
|
|
||||||
|
| # | 기능 | 설명 |
|
||||||
|
|---|------|------|
|
||||||
|
| 1-1 | 크레딧 잔액 상시 표시 | 헤더에 실시간 크레딧 배지, 10 이하 경고 |
|
||||||
|
| 1-2 | 보컬 성별 선택 | Male / Female / Auto 3버튼 |
|
||||||
|
| 1-3 | negativeTags | 제외 스타일 텍스트 + 프리셋 칩 |
|
||||||
|
| 1-4 | V5_5 모델 추가 | SUNO_MODELS 딕셔너리에 추가 |
|
||||||
|
| 1-5 | styleWeight / audioWeight | 0~1.0 슬라이더 2개 |
|
||||||
|
| 1-6 | 커버 이미지 생성 | 라이브러리 카드에서 앨범아트 2장 생성 |
|
||||||
|
|
||||||
|
### Phase 2: 후처리 파워업
|
||||||
|
|
||||||
|
| # | 기능 | 설명 |
|
||||||
|
|---|------|------|
|
||||||
|
| 2-1 | WAV 고음질 변환 | MP3→WAV 변환 다운로드 |
|
||||||
|
| 2-2 | 12스템 분리 | 드럼/베이스/기타 등 12개 개별 추출 |
|
||||||
|
| 2-3 | 타임스탬프 가사 | 가라오케 스타일 싱크 재생 |
|
||||||
|
| 2-4 | 스타일 부스트 | AI로 최적 스타일 프롬프트 자동 생성 |
|
||||||
|
|
||||||
|
### Phase 3: 고급 크리에이티브
|
||||||
|
|
||||||
|
| # | 기능 | 설명 |
|
||||||
|
|---|------|------|
|
||||||
|
| 3-1 | 오디오 업로드 + 커버 | 외부 음원을 Suno 스타일로 리메이크 |
|
||||||
|
| 3-2 | 오디오 업로드 + 확장 | 외부 음원 이어서 만들기 |
|
||||||
|
| 3-3 | 보컬 추가 | 인스트루멘탈에 AI 보컬 입히기 |
|
||||||
|
| 3-4 | 인스트루멘탈 추가 | 보컬에 AI 반주 입히기 |
|
||||||
|
| 3-5 | 뮤직비디오 생성 | MP4 자동 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 백엔드 API 설계
|
||||||
|
|
||||||
|
### 3.1 기존 엔드포인트 수정
|
||||||
|
|
||||||
|
#### GenerateRequest 스키마 확장 (main.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class GenerateRequest(BaseModel):
|
||||||
|
# 기존 필드 유지
|
||||||
|
provider: str = "suno"
|
||||||
|
model: str = "V4"
|
||||||
|
title: str = ""
|
||||||
|
genre: str = ""
|
||||||
|
moods: list[str] = []
|
||||||
|
instruments: list[str] = []
|
||||||
|
duration_sec: int | None = None
|
||||||
|
bpm: int | None = None
|
||||||
|
key: str = ""
|
||||||
|
scale: str = ""
|
||||||
|
prompt: str = ""
|
||||||
|
lyrics: str = ""
|
||||||
|
instrumental: bool = False
|
||||||
|
|
||||||
|
# Phase 1 추가
|
||||||
|
vocal_gender: str | None = None # "m" | "f" | None(auto)
|
||||||
|
negative_tags: str | None = None # 제외 스타일
|
||||||
|
style_weight: float | None = None # 0.0~1.0
|
||||||
|
audio_weight: float | None = None # 0.0~1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SUNO_MODELS 확장 (suno_provider.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
SUNO_MODELS = {
|
||||||
|
"V4": {"name": "V4", "max_duration": 240},
|
||||||
|
"V4_5": {"name": "V4.5", "max_duration": 480},
|
||||||
|
"V4_5PLUS": {"name": "V4.5+", "max_duration": 480},
|
||||||
|
"V4_5ALL": {"name": "V4.5 All","max_duration": 480},
|
||||||
|
"V5": {"name": "V5", "max_duration": 480},
|
||||||
|
"V5_5": {"name": "V5.5", "max_duration": 480}, # 추가
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### _build_suno_payload 확장
|
||||||
|
|
||||||
|
새 파라미터를 Suno API 페이로드에 매핑:
|
||||||
|
- `vocal_gender` → `vocalGender`
|
||||||
|
- `negative_tags` → `negativeTags`
|
||||||
|
- `style_weight` → `styleWeight`
|
||||||
|
- `audio_weight` → `audioWeight`
|
||||||
|
|
||||||
|
None이 아닌 경우에만 페이로드에 포함.
|
||||||
|
|
||||||
|
### 3.2 신규 엔드포인트
|
||||||
|
|
||||||
|
#### Phase 1
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/music/cover-image
|
||||||
|
Request: { "task_id": str, "suno_id": str }
|
||||||
|
Response: { "task_id": str } → 폴링 → { "images": [url1, url2] }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Phase 2
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/music/wav
|
||||||
|
Request: { "task_id": str, "suno_id": str }
|
||||||
|
Response: { "task_id": str } → 폴링 → { "wav_url": str }
|
||||||
|
|
||||||
|
POST /api/music/stem-split
|
||||||
|
Request: { "task_id": str, "suno_id": str }
|
||||||
|
Response: { "task_id": str } → 폴링 → { "stems": { vocal: url, drums: url, ... } }
|
||||||
|
|
||||||
|
GET /api/music/timestamped-lyrics?task_id=...&suno_id=...
|
||||||
|
Response: { "aligned_words": [...], "waveform_data": [...] }
|
||||||
|
|
||||||
|
POST /api/music/style-boost
|
||||||
|
Request: { "content": str }
|
||||||
|
Response: { "result": str, "credits_consumed": float }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Phase 3
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/music/upload-cover
|
||||||
|
Request: { "upload_url": str, "model": str, "custom_mode": bool,
|
||||||
|
"instrumental": bool, "prompt"?: str, "style"?: str, "title"?: str,
|
||||||
|
"vocal_gender"?: str, "negative_tags"?: str,
|
||||||
|
"style_weight"?: float, "audio_weight"?: float }
|
||||||
|
Response: { "task_id": str }
|
||||||
|
|
||||||
|
POST /api/music/upload-extend
|
||||||
|
Request: { "upload_url": str, "model": str, "continue_at"?: float,
|
||||||
|
"default_param_flag": bool, "prompt"?: str, "style"?: str, "title"?: str,
|
||||||
|
"vocal_gender"?: str, "negative_tags"?: str }
|
||||||
|
Response: { "task_id": str }
|
||||||
|
|
||||||
|
POST /api/music/add-vocals
|
||||||
|
Request: { "upload_url": str, "prompt": str, "title": str, "style": str,
|
||||||
|
"negative_tags": str, "vocal_gender"?: str, "model"?: str,
|
||||||
|
"style_weight"?: float, "audio_weight"?: float }
|
||||||
|
Response: { "task_id": str }
|
||||||
|
|
||||||
|
POST /api/music/add-instrumental
|
||||||
|
Request: { "upload_url": str, "title": str, "tags": str, "negative_tags": str,
|
||||||
|
"vocal_gender"?: str, "model"?: str,
|
||||||
|
"style_weight"?: float, "audio_weight"?: float }
|
||||||
|
Response: { "task_id": str }
|
||||||
|
|
||||||
|
POST /api/music/video
|
||||||
|
Request: { "task_id": str, "suno_id": str, "author"?: str, "domain_name"?: str }
|
||||||
|
Response: { "task_id": str } → 폴링 → { "video_url": str }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 suno_provider.py 리팩토링
|
||||||
|
|
||||||
|
**공통 폴링 헬퍼 추출:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _poll_suno_task(
|
||||||
|
record_info_url: str,
|
||||||
|
task_id: str,
|
||||||
|
max_attempts: int = 40,
|
||||||
|
interval: int = 8,
|
||||||
|
success_extractor: Callable[[dict], Any] = None
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
범용 Suno 작업 폴링.
|
||||||
|
record_info_url: 예) "/api/v1/generate/record-info"
|
||||||
|
success_extractor: SUCCESS 상태일 때 결과 추출 함수
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 `run_suno_generation`, `run_suno_extend`, `run_vocal_removal`도 이 헬퍼를 사용하도록 리팩토링.
|
||||||
|
|
||||||
|
**신규 함수 목록:**
|
||||||
|
|
||||||
|
| 함수 | Phase | Suno 엔드포인트 | 비동기 |
|
||||||
|
|------|-------|----------------|--------|
|
||||||
|
| `run_cover_image` | 1 | `POST /api/v1/suno/cover/generate` | 폴링 |
|
||||||
|
| `run_wav_convert` | 2 | `POST /api/v1/wav/generate` | 폴링 |
|
||||||
|
| `run_stem_split` | 2 | `POST /api/v1/vocal-removal/generate` (type=split_stem) | 폴링 |
|
||||||
|
| `get_timestamped_lyrics` | 2 | `POST /api/v1/generate/get-timestamped-lyrics` | 동기 |
|
||||||
|
| `generate_style_boost` | 2 | `POST /api/v1/style/generate` | 동기 |
|
||||||
|
| `run_upload_cover` | 3 | `POST /api/v1/generate/upload-cover` | 폴링 |
|
||||||
|
| `run_upload_extend` | 3 | `POST /api/v1/generate/upload-extend` | 폴링 |
|
||||||
|
| `run_add_vocals` | 3 | `POST /api/v1/generate/add-vocals` | 폴링 |
|
||||||
|
| `run_add_instrumental` | 3 | `POST /api/v1/generate/add-instrumental` | 폴링 |
|
||||||
|
| `run_video_generate` | 3 | `POST /api/v1/mp4/generate` | 폴링 |
|
||||||
|
|
||||||
|
### 3.4 DB 스키마 변경
|
||||||
|
|
||||||
|
**music_library 테이블 컬럼 추가 (ALTER TABLE 마이그레이션):**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE music_library ADD COLUMN cover_images TEXT NOT NULL DEFAULT '[]';
|
||||||
|
ALTER TABLE music_library ADD COLUMN wav_url TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE music_library ADD COLUMN video_url TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE music_library ADD COLUMN stem_urls TEXT NOT NULL DEFAULT '{}';
|
||||||
|
```
|
||||||
|
|
||||||
|
**db.py 함수 추가:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def update_track_cover_images(track_id: int, images: list[str])
|
||||||
|
def update_track_wav_url(track_id: int, wav_url: str)
|
||||||
|
def update_track_video_url(track_id: int, video_url: str)
|
||||||
|
def update_track_stem_urls(track_id: int, stems: dict)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 프론트엔드 UI/UX 설계
|
||||||
|
|
||||||
|
### 4.1 파일 구조 (컴포넌트 분할)
|
||||||
|
|
||||||
|
```
|
||||||
|
web-ui/src/pages/music/
|
||||||
|
├── MusicStudio.jsx -- 메인 (탭 라우팅 + 공유 상태)
|
||||||
|
├── MusicStudio.css -- 전체 스타일
|
||||||
|
├── components/
|
||||||
|
│ ├── CreateTab.jsx -- 생성 폼 (6단계 + Phase 1 확장)
|
||||||
|
│ ├── LyricsTab.jsx -- 가사 관리
|
||||||
|
│ ├── LibraryTab.jsx -- 라이브러리 + 카드
|
||||||
|
│ ├── RemixTab.jsx -- Phase 3: 업로드/리믹스
|
||||||
|
│ ├── AudioPlayer.jsx -- 오디오 플레이어
|
||||||
|
│ ├── LibraryCard.jsx -- 트랙 카드 + 액션 메뉴
|
||||||
|
│ ├── StemModal.jsx -- 12스템 결과 모달
|
||||||
|
│ ├── CoverArtModal.jsx -- 커버 이미지 선택 모달
|
||||||
|
│ ├── SyncedLyricsPlayer.jsx -- 타임스탬프 가사 오버레이
|
||||||
|
│ └── CreditsBadge.jsx -- 크레딧 잔액 배지
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Phase 1 UI 변경
|
||||||
|
|
||||||
|
#### 크레딧 배지 (CreditsBadge)
|
||||||
|
- 위치: 헤더 우측 상단, 탭 옆
|
||||||
|
- 표시: `⚡ 127 credits`
|
||||||
|
- 10 이하: 빨간색 + pulse 애니메이션
|
||||||
|
- 갱신 시점: 페이지 로드, 생성 완료 후, 30초 자동 갱신
|
||||||
|
|
||||||
|
#### Create 탭 Step 4 확장
|
||||||
|
|
||||||
|
**Vocal Gender (Suno 전용):**
|
||||||
|
- 3버튼 토글: `♂ Male` | `♀ Female` | `Auto`
|
||||||
|
- 기본값: Auto
|
||||||
|
- 스타일: `.ms-gender-toggle` (기존 duration-rail과 유사)
|
||||||
|
|
||||||
|
**Negative Tags:**
|
||||||
|
- 텍스트 입력 필드 + 프리셋 칩
|
||||||
|
- 프리셋: screaming, autotune, distortion, whisper, falsetto, rap
|
||||||
|
- 칩 클릭 시 텍스트에 추가/제거
|
||||||
|
- 스타일: `.ms-negative-tags` (기존 mood-rack과 유사)
|
||||||
|
|
||||||
|
**Style Weight / Audio Weight:**
|
||||||
|
- range 슬라이더 2개 (기존 BPM 슬라이더와 동일 스타일)
|
||||||
|
- 레이블: "Prompt ↔ Style Balance" / "Original ↔ AI Balance"
|
||||||
|
- 0~100 표시, API 전송 시 0.0~1.0 변환
|
||||||
|
- 기본값: 미설정 (슬라이더 중앙, 값 전송 안 함)
|
||||||
|
|
||||||
|
#### Library 카드 액션 메뉴 확장
|
||||||
|
|
||||||
|
기존 5개 버튼 → 6개 (Cover Art 추가)
|
||||||
|
4개 초과 시 `•••` 더보기 드롭다운 메뉴로 분기:
|
||||||
|
- 기본 노출: Play, Download, Delete
|
||||||
|
- 더보기: Extend, Vocal Split, Cover Art (+ Phase 2/3 추가분)
|
||||||
|
|
||||||
|
#### CoverArtModal
|
||||||
|
- 2장 이미지 좌우 비교 표시
|
||||||
|
- 각 이미지 아래 "이 이미지 사용" 버튼
|
||||||
|
- 선택 시 라이브러리 카드 썸네일 업데이트
|
||||||
|
|
||||||
|
### 4.3 Phase 2 UI 변경
|
||||||
|
|
||||||
|
#### Library 카드 더보기 메뉴 추가
|
||||||
|
- WAV 다운로드
|
||||||
|
- Stem Split (12스템)
|
||||||
|
- Synced Lyrics
|
||||||
|
- Style Boost (Create 탭 프롬프트로 전달)
|
||||||
|
|
||||||
|
#### StemModal
|
||||||
|
- 3×4 그리드 카드 레이아웃
|
||||||
|
- 각 스템: 이름 아이콘 + 미니 재생 버튼 + 다운로드 버튼
|
||||||
|
- 12개 스템: vocal, backing_vocals, drums, bass, guitar, keyboard, strings, brass, woodwinds, percussion, synth, fx
|
||||||
|
- 스타일: 기존 라이브러리 카드의 축소 버전
|
||||||
|
|
||||||
|
#### SyncedLyricsPlayer
|
||||||
|
- AudioPlayer 교체/오버레이 모드
|
||||||
|
- 재생 중 현재 단어를 accent 컬러로 하이라이트
|
||||||
|
- 하단에 waveformData 기반 파형 바
|
||||||
|
- 닫기 버튼으로 일반 플레이어 복귀
|
||||||
|
|
||||||
|
#### Style Boost 버튼
|
||||||
|
- Create 탭 장르 선택 영역에 `✨ Style Boost` 버튼
|
||||||
|
- 클릭 시: 현재 genre + moods 조합 → API 호출 → 결과를 프롬프트에 삽입
|
||||||
|
- 로딩 중 버튼 스피너
|
||||||
|
|
||||||
|
### 4.4 Phase 3 UI 변경
|
||||||
|
|
||||||
|
#### Remix 탭 (신규 4번째 탭)
|
||||||
|
- 탭 레이블: `REMIX`
|
||||||
|
- 상단: 오디오 URL 입력 필드 (또는 라이브러리에서 선택)
|
||||||
|
- 4개 액션 카드 그리드 (2×2):
|
||||||
|
- **AI Cover**: 아이콘 + 설명 + 파라미터 폼 (펼침)
|
||||||
|
- **Extend**: 아이콘 + 설명 + continue_at 입력
|
||||||
|
- **Add Vocals**: 아이콘 + 설명 + prompt/style 입력
|
||||||
|
- **Add Instrumental**: 아이콘 + 설명 + tags 입력
|
||||||
|
- 선택한 카드만 펼쳐서 세부 옵션 표시
|
||||||
|
- 하단: 뮤직비디오 생성 버튼 (라이브러리에서 선택한 곡 대상)
|
||||||
|
|
||||||
|
### 4.5 디자인 토큰 추가
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Phase 1 추가 토큰 */
|
||||||
|
--ms-danger: #e74c3c; /* 크레딧 경고 빨간색 */
|
||||||
|
--ms-male: #4a9eff; /* 남성 보컬 파란색 */
|
||||||
|
--ms-female: #ff6b9d; /* 여성 보컬 분홍색 */
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. api.js 추가 함수
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Phase 1
|
||||||
|
export const generateCoverImage = (payload) => apiPost('/api/music/cover-image', payload);
|
||||||
|
|
||||||
|
// Phase 2
|
||||||
|
export const convertToWav = (payload) => apiPost('/api/music/wav', payload);
|
||||||
|
export const splitStems = (payload) => apiPost('/api/music/stem-split', payload);
|
||||||
|
export const getTimestampedLyrics = (taskId, sunoId) =>
|
||||||
|
apiGet(`/api/music/timestamped-lyrics?task_id=${taskId}&suno_id=${sunoId}`);
|
||||||
|
export const generateStyleBoost = (content) => apiPost('/api/music/style-boost', { content });
|
||||||
|
|
||||||
|
// Phase 3
|
||||||
|
export const uploadAndCover = (payload) => apiPost('/api/music/upload-cover', payload);
|
||||||
|
export const uploadAndExtend = (payload) => apiPost('/api/music/upload-extend', payload);
|
||||||
|
export const addVocals = (payload) => apiPost('/api/music/add-vocals', payload);
|
||||||
|
export const addInstrumental = (payload) => apiPost('/api/music/add-instrumental', payload);
|
||||||
|
export const generateVideo = (payload) => apiPost('/api/music/video', payload);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 폴링 패턴
|
||||||
|
|
||||||
|
모든 비동기 작업은 기존 음악 생성과 동일한 폴링 패턴:
|
||||||
|
|
||||||
|
1. POST 요청 → `{ task_id }` 반환
|
||||||
|
2. 프론트: 3초 간격 `GET /api/music/status/{task_id}` 폴링
|
||||||
|
3. 백엔드: BackgroundTask에서 Suno 폴링 → music_tasks 상태 업데이트
|
||||||
|
4. `status: succeeded` → 결과 반환 + 라이브러리 자동 갱신
|
||||||
|
|
||||||
|
동기 API (타임스탬프 가사, 스타일 부스트)는 즉시 응답.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 구현 순서
|
||||||
|
|
||||||
|
### Phase 1 (핵심 생성 강화)
|
||||||
|
1. 백엔드: SUNO_MODELS에 V5_5 추가 + 공통 폴링 헬퍼 추출
|
||||||
|
2. 백엔드: GenerateRequest 스키마 확장 + _build_suno_payload 매핑
|
||||||
|
3. 백엔드: 커버 이미지 생성 엔드포인트 + suno_provider 함수
|
||||||
|
4. 백엔드: DB 마이그레이션 (cover_images, wav_url, video_url, stem_urls)
|
||||||
|
5. 프론트: 컴포넌트 분할 (MusicStudio → CreateTab, LyricsTab, LibraryTab, etc.)
|
||||||
|
6. 프론트: CreditsBadge 구현
|
||||||
|
7. 프론트: Create 탭 Step 4 확장 (vocal gender, negative tags, weight 슬라이더)
|
||||||
|
8. 프론트: LibraryCard 더보기 메뉴 + CoverArtModal
|
||||||
|
9. 프론트: api.js 함수 추가
|
||||||
|
|
||||||
|
### Phase 2 (후처리 파워업)
|
||||||
|
10. 백엔드: WAV/스템/타임스탬프/스타일부스트 엔드포인트
|
||||||
|
11. 프론트: StemModal + SyncedLyricsPlayer + Style Boost 버튼
|
||||||
|
12. 프론트: Library 카드 Phase 2 액션 추가
|
||||||
|
|
||||||
|
### Phase 3 (고급 크리에이티브)
|
||||||
|
13. 백엔드: upload-cover/upload-extend/add-vocals/add-instrumental/video 엔드포인트
|
||||||
|
14. 프론트: RemixTab 구현
|
||||||
|
15. 프론트: Library 카드 Phase 3 액션 (Video)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 제약사항 및 주의점
|
||||||
|
|
||||||
|
- **Suno 파일 보관**: 14~15일 후 자동 삭제 → 로컬 다운로드 필수 (기존 패턴 유지)
|
||||||
|
- **동시 요청 제한**: 10초당 20건 → 배치 작업 시 rate limiting 고려
|
||||||
|
- **12스템 분리 비용**: 50크레딧 → UI에 비용 경고 표시
|
||||||
|
- **WAV 중복 변환**: 409 에러 → 이미 변환된 경우 기존 URL 반환
|
||||||
|
- **뮤직비디오**: taskId + audioId 필요 → 라이브러리에 task_id 저장 필수
|
||||||
|
- **V5_5 모델**: 커스텀 모델 → 문서상 제한사항 추가 확인 필요
|
||||||
|
- **크레딧 조회 엔드포인트**: 기존 `/api/v1/get-credits` vs 문서 `/api/v1/generate/credit` → 두 엔드포인트 폴백 시도
|
||||||
|
- **upload 계열 API**: upload_url은 외부 접근 가능한 URL이어야 함 → 로컬 파일은 NAS nginx URL로 변환
|
||||||
@@ -83,6 +83,18 @@ def init_db() -> None:
|
|||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
# ── music_tasks CRUD ──────────────────────────────────────────────────────────
|
# ── music_tasks CRUD ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -161,6 +173,10 @@ def _track_row_to_dict(r) -> Dict[str, Any]:
|
|||||||
"image_url": r["image_url"] if "image_url" in keys else "",
|
"image_url": r["image_url"] if "image_url" in keys else "",
|
||||||
"suno_id": r["suno_id"] if "suno_id" in keys else "",
|
"suno_id": r["suno_id"] if "suno_id" in keys else "",
|
||||||
"file_hash": r["file_hash"] if "file_hash" in keys else "",
|
"file_hash": r["file_hash"] if "file_hash" in keys else "",
|
||||||
|
"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 {},
|
||||||
"created_at": r["created_at"],
|
"created_at": r["created_at"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,6 +316,26 @@ def update_lyrics(lyrics_id: int, data: Dict[str, Any]) -> Optional[Dict[str, An
|
|||||||
return _lyrics_row_to_dict(row) if row else None
|
return _lyrics_row_to_dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
def delete_lyrics(lyrics_id: int) -> bool:
|
def delete_lyrics(lyrics_id: int) -> bool:
|
||||||
with _conn() as conn:
|
with _conn() as conn:
|
||||||
row = conn.execute("SELECT id FROM saved_lyrics WHERE id = ?", (lyrics_id,)).fetchone()
|
row = conn.execute("SELECT id FROM saved_lyrics WHERE id = ?", (lyrics_id,)).fetchone()
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ from .db import (
|
|||||||
from .local_provider import run_local_generation
|
from .local_provider import run_local_generation
|
||||||
from .suno_provider import (
|
from .suno_provider import (
|
||||||
run_suno_generation, run_suno_extend, run_vocal_removal,
|
run_suno_generation, run_suno_extend, run_vocal_removal,
|
||||||
generate_lyrics, get_credits,
|
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,
|
SUNO_API_KEY, SUNO_MODELS,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,6 +104,11 @@ class GenerateRequest(BaseModel):
|
|||||||
# Suno 전용
|
# Suno 전용
|
||||||
lyrics: str = "" # 커스텀 가사 ([Verse], [Chorus] 등)
|
lyrics: str = "" # 커스텀 가사 ([Verse], [Chorus] 등)
|
||||||
instrumental: bool = False # True면 보컬 없이 인스트루멘탈만
|
instrumental: bool = False # True면 보컬 없이 인스트루멘탈만
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/music/generate")
|
@app.post("/api/music/generate")
|
||||||
@@ -402,6 +409,224 @@ def vocal_removal(req: VocalRemovalRequest, background_tasks: BackgroundTasks):
|
|||||||
return {"task_id": task_id, "provider": "suno"}
|
return {"task_id": task_id, "provider": "suno"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 커버 이미지 생성 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"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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"}
|
||||||
|
|
||||||
|
|
||||||
# ── 저장된 가사 CRUD API ────────────────────────────────────────────────────
|
# ── 저장된 가사 CRUD API ────────────────────────────────────────────────────
|
||||||
|
|
||||||
class LyricsSave(BaseModel):
|
class LyricsSave(BaseModel):
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ Suno API Provider — sunoapi.org 래퍼를 통한 음악 생성
|
|||||||
https://docs.sunoapi.org/suno-api/quickstart
|
https://docs.sunoapi.org/suno-api/quickstart
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .db import update_task, add_track
|
from .db import (
|
||||||
|
update_task, add_track,
|
||||||
|
update_track_cover_images, update_track_wav_url,
|
||||||
|
update_track_video_url, update_track_stem_urls,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -29,6 +34,7 @@ SUNO_MODELS = [
|
|||||||
{"id": "V4_5PLUS", "name": "V4.5+", "max_duration": "8분", "description": "강화된 음악성"},
|
{"id": "V4_5PLUS", "name": "V4.5+", "max_duration": "8분", "description": "강화된 음악성"},
|
||||||
{"id": "V4_5ALL", "name": "V4.5 All", "max_duration": "8분", "description": "더 나은 곡 구조"},
|
{"id": "V4_5ALL", "name": "V4.5 All", "max_duration": "8분", "description": "더 나은 곡 구조"},
|
||||||
{"id": "V5", "name": "V5", "max_duration": "8분", "description": "최신, 빠른 생성 + 뛰어난 음악성"},
|
{"id": "V5", "name": "V5", "max_duration": "8분", "description": "최신, 빠른 생성 + 뛰어난 음악성"},
|
||||||
|
{"id": "V5_5", "name": "V5.5", "max_duration": "8분", "description": "커스텀 모델, 최신 음악성"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -140,9 +146,13 @@ def run_suno_generation(task_id: str, params: dict) -> None:
|
|||||||
logger.info("Suno task created: %s (internal: %s)", suno_task_id, task_id)
|
logger.info("Suno task created: %s (internal: %s)", suno_task_id, task_id)
|
||||||
|
|
||||||
# ── 2단계: 상태 폴링 ──
|
# ── 2단계: 상태 폴링 ──
|
||||||
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:
|
if not completed_tracks:
|
||||||
return # 에러는 _poll_until_complete 내부에서 처리
|
update_task(task_id, "failed", 0, "", error="Suno 생성 완료했으나 트랙 데이터 없음")
|
||||||
|
return
|
||||||
|
|
||||||
update_task(task_id, "processing", 80, "오디오 파일 다운로드 중...")
|
update_task(task_id, "processing", 80, "오디오 파일 다운로드 중...")
|
||||||
|
|
||||||
@@ -231,63 +241,73 @@ def _build_suno_payload(params: dict) -> dict:
|
|||||||
parts.append(", ".join(params["moods"]))
|
parts.append(", ".join(params["moods"]))
|
||||||
payload["prompt"] = " ".join(parts)[:500] if parts else "instrumental music"
|
payload["prompt"] = " ".join(parts)[:500] if parts else "instrumental music"
|
||||||
|
|
||||||
|
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
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _poll_until_complete(task_id: str, suno_task_id: str) -> Optional[list]:
|
def _poll_suno_record(
|
||||||
"""sunoapi.org record-info를 폴링하여 SUCCESS가 될 때까지 대기."""
|
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 객체 반환."""
|
||||||
error_statuses = {
|
error_statuses = {
|
||||||
"CREATE_TASK_FAILED", "GENERATE_AUDIO_FAILED",
|
"CREATE_TASK_FAILED", "GENERATE_AUDIO_FAILED",
|
||||||
"CALLBACK_EXCEPTION", "SENSITIVE_WORD_ERROR",
|
"CALLBACK_EXCEPTION", "SENSITIVE_WORD_ERROR",
|
||||||
}
|
}
|
||||||
|
default_msgs = {
|
||||||
|
"PENDING": "대기열에서 대기 중...",
|
||||||
|
"TEXT_SUCCESS": "가사 생성 완료, 음악 생성 중...",
|
||||||
|
"FIRST_SUCCESS": "첫 번째 트랙 완료, 두 번째 생성 중...",
|
||||||
|
"GENERATING": "생성 중...",
|
||||||
|
}
|
||||||
|
msgs = {**default_msgs, **(progress_msg_map or {})}
|
||||||
|
|
||||||
for attempt in range(POLL_MAX_ATTEMPTS):
|
for attempt in range(max_attempts):
|
||||||
time.sleep(POLL_INTERVAL)
|
time.sleep(interval)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
f"{SUNO_BASE_URL}/generate/record-info",
|
f"{SUNO_BASE_URL}{record_info_path}",
|
||||||
headers=_headers(),
|
headers=_headers(),
|
||||||
params={"taskId": suno_task_id},
|
params={"taskId": suno_task_id},
|
||||||
timeout=15,
|
timeout=15,
|
||||||
)
|
)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
if body.get("code") != 200:
|
if body.get("code") != 200:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
data = body.get("data", {})
|
data = body.get("data", {})
|
||||||
status = data.get("status", "")
|
status = data.get("status", "")
|
||||||
progress = min(15 + int((attempt / POLL_MAX_ATTEMPTS) * 65), 79)
|
progress = min(15 + int((attempt / max_attempts) * 65), 79)
|
||||||
|
|
||||||
if status == "PENDING":
|
if status == "SUCCESS":
|
||||||
update_task(task_id, "processing", progress, "대기열에서 대기 중...")
|
return data.get("response", data)
|
||||||
elif status == "TEXT_SUCCESS":
|
|
||||||
update_task(task_id, "processing", progress, "가사 생성 완료, 음악 생성 중...")
|
|
||||||
elif status == "FIRST_SUCCESS":
|
|
||||||
update_task(task_id, "processing", max(progress, 60), "첫 번째 트랙 완료, 두 번째 생성 중...")
|
|
||||||
elif status == "SUCCESS":
|
|
||||||
# data.response.sunoData 에 트랙 배열이 들어있음
|
|
||||||
response_obj = data.get("response", {})
|
|
||||||
tracks = response_obj.get("sunoData") or []
|
|
||||||
if tracks:
|
|
||||||
return tracks
|
|
||||||
update_task(task_id, "failed", 0, "", error="Suno 생성 완료했으나 트랙 데이터 없음")
|
|
||||||
return None
|
|
||||||
elif status in error_statuses:
|
elif status in error_statuses:
|
||||||
error_msg = data.get("errorMessage") or data.get("msg") or f"Suno 생성 실패 ({status})"
|
error_msg = data.get("errorMessage") or data.get("msg") or f"Suno 작업 실패 ({status})"
|
||||||
update_task(task_id, "failed", 0, "", error=error_msg)
|
update_task(task_id, "failed", 0, "", error=error_msg)
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
update_task(task_id, "processing", progress, f"처리 중... ({status})")
|
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:
|
except Exception as e:
|
||||||
logger.warning("Suno poll error (attempt %d): %s", attempt, e)
|
logger.warning("Suno poll error (attempt %d): %s", attempt, e)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
update_task(task_id, "failed", 0, "", error="Suno 생성 타임아웃 (5분 초과)")
|
update_task(task_id, "failed", 0, "", error="Suno 작업 타임아웃")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -351,20 +371,20 @@ def _download_and_register(
|
|||||||
# ── 크레딧 조회 ──────────────────────────────────────────────────────────────
|
# ── 크레딧 조회 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_credits() -> Optional[dict]:
|
def get_credits() -> Optional[dict]:
|
||||||
"""Suno API 잔여 크레딧 조회."""
|
"""Suno API 잔여 크레딧 조회. 두 엔드포인트 폴백."""
|
||||||
if not SUNO_API_KEY:
|
if not SUNO_API_KEY:
|
||||||
return None
|
return None
|
||||||
try:
|
for path in ["/generate/credit", "/get-credits"]:
|
||||||
resp = requests.get(
|
try:
|
||||||
f"{SUNO_BASE_URL}/get-credits",
|
resp = requests.get(f"{SUNO_BASE_URL}{path}", headers=_headers(), timeout=15)
|
||||||
headers=_headers(),
|
if resp.status_code == 200:
|
||||||
timeout=15,
|
body = resp.json()
|
||||||
)
|
data = body.get("data", body)
|
||||||
if resp.status_code == 200:
|
if isinstance(data, (int, float)):
|
||||||
body = resp.json()
|
return {"credits_left": int(data)}
|
||||||
return body.get("data", body)
|
return data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Suno credits API error: %s", e)
|
logger.warning("Suno credits API error (%s): %s", path, e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -418,8 +438,12 @@ def run_suno_extend(task_id: str, params: dict) -> None:
|
|||||||
|
|
||||||
update_task(task_id, "processing", 15, "곡 연장 대기열에 등록됨...")
|
update_task(task_id, "processing", 15, "곡 연장 대기열에 등록됨...")
|
||||||
|
|
||||||
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:
|
if not completed_tracks:
|
||||||
|
update_task(task_id, "failed", 0, "", error="Suno 연장 완료했으나 트랙 데이터 없음")
|
||||||
return
|
return
|
||||||
|
|
||||||
update_task(task_id, "processing", 80, "연장된 오디오 다운로드 중...")
|
update_task(task_id, "processing", 80, "연장된 오디오 다운로드 중...")
|
||||||
@@ -483,8 +507,12 @@ def run_vocal_removal(task_id: str, params: dict) -> None:
|
|||||||
update_task(task_id, "processing", 15, "보컬 분리 처리 중...")
|
update_task(task_id, "processing", 15, "보컬 분리 처리 중...")
|
||||||
|
|
||||||
# 보컬 분리 결과 폴링
|
# 보컬 분리 결과 폴링
|
||||||
completed_tracks = _poll_until_complete(task_id, suno_task_id)
|
response = _poll_suno_record("/vocal-removal/record-info", suno_task_id, task_id)
|
||||||
|
if not response:
|
||||||
|
return
|
||||||
|
completed_tracks = response.get("sunoData") or []
|
||||||
if not completed_tracks:
|
if not completed_tracks:
|
||||||
|
update_task(task_id, "failed", 0, "", error="보컬 분리 완료했으나 트랙 데이터 없음")
|
||||||
return
|
return
|
||||||
|
|
||||||
update_task(task_id, "processing", 80, "분리된 오디오 다운로드 중...")
|
update_task(task_id, "processing", 80, "분리된 오디오 다운로드 중...")
|
||||||
@@ -512,3 +540,511 @@ def run_vocal_removal(task_id: str, params: dict) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Suno vocal removal error for task %s", task_id)
|
logger.exception("Suno vocal removal error for task %s", task_id)
|
||||||
update_task(task_id, "failed", 0, "", error=str(e))
|
update_task(task_id, "failed", 0, "", error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 커버 이미지 생성 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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))
|
||||||
|
if params.get("track_id") and image_urls:
|
||||||
|
update_track_cover_images(params["track_id"], 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))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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:
|
||||||
|
body = resp.json()
|
||||||
|
wav_url = body.get("data", {}).get("audioWavUrl", "")
|
||||||
|
if wav_url:
|
||||||
|
update_task(task_id, "succeeded", 100, "WAV 변환 완료 (캐시)", audio_url=wav_url)
|
||||||
|
if params.get("track_id") and wav_url:
|
||||||
|
update_track_wav_url(params["track_id"], 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)
|
||||||
|
if params.get("track_id") and wav_url:
|
||||||
|
update_track_wav_url(params["track_id"], 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))
|
||||||
|
if params.get("track_id") and stems:
|
||||||
|
update_track_stem_urls(params["track_id"], 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
|
||||||
|
|
||||||
|
|
||||||
|
# ── 오디오 업로드 + 커버 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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)
|
||||||
|
if params.get("track_id") and video_url:
|
||||||
|
update_track_video_url(params["track_id"], 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))
|
||||||
|
|||||||
Reference in New Issue
Block a user