feat(music-lab): Phase 3 UI — RemixTab + 뮤직비디오 생성
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user