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:
@@ -2535,3 +2535,38 @@
|
||||
display: block; padding: 8px; text-align: center;
|
||||
font-family: 'Courier Prime', monospace; font-size: 0.78rem; color: var(--ms-muted);
|
||||
}
|
||||
|
||||
/* ── Stem Modal ─────────────────────────────────────────── */
|
||||
.ms-modal--wide { max-width: 680px; }
|
||||
.ms-modal__subtitle { font-size: 0.78rem; color: var(--ms-muted); font-family: 'Courier Prime', monospace; }
|
||||
.ms-stem-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.ms-stem-card {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||
padding: 12px 8px; border-radius: 10px;
|
||||
background: var(--ms-surface2); border: 1px solid var(--ms-line);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.ms-stem-card.is-playing { border-color: var(--ms-accent); background: rgba(245,166,35,0.08); }
|
||||
.ms-stem-card__icon { font-size: 1.4rem; }
|
||||
.ms-stem-card__name {
|
||||
font-family: 'Courier Prime', monospace; font-size: 0.72rem;
|
||||
color: var(--ms-muted); text-transform: capitalize;
|
||||
}
|
||||
.ms-stem-card__actions { display: flex; gap: 6px; }
|
||||
|
||||
/* ── Synced Lyrics Player ───────────────────────────────── */
|
||||
.ms-synced-player {
|
||||
background: var(--ms-surface); border: 1px solid var(--ms-line);
|
||||
border-radius: 12px; padding: 16px; margin-top: 12px;
|
||||
}
|
||||
.ms-synced-player__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.ms-synced-player__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; color: var(--ms-text); }
|
||||
.ms-synced-player__audio { width: 100%; margin-bottom: 12px; }
|
||||
.ms-synced-player__lyrics { line-height: 1.8; font-family: 'Syne', sans-serif; font-size: 0.95rem; }
|
||||
.ms-synced-word { color: var(--ms-dim); transition: color 0.15s; }
|
||||
.ms-synced-word.is-active { color: var(--synced-accent, var(--ms-accent)); font-weight: 600; }
|
||||
.ms-synced-word.is-past { color: var(--ms-muted); }
|
||||
|
||||
/* ── Style Boost Button ─────────────────────────────────── */
|
||||
.ms-style-boost-btn { margin-left: auto; }
|
||||
.ms-style-boost-btn.is-loading { opacity: 0.6; }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
55
src/pages/music/components/StemModal.jsx
Normal file
55
src/pages/music/components/StemModal.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const STEM_ICONS = {
|
||||
vocal: '🎤', backing_vocals: '🎶', drums: '🥁', bass: '🎸',
|
||||
guitar: '🎸', keyboard: '🎹', strings: '🎻', brass: '🎺',
|
||||
woodwinds: '🪈', percussion: '🪘', synth: '🎛', fx: '✨',
|
||||
};
|
||||
|
||||
const StemModal = ({ stems, onClose }) => {
|
||||
const [playingStem, setPlayingStem] = useState(null);
|
||||
|
||||
if (!stems || Object.keys(stems).length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="ms-modal-overlay" onClick={onClose}>
|
||||
<div className="ms-modal ms-modal--wide" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="ms-modal__header">
|
||||
<h3 className="ms-modal__title">12 Stems</h3>
|
||||
<span className="ms-modal__subtitle">각 스템을 개별 재생 및 다운로드할 수 있습니다</span>
|
||||
<button type="button" className="ms-modal__close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<div className="ms-stem-grid">
|
||||
{Object.entries(stems).map(([name, url]) => {
|
||||
if (!url) return null;
|
||||
const isPlaying = playingStem === name;
|
||||
return (
|
||||
<div key={name} className={`ms-stem-card ${isPlaying ? 'is-playing' : ''}`}>
|
||||
<span className="ms-stem-card__icon">{STEM_ICONS[name] || '🎵'}</span>
|
||||
<span className="ms-stem-card__name">{name.replace(/_/g, ' ')}</span>
|
||||
<div className="ms-stem-card__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ms-btn--icon"
|
||||
onClick={() => setPlayingStem(isPlaying ? null : name)}
|
||||
>
|
||||
{isPlaying ? '■' : '▶'}
|
||||
</button>
|
||||
<a href={url} download className="ms-btn--icon" aria-label="다운로드">↓</a>
|
||||
</div>
|
||||
{isPlaying && (
|
||||
<audio src={url} autoPlay onEnded={() => setPlayingStem(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="ms-modal__actions">
|
||||
<button type="button" className="ms-btn ms-btn--ghost" onClick={onClose}>닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StemModal;
|
||||
51
src/pages/music/components/SyncedLyricsPlayer.jsx
Normal file
51
src/pages/music/components/SyncedLyricsPlayer.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const SyncedLyricsPlayer = ({ audioUrl, alignedWords, onClose, accentColor }) => {
|
||||
const audioRef = useRef(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
const handler = () => setCurrentTime(el.currentTime);
|
||||
el.addEventListener('timeupdate', handler);
|
||||
return () => el.removeEventListener('timeupdate', handler);
|
||||
}, []);
|
||||
|
||||
if (!alignedWords || alignedWords.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="ms-synced-player" style={{ '--synced-accent': accentColor }}>
|
||||
<div className="ms-synced-player__header">
|
||||
<h4 className="ms-synced-player__title">Synced Lyrics</h4>
|
||||
<button type="button" className="ms-modal__close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => setPlaying(false)}
|
||||
controls
|
||||
className="ms-synced-player__audio"
|
||||
/>
|
||||
<div className="ms-synced-player__lyrics">
|
||||
{alignedWords.map((word, idx) => {
|
||||
const isActive = currentTime >= word.startS && currentTime < word.endS;
|
||||
const isPast = currentTime >= word.endS;
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className={`ms-synced-word ${isActive ? 'is-active' : ''} ${isPast ? 'is-past' : ''}`}
|
||||
>
|
||||
{word.word}{' '}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SyncedLyricsPlayer;
|
||||
Reference in New Issue
Block a user