feat(music-lab): Phase 1 UI — 보컬 성별, 제외 스타일, weight 슬라이더, 더보기 메뉴, CoverArtModal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,11 +9,13 @@ import {
|
||||
getMusicModels,
|
||||
extendMusicTrack,
|
||||
removeVocals,
|
||||
generateCoverImage,
|
||||
} from '../../api';
|
||||
import './MusicStudio.css';
|
||||
import AudioPlayer from './components/AudioPlayer';
|
||||
import { fmtTime } from './components/AudioPlayer';
|
||||
import CreditsBadge from './components/CreditsBadge';
|
||||
import CoverArtModal from './components/CoverArtModal';
|
||||
import LyricsTab from './components/LyricsTab';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
@@ -325,7 +327,8 @@ const TrackResult = ({ track, onDownload, onNew }) => {
|
||||
/* ─────────────────────────────────────────────
|
||||
Library Card
|
||||
───────────────────────────────────────────── */
|
||||
const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, isGenerating }) => {
|
||||
const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, isGenerating }) => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const genre = GENRES.find((g) => g.id === track.genre);
|
||||
const totalSec = track.duration_sec ?? null;
|
||||
const filename = track.audio_url ? track.audio_url.split('/').pop() : '';
|
||||
@@ -386,29 +389,27 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
|
||||
</div>
|
||||
{hasSunoId && (
|
||||
<div className="ms-lib-card__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||
onClick={() => onExtend(track)}
|
||||
disabled={isGenerating}
|
||||
title="이 곡을 이어서 연장합니다"
|
||||
>
|
||||
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||
onClick={() => onExtend(track)} disabled={isGenerating}>
|
||||
⏩ Extend
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||
onClick={() => onVocalRemoval(track)}
|
||||
disabled={isGenerating}
|
||||
title="보컬과 인스트루멘탈을 분리합니다"
|
||||
>
|
||||
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||
onClick={() => onVocalRemoval(track)} disabled={isGenerating}>
|
||||
🎤 Vocal Split
|
||||
</button>
|
||||
{track.audio_url && (
|
||||
<a href={track.audio_url} download className="ms-btn ms-btn--ghost ms-btn--sm">
|
||||
↓ Download
|
||||
</a>
|
||||
<a href={track.audio_url} download className="ms-btn ms-btn--ghost ms-btn--sm">↓ Download</a>
|
||||
)}
|
||||
<div className="ms-more-menu">
|
||||
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||
onClick={() => setMenuOpen(!menuOpen)}>•••</button>
|
||||
{menuOpen && (
|
||||
<div className="ms-more-menu__dropdown">
|
||||
<button type="button" onClick={() => { onCoverArt(track); setMenuOpen(false); }}
|
||||
disabled={isGenerating}>🖼 Cover Art</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!hasSunoId && track.audio_url && (
|
||||
@@ -428,7 +429,7 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
|
||||
/* ─────────────────────────────────────────────
|
||||
Library Section
|
||||
───────────────────────────────────────────── */
|
||||
const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, isGenerating, loading }) => {
|
||||
const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, isGenerating, loading }) => {
|
||||
const [playingId, setPlayingId] = useState(null);
|
||||
|
||||
const handlePlay = (track) => {
|
||||
@@ -477,6 +478,7 @@ const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, isGene
|
||||
isPlaying={playingId === track.id}
|
||||
onExtend={onExtend}
|
||||
onVocalRemoval={onVocalRemoval}
|
||||
onCoverArt={onCoverArt}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
))}
|
||||
@@ -514,6 +516,15 @@ export default function MusicStudio() {
|
||||
const [model, setModel] = useState('V4');
|
||||
const [models, setModels] = useState([]);
|
||||
|
||||
/* ── Phase 1: 신규 파라미터 ── */
|
||||
const [vocalGender, setVocalGender] = useState(null); // "m" | "f" | null
|
||||
const [negativeTags, setNegativeTags] = useState('');
|
||||
const [styleWeight, setStyleWeight] = useState(50); // UI: 0~100
|
||||
const [audioWeight, setAudioWeight] = useState(50);
|
||||
|
||||
/* ── CoverArt 상태 ── */
|
||||
const [coverArtModal, setCoverArtModal] = useState(null); // { trackId, images }
|
||||
|
||||
/* ── 생성 상태 ── */
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [genProgress, setGenProgress] = useState(0);
|
||||
@@ -710,6 +721,10 @@ export default function MusicStudio() {
|
||||
...(provider === 'suno' ? {
|
||||
lyrics: lyrics || undefined,
|
||||
instrumental,
|
||||
vocal_gender: vocalGender || undefined,
|
||||
negative_tags: negativeTags || undefined,
|
||||
style_weight: styleWeight !== 50 ? styleWeight / 100 : undefined,
|
||||
audio_weight: audioWeight !== 50 ? audioWeight / 100 : undefined,
|
||||
} : {}),
|
||||
};
|
||||
|
||||
@@ -798,6 +813,64 @@ export default function MusicStudio() {
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 커버 아트 핸들러 ── */
|
||||
const handleCoverArt = async (track) => {
|
||||
if (!track.task_id || isGenerating) return;
|
||||
setTab('create');
|
||||
setIsGenerating(true);
|
||||
setTrack(null);
|
||||
setGenProgress(0);
|
||||
setGenStep('커버 이미지 생성 요청 중…');
|
||||
setGenError(null);
|
||||
try {
|
||||
const res = await generateCoverImage({
|
||||
suno_task_id: track.task_id,
|
||||
track_id: track.id,
|
||||
});
|
||||
if (res?.task_id) {
|
||||
taskIdRef.current = res.task_id;
|
||||
setGenStep('AI가 커버 이미지를 생성하고 있습니다…');
|
||||
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 images = JSON.parse(status.audio_url || '[]');
|
||||
setCoverArtModal({ trackId: track.id, images });
|
||||
} 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('커버 이미지 생성에 실패했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCoverSelect = (imageUrl) => {
|
||||
if (coverArtModal?.trackId) {
|
||||
setLibrary((prev) => prev.map((t) =>
|
||||
t.id === coverArtModal.trackId
|
||||
? { ...t, cover_images: [imageUrl, ...(coverArtModal.images || []).filter(u => u !== imageUrl)] }
|
||||
: t
|
||||
));
|
||||
}
|
||||
setCoverArtModal(null);
|
||||
};
|
||||
|
||||
const handleNewTrack = () => {
|
||||
setTrack(null);
|
||||
setGenProgress(0);
|
||||
@@ -865,6 +938,7 @@ export default function MusicStudio() {
|
||||
onRefresh={loadLibrary}
|
||||
onExtend={handleExtend}
|
||||
onVocalRemoval={handleVocalRemoval}
|
||||
onCoverArt={handleCoverArt}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
)}
|
||||
@@ -1157,6 +1231,96 @@ export default function MusicStudio() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vocal Gender (Suno only) */}
|
||||
{provider === 'suno' && (
|
||||
<div className="ms-param-group">
|
||||
<label className="ms-param-label">Vocal Gender</label>
|
||||
<div className="ms-gender-toggle">
|
||||
{[
|
||||
{ value: null, label: 'Auto', icon: '🎵' },
|
||||
{ value: 'm', label: 'Male', icon: '♂' },
|
||||
{ value: 'f', label: 'Female', icon: '♀' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.label}
|
||||
type="button"
|
||||
className={`ms-gender-btn ${vocalGender === opt.value ? 'is-active' : ''} ${opt.value === 'm' ? 'is-male' : opt.value === 'f' ? 'is-female' : ''}`}
|
||||
onClick={() => setVocalGender(opt.value)}
|
||||
>
|
||||
<span className="ms-gender-btn__icon">{opt.icon}</span>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Negative Tags (Suno only) */}
|
||||
{provider === 'suno' && (
|
||||
<div className="ms-param-group">
|
||||
<label className="ms-param-label">Exclude Styles</label>
|
||||
<div className="ms-negative-tags">
|
||||
<div className="ms-negative-tags__presets">
|
||||
{['screaming', 'autotune', 'distortion', 'whisper', 'falsetto', 'rap'].map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
className={`ms-neg-chip ${negativeTags.includes(tag) ? 'is-active' : ''}`}
|
||||
onClick={() => {
|
||||
setNegativeTags((prev) => {
|
||||
const tags = prev.split(',').map(t => t.trim()).filter(Boolean);
|
||||
if (tags.includes(tag)) return tags.filter(t => t !== tag).join(', ');
|
||||
return [...tags, tag].join(', ');
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="ms-negative-tags__input"
|
||||
placeholder="추가로 제외할 스타일을 입력..."
|
||||
value={negativeTags}
|
||||
onChange={(e) => setNegativeTags(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Style Weight / Audio Weight (Suno only) */}
|
||||
{provider === 'suno' && (
|
||||
<div className="ms-param-grid">
|
||||
<div className="ms-param-group">
|
||||
<div className="ms-param-row">
|
||||
<label className="ms-param-label">Style Weight</label>
|
||||
<span className="ms-param-value">{styleWeight}%</span>
|
||||
</div>
|
||||
<p className="ms-param-hint ms-param-hint--inline">Prompt ↔ Style 밸런스</p>
|
||||
<input
|
||||
type="range" min={0} max={100} value={styleWeight}
|
||||
onChange={(e) => setStyleWeight(Number(e.target.value))}
|
||||
className="ms-bpm-slider"
|
||||
aria-label="Style Weight"
|
||||
/>
|
||||
</div>
|
||||
<div className="ms-param-group">
|
||||
<div className="ms-param-row">
|
||||
<label className="ms-param-label">Audio Weight</label>
|
||||
<span className="ms-param-value">{audioWeight}%</span>
|
||||
</div>
|
||||
<p className="ms-param-hint ms-param-hint--inline">Original ↔ AI 밸런스</p>
|
||||
<input
|
||||
type="range" min={0} max={100} value={audioWeight}
|
||||
onChange={(e) => setAudioWeight(Number(e.target.value))}
|
||||
className="ms-bpm-slider"
|
||||
aria-label="Audio Weight"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Step 5: Prompt */}
|
||||
@@ -1348,6 +1512,14 @@ export default function MusicStudio() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{coverArtModal && (
|
||||
<CoverArtModal
|
||||
images={coverArtModal.images}
|
||||
onSelect={handleCoverSelect}
|
||||
onClose={() => setCoverArtModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user