diff --git a/src/api.js b/src/api.js index 2fdca69..6bedae5 100644 --- a/src/api.js +++ b/src/api.js @@ -363,6 +363,33 @@ export function generateStyleBoost(content) { return apiPost('/api/music/style-boost', { content }); } +// ── 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); +} + // ── 로또 고도화 API ──────────────────────────────────────────────────────────── // GET /api/lotto/stats/performance diff --git a/src/pages/music/MusicStudio.css b/src/pages/music/MusicStudio.css index 8fe9bb3..1f2f59a 100644 --- a/src/pages/music/MusicStudio.css +++ b/src/pages/music/MusicStudio.css @@ -2570,3 +2570,29 @@ /* ── Style Boost Button ─────────────────────────────────── */ .ms-style-boost-btn { margin-left: auto; } .ms-style-boost-btn.is-loading { opacity: 0.6; } + +/* ── 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; } diff --git a/src/pages/music/MusicStudio.jsx b/src/pages/music/MusicStudio.jsx index 7507ea9..8521672 100644 --- a/src/pages/music/MusicStudio.jsx +++ b/src/pages/music/MusicStudio.jsx @@ -14,6 +14,7 @@ import { splitStems, getTimestampedLyrics, generateStyleBoost, + generateVideo, } from '../../api'; import './MusicStudio.css'; import AudioPlayer from './components/AudioPlayer'; @@ -23,6 +24,7 @@ import CoverArtModal from './components/CoverArtModal'; import LyricsTab from './components/LyricsTab'; import StemModal from './components/StemModal'; import SyncedLyricsPlayer from './components/SyncedLyricsPlayer'; +import RemixTab from './components/RemixTab'; /* ───────────────────────────────────────────── 데이터 상수 @@ -333,7 +335,7 @@ const TrackResult = ({ track, onDownload, onNew }) => { /* ───────────────────────────────────────────── Library Card ───────────────────────────────────────────── */ -const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, isGenerating }) => { +const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, isGenerating }) => { const [menuOpen, setMenuOpen] = useState(false); const genre = GENRES.find((g) => g.id === track.genre); const totalSec = track.duration_sec ?? null; @@ -419,6 +421,8 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo disabled={isGenerating}>🎛 12 Stems (50cr) + )} @@ -441,7 +445,7 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo /* ───────────────────────────────────────────── Library Section ───────────────────────────────────────────── */ -const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, isGenerating, loading }) => { +const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, isGenerating, loading }) => { const [playingId, setPlayingId] = useState(null); const handlePlay = (track) => { @@ -494,6 +498,7 @@ const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCove onWavConvert={onWavConvert} onStemSplit={onStemSplit} onSyncedLyrics={onSyncedLyrics} + onVideoGenerate={onVideoGenerate} isGenerating={isGenerating} /> ))} @@ -1026,6 +1031,31 @@ export default function MusicStudio() { finally { setStyleBoostLoading(false); } }; + /* ── 뮤직비디오 핸들러 ── */ + 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('뮤직비디오 생성에 실패했습니다'); + } + }; + const handleNewTrack = () => { setTrack(null); setGenProgress(0); @@ -1082,6 +1112,13 @@ export default function MusicStudio() { {library.length} )} + {/* ═══ LIBRARY TAB ═══ */} @@ -1097,6 +1134,7 @@ export default function MusicStudio() { onWavConvert={handleWavConvert} onStemSplit={handleStemSplit} onSyncedLyrics={handleSyncedLyrics} + onVideoGenerate={handleVideoGenerate} isGenerating={isGenerating} /> )} @@ -1106,6 +1144,24 @@ export default function MusicStudio() { { setLyrics(text); setInstrumental(false); setProvider('suno'); setTab('create'); }} /> )} + {/* ═══ REMIX TAB ═══ */} + {tab === 'remix' && ( + { + setTab('create'); + setIsGenerating(true); + setTrack(null); + setGenProgress(0); + setGenStep(`${title} 처리 중…`); + setGenError(null); + taskIdRef.current = taskId; + startPolling(taskId, title); + }} + model={model} + isGenerating={isGenerating} + /> + )} + {/* ═══ CREATE TAB ═══ */} {tab === 'create' && (
diff --git a/src/pages/music/components/RemixTab.jsx b/src/pages/music/components/RemixTab.jsx new file mode 100644 index 0000000..f86222d --- /dev/null +++ b/src/pages/music/components/RemixTab.jsx @@ -0,0 +1,193 @@ +import React, { useState } from 'react'; +import { uploadAndCover, uploadAndExtend, addVocals, addInstrumental } from '../../../api'; + +const REMIX_ACTIONS = [ + { id: 'cover', label: 'AI Cover', icon: '🎨', desc: '외부 음원을 Suno AI 스타일로 리메이크' }, + { id: 'extend', label: 'Extend', icon: '⏩', desc: '외부 음원을 이어서 확장' }, + { id: 'add-vocals', label: 'Add Vocals', icon: '🎤', desc: '인스트루멘탈에 AI 보컬 입히기' }, + { id: 'add-instrumental', label: 'Add Instrumental', icon: '🎹', desc: '보컬에 AI 반주 입히기' }, +]; + +const RemixTab = ({ onTaskStarted, model, isGenerating }) => { + const [uploadUrl, setUploadUrl] = useState(''); + const [activeAction, setActiveAction] = useState(null); + + // 각 액션별 파라미터 + const [title, setTitle] = useState(''); + const [style, setStyle] = useState(''); + const [prompt, setPrompt] = useState(''); + const [tags, setTags] = useState(''); + const [negativeTags, setNegativeTags] = useState(''); + const [vocalGender, setVocalGender] = useState(null); + const [continueAt, setContinueAt] = useState(0); + const [instrumental, setInstrumental] = useState(false); + + const handleSubmit = async () => { + if (!uploadUrl || !activeAction || isGenerating) return; + + let apiCall; + let payload = {}; + + switch (activeAction) { + case 'cover': + apiCall = uploadAndCover; + payload = { + upload_url: uploadUrl, model, custom_mode: true, + instrumental, prompt, style, title, + vocal_gender: vocalGender || undefined, + negative_tags: negativeTags || undefined, + }; + break; + case 'extend': + apiCall = uploadAndExtend; + payload = { + upload_url: uploadUrl, model, + default_param_flag: !!prompt, + continue_at: continueAt || undefined, + prompt, style, title, instrumental, + vocal_gender: vocalGender || undefined, + negative_tags: negativeTags || undefined, + }; + break; + case 'add-vocals': + apiCall = addVocals; + payload = { + upload_url: uploadUrl, prompt, title, style, + negative_tags: negativeTags, + vocal_gender: vocalGender || undefined, + model: 'V4_5PLUS', + }; + break; + case 'add-instrumental': + apiCall = addInstrumental; + payload = { + upload_url: uploadUrl, title, tags, + negative_tags: negativeTags, + vocal_gender: vocalGender || undefined, + model: 'V4_5PLUS', + }; + break; + default: + return; + } + + try { + const res = await apiCall(payload); + if (res?.task_id) { + onTaskStarted(res.task_id, `Remix: ${REMIX_ACTIONS.find(a => a.id === activeAction)?.label}`); + } + } catch (e) { + // 에러는 부모 컴포넌트에서 처리 + } + }; + + return ( +
+
+

Remix Studio

+

외부 음원을 AI로 리메이크, 확장, 보컬/반주 추가

+
+ +
+ + setUploadUrl(e.target.value)} + style={{ width: '100%' }} + /> +
+ +
+ {REMIX_ACTIONS.map((action) => ( + + ))} +
+ + {activeAction && ( +
+ {/* 공통 파라미터 */} +
+ + setTitle(e.target.value)} placeholder="곡 제목" style={{ width: '100%' }} /> +
+ + {(activeAction === 'cover' || activeAction === 'extend' || activeAction === 'add-vocals') && ( +
+ +