feat(music-lab): Phase 1 UI — 보컬 성별, 제외 스타일, weight 슬라이더, 더보기 메뉴, CoverArtModal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 08:53:47 +09:00
parent 312677e624
commit 7a591bb0f1
4 changed files with 324 additions and 19 deletions

View File

@@ -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>
);
}