feat(music-lab): Phase 3 UI — RemixTab + 뮤직비디오 생성
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
27
src/api.js
27
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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)</button>
|
||||
<button type="button" onClick={() => { onSyncedLyrics(track); setMenuOpen(false); }}
|
||||
disabled={isGenerating || !track.lyrics}>📝 Synced Lyrics</button>
|
||||
<button type="button" onClick={() => { onVideoGenerate(track); setMenuOpen(false); }}
|
||||
disabled={isGenerating}>🎬 Music Video</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -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() {
|
||||
<span className="ms-tab__badge">{library.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ms-tab ${tab === 'remix' ? 'is-active' : ''}`}
|
||||
onClick={() => setTab('remix')}
|
||||
>
|
||||
<span className="ms-tab__icon">🔄</span> Remix
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* ═══ 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() {
|
||||
<LyricsTab onUseInCreate={(text) => { setLyrics(text); setInstrumental(false); setProvider('suno'); setTab('create'); }} />
|
||||
)}
|
||||
|
||||
{/* ═══ REMIX TAB ═══ */}
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ CREATE TAB ═══ */}
|
||||
{tab === 'create' && (
|
||||
<div className="ms-layout">
|
||||
|
||||
193
src/pages/music/components/RemixTab.jsx
Normal file
193
src/pages/music/components/RemixTab.jsx
Normal file
@@ -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 (
|
||||
<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;
|
||||
Reference in New Issue
Block a user