feat(music-lab): Phase 2 UI — StemModal, SyncedLyricsPlayer, Style Boost, WAV 변환
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,10 @@ import {
|
||||
extendMusicTrack,
|
||||
removeVocals,
|
||||
generateCoverImage,
|
||||
convertToWav,
|
||||
splitStems,
|
||||
getTimestampedLyrics,
|
||||
generateStyleBoost,
|
||||
} from '../../api';
|
||||
import './MusicStudio.css';
|
||||
import AudioPlayer from './components/AudioPlayer';
|
||||
@@ -17,6 +21,8 @@ import { fmtTime } from './components/AudioPlayer';
|
||||
import CreditsBadge from './components/CreditsBadge';
|
||||
import CoverArtModal from './components/CoverArtModal';
|
||||
import LyricsTab from './components/LyricsTab';
|
||||
import StemModal from './components/StemModal';
|
||||
import SyncedLyricsPlayer from './components/SyncedLyricsPlayer';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
데이터 상수
|
||||
@@ -327,7 +333,7 @@ const TrackResult = ({ track, onDownload, onNew }) => {
|
||||
/* ─────────────────────────────────────────────
|
||||
Library Card
|
||||
───────────────────────────────────────────── */
|
||||
const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, isGenerating }) => {
|
||||
const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, isGenerating }) => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const genre = GENRES.find((g) => g.id === track.genre);
|
||||
const totalSec = track.duration_sec ?? null;
|
||||
@@ -407,6 +413,12 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
|
||||
<div className="ms-more-menu__dropdown">
|
||||
<button type="button" onClick={() => { onCoverArt(track); setMenuOpen(false); }}
|
||||
disabled={isGenerating}>🖼 Cover Art</button>
|
||||
<button type="button" onClick={() => { onWavConvert(track); setMenuOpen(false); }}
|
||||
disabled={isGenerating}>📀 WAV Download</button>
|
||||
<button type="button" onClick={() => { onStemSplit(track); setMenuOpen(false); }}
|
||||
disabled={isGenerating}>🎛 12 Stems (50cr)</button>
|
||||
<button type="button" onClick={() => { onSyncedLyrics(track); setMenuOpen(false); }}
|
||||
disabled={isGenerating || !track.lyrics}>📝 Synced Lyrics</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -429,7 +441,7 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
|
||||
/* ─────────────────────────────────────────────
|
||||
Library Section
|
||||
───────────────────────────────────────────── */
|
||||
const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, isGenerating, loading }) => {
|
||||
const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, isGenerating, loading }) => {
|
||||
const [playingId, setPlayingId] = useState(null);
|
||||
|
||||
const handlePlay = (track) => {
|
||||
@@ -479,6 +491,9 @@ const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCove
|
||||
onExtend={onExtend}
|
||||
onVocalRemoval={onVocalRemoval}
|
||||
onCoverArt={onCoverArt}
|
||||
onWavConvert={onWavConvert}
|
||||
onStemSplit={onStemSplit}
|
||||
onSyncedLyrics={onSyncedLyrics}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
))}
|
||||
@@ -525,6 +540,11 @@ export default function MusicStudio() {
|
||||
/* ── CoverArt 상태 ── */
|
||||
const [coverArtModal, setCoverArtModal] = useState(null); // { trackId, images }
|
||||
|
||||
/* ── Phase 2 상태 ── */
|
||||
const [stemModal, setStemModal] = useState(null); // { stems: {} }
|
||||
const [syncedLyrics, setSyncedLyrics] = useState(null); // { audioUrl, words }
|
||||
const [styleBoostLoading, setStyleBoostLoading] = useState(false);
|
||||
|
||||
/* ── 생성 상태 ── */
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [genProgress, setGenProgress] = useState(0);
|
||||
@@ -871,6 +891,141 @@ export default function MusicStudio() {
|
||||
setCoverArtModal(null);
|
||||
};
|
||||
|
||||
/* ── WAV 변환 핸들러 ── */
|
||||
const handleWavConvert = async (track) => {
|
||||
if (!track.task_id || !track.suno_id || isGenerating) return;
|
||||
setTab('create');
|
||||
setIsGenerating(true);
|
||||
setTrack(null);
|
||||
setGenProgress(0);
|
||||
setGenStep('WAV 변환 요청 중…');
|
||||
setGenError(null);
|
||||
try {
|
||||
const res = await convertToWav({
|
||||
suno_task_id: track.task_id,
|
||||
suno_id: track.suno_id,
|
||||
track_id: track.id,
|
||||
});
|
||||
if (res?.task_id) {
|
||||
taskIdRef.current = res.task_id;
|
||||
setGenStep('WAV 변환 처리 중…');
|
||||
setGenProgress(5);
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const status = await getMusicStatus(res.task_id);
|
||||
setGenProgress(status.progress ?? 0);
|
||||
setGenStep(status.message ?? '처리 중…');
|
||||
if (status.status === 'succeeded') {
|
||||
clearInterval(pollRef.current);
|
||||
setIsGenerating(false);
|
||||
const wavUrl = status.audio_url;
|
||||
if (wavUrl) {
|
||||
const a = document.createElement('a');
|
||||
a.href = wavUrl;
|
||||
a.download = `${track.title || 'track'}.wav`;
|
||||
a.click();
|
||||
}
|
||||
setGenStep('WAV 변환 완료!');
|
||||
} else if (status.status === 'failed') {
|
||||
clearInterval(pollRef.current);
|
||||
setIsGenerating(false);
|
||||
setGenError(`WAV 변환 실패: ${status.error ?? '알 수 없는 오류'}`);
|
||||
}
|
||||
} catch {
|
||||
clearInterval(pollRef.current);
|
||||
setIsGenerating(false);
|
||||
setGenError('WAV 변환 상태 조회 실패');
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
} catch {
|
||||
setIsGenerating(false);
|
||||
setGenError('WAV 변환에 실패했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 12스템 분리 핸들러 ── */
|
||||
const handleStemSplit = async (track) => {
|
||||
if (!track.task_id || !track.suno_id || isGenerating) return;
|
||||
setTab('create');
|
||||
setIsGenerating(true);
|
||||
setTrack(null);
|
||||
setGenProgress(0);
|
||||
setGenStep('12스템 분리 요청 중…');
|
||||
setGenError(null);
|
||||
try {
|
||||
const res = await splitStems({
|
||||
suno_task_id: track.task_id,
|
||||
suno_id: track.suno_id,
|
||||
track_id: track.id,
|
||||
});
|
||||
if (res?.task_id) {
|
||||
taskIdRef.current = res.task_id;
|
||||
setGenStep('12스템 분리 처리 중 (약 2~3분)…');
|
||||
setGenProgress(5);
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const status = await getMusicStatus(res.task_id);
|
||||
setGenProgress(status.progress ?? 0);
|
||||
setGenStep(status.message ?? '처리 중…');
|
||||
if (status.status === 'succeeded') {
|
||||
clearInterval(pollRef.current);
|
||||
setIsGenerating(false);
|
||||
const stems = JSON.parse(status.audio_url || '{}');
|
||||
setStemModal({ stems });
|
||||
} else if (status.status === 'failed') {
|
||||
clearInterval(pollRef.current);
|
||||
setIsGenerating(false);
|
||||
setGenError(`스템 분리 실패: ${status.error ?? '알 수 없는 오류'}`);
|
||||
}
|
||||
} catch {
|
||||
clearInterval(pollRef.current);
|
||||
setIsGenerating(false);
|
||||
setGenError('스템 분리 상태 조회 실패');
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
} catch {
|
||||
setIsGenerating(false);
|
||||
setGenError('12스템 분리에 실패했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 타임스탬프 가사 핸들러 ── */
|
||||
const handleSyncedLyrics = async (track) => {
|
||||
if (!track.task_id || !track.suno_id) return;
|
||||
try {
|
||||
const result = await getTimestampedLyrics(track.task_id, track.suno_id);
|
||||
if (result?.alignedWords || result?.aligned_words) {
|
||||
setSyncedLyrics({
|
||||
audioUrl: track.audio_url,
|
||||
words: result.alignedWords || result.aligned_words,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setGenError('타임스탬프 가사 조회에 실패했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 스타일 부스트 핸들러 ── */
|
||||
const handleStyleBoost = async () => {
|
||||
if (!genre || styleBoostLoading) return;
|
||||
setStyleBoostLoading(true);
|
||||
try {
|
||||
const content = [
|
||||
GENRES.find(g => g.id === genre)?.label,
|
||||
...moods.map(id => MOODS.find(m => m.id === id)?.label).filter(Boolean),
|
||||
].join(', ');
|
||||
const result = await generateStyleBoost(content);
|
||||
if (result?.result) {
|
||||
setPrompt(result.result);
|
||||
}
|
||||
} catch {}
|
||||
finally { setStyleBoostLoading(false); }
|
||||
};
|
||||
|
||||
const handleNewTrack = () => {
|
||||
setTrack(null);
|
||||
setGenProgress(0);
|
||||
@@ -939,6 +1094,9 @@ export default function MusicStudio() {
|
||||
onExtend={handleExtend}
|
||||
onVocalRemoval={handleVocalRemoval}
|
||||
onCoverArt={handleCoverArt}
|
||||
onWavConvert={handleWavConvert}
|
||||
onStemSplit={handleStemSplit}
|
||||
onSyncedLyrics={handleSyncedLyrics}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
)}
|
||||
@@ -1018,6 +1176,17 @@ export default function MusicStudio() {
|
||||
<span className="ms-section__step">01</span>
|
||||
<h2 className="ms-section__title">Genre</h2>
|
||||
<span className="ms-section__hint">장르를 선택하세요</span>
|
||||
{provider === 'suno' && (
|
||||
<button
|
||||
type="button"
|
||||
className={`ms-btn ms-btn--ghost ms-btn--sm ms-style-boost-btn ${styleBoostLoading ? 'is-loading' : ''}`}
|
||||
onClick={handleStyleBoost}
|
||||
disabled={styleBoostLoading || !genre}
|
||||
title="현재 설정으로 최적 스타일 프롬프트 생성"
|
||||
>
|
||||
{styleBoostLoading ? '생성 중...' : '✨ Style Boost'}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className={`ms-desc-toggle ${isOpen('genre') ? 'is-open' : ''}`} onClick={() => toggleDesc('genre')} aria-label="설명 펼치기">ℹ</button>
|
||||
</div>
|
||||
<div className={`ms-desc-wrap ${isOpen('genre') ? 'is-open' : ''}`}>
|
||||
@@ -1520,6 +1689,21 @@ export default function MusicStudio() {
|
||||
onClose={() => setCoverArtModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ Stem Modal ═══ */}
|
||||
{stemModal && (
|
||||
<StemModal stems={stemModal.stems} onClose={() => setStemModal(null)} />
|
||||
)}
|
||||
|
||||
{/* ═══ Synced Lyrics Player ═══ */}
|
||||
{syncedLyrics && (
|
||||
<SyncedLyricsPlayer
|
||||
audioUrl={syncedLyrics.audioUrl}
|
||||
alignedWords={syncedLyrics.words}
|
||||
onClose={() => setSyncedLyrics(null)}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user