feat(music-lab): Phase 1 UI — 보컬 성별, 제외 스타일, weight 슬라이더, 더보기 메뉴, CoverArtModal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -334,6 +334,13 @@ export function deleteLyrics(id) {
|
||||
return apiDelete(`/api/music/lyrics/library/${id}`);
|
||||
}
|
||||
|
||||
// ── Phase 1: 커버 이미지 ────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/music/cover-image body: { suno_task_id, track_id }
|
||||
export function generateCoverImage(payload) {
|
||||
return apiPost('/api/music/cover-image', payload);
|
||||
}
|
||||
|
||||
// ── 로또 고도화 API ────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/lotto/stats/performance
|
||||
|
||||
@@ -2449,3 +2449,89 @@
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* ── Phase 1: Vocal Gender Toggle ───────────────────────── */
|
||||
.ms-gender-toggle { display: flex; gap: 6px; }
|
||||
.ms-gender-btn {
|
||||
flex: 1; padding: 8px 12px; border-radius: 8px;
|
||||
background: var(--ms-surface); border: 1px solid var(--ms-line);
|
||||
color: var(--ms-muted); font-family: 'Syne', sans-serif;
|
||||
font-size: 0.82rem; cursor: pointer; transition: all 0.2s;
|
||||
display: flex; align-items: center; gap: 6px; justify-content: center;
|
||||
}
|
||||
.ms-gender-btn:hover { border-color: var(--ms-accent); color: var(--ms-text); }
|
||||
.ms-gender-btn.is-active { background: rgba(245, 166, 35, 0.12); border-color: var(--ms-accent); color: var(--ms-text); }
|
||||
.ms-gender-btn.is-active.is-male { background: rgba(74, 158, 255, 0.12); border-color: #4a9eff; color: #4a9eff; }
|
||||
.ms-gender-btn.is-active.is-female { background: rgba(255, 107, 157, 0.12); border-color: #ff6b9d; color: #ff6b9d; }
|
||||
.ms-gender-btn__icon { font-size: 1.1rem; }
|
||||
|
||||
/* ── Phase 1: Negative Tags ─────────────────────────────── */
|
||||
.ms-negative-tags { display: flex; flex-direction: column; gap: 8px; }
|
||||
.ms-negative-tags__presets { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.ms-neg-chip {
|
||||
padding: 4px 12px; border-radius: 14px;
|
||||
background: var(--ms-surface); border: 1px solid var(--ms-line);
|
||||
color: var(--ms-muted); font-size: 0.78rem; cursor: pointer;
|
||||
font-family: 'Syne', sans-serif; transition: all 0.2s;
|
||||
}
|
||||
.ms-neg-chip:hover { border-color: #e74c3c; color: var(--ms-text); }
|
||||
.ms-neg-chip.is-active {
|
||||
background: rgba(231, 76, 60, 0.12); border-color: #e74c3c; color: #e74c3c;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.ms-negative-tags__input {
|
||||
padding: 8px 12px; border-radius: 8px;
|
||||
background: var(--ms-surface); border: 1px solid var(--ms-line);
|
||||
color: var(--ms-text); font-family: 'Syne', sans-serif; font-size: 0.82rem;
|
||||
}
|
||||
.ms-negative-tags__input::placeholder { color: var(--ms-dim); }
|
||||
.ms-param-hint--inline {
|
||||
font-size: 0.72rem; color: var(--ms-dim); margin: 0 0 4px;
|
||||
font-family: 'Courier Prime', monospace;
|
||||
}
|
||||
|
||||
/* ── More Menu ──────────────────────────────────────────── */
|
||||
.ms-more-menu { position: relative; }
|
||||
.ms-more-menu__dropdown {
|
||||
position: absolute; bottom: 100%; right: 0;
|
||||
background: var(--ms-surface2); border: 1px solid var(--ms-line);
|
||||
border-radius: 8px; padding: 4px; min-width: 160px; z-index: 20;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
.ms-more-menu__dropdown button {
|
||||
display: block; width: 100%; padding: 8px 12px; border: none;
|
||||
background: none; color: var(--ms-text); font-size: 0.82rem;
|
||||
font-family: 'Syne', sans-serif; cursor: pointer; text-align: left;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.ms-more-menu__dropdown button:hover { background: rgba(245,166,35,0.1); }
|
||||
.ms-more-menu__dropdown button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* ── Modal ──────────────────────────────────────────────── */
|
||||
.ms-modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
.ms-modal {
|
||||
background: var(--ms-surface); border: 1px solid var(--ms-line);
|
||||
border-radius: 16px; padding: 24px; max-width: 520px; width: 90%;
|
||||
max-height: 90vh; overflow-y: auto;
|
||||
}
|
||||
.ms-modal__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.ms-modal__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.3rem; color: var(--ms-text); }
|
||||
.ms-modal__close { background: none; border: none; color: var(--ms-muted); font-size: 1.2rem; cursor: pointer; }
|
||||
.ms-modal__actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
|
||||
|
||||
/* ── Cover Art Grid ─────────────────────────────────────── */
|
||||
.ms-cover-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.ms-cover-option {
|
||||
border: 2px solid var(--ms-line); border-radius: 12px; overflow: hidden;
|
||||
cursor: pointer; background: none; padding: 0; transition: border-color 0.2s;
|
||||
}
|
||||
.ms-cover-option:hover { border-color: var(--ms-accent); }
|
||||
.ms-cover-option.is-selected { border-color: var(--ms-accent); box-shadow: 0 0 12px rgba(245,166,35,0.3); }
|
||||
.ms-cover-option__img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
|
||||
.ms-cover-option__label {
|
||||
display: block; padding: 8px; text-align: center;
|
||||
font-family: 'Courier Prime', monospace; font-size: 0.78rem; color: var(--ms-muted);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
40
src/pages/music/components/CoverArtModal.jsx
Normal file
40
src/pages/music/components/CoverArtModal.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const CoverArtModal = ({ images, onSelect, onClose }) => {
|
||||
const [selected, setSelected] = useState(null);
|
||||
|
||||
if (!images || images.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="ms-modal-overlay" onClick={onClose}>
|
||||
<div className="ms-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="ms-modal__header">
|
||||
<h3 className="ms-modal__title">Cover Art 선택</h3>
|
||||
<button type="button" className="ms-modal__close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<div className="ms-cover-grid">
|
||||
{images.map((url, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
className={`ms-cover-option ${selected === idx ? 'is-selected' : ''}`}
|
||||
onClick={() => setSelected(idx)}
|
||||
>
|
||||
<img src={url} alt={`Cover option ${idx + 1}`} className="ms-cover-option__img" />
|
||||
<span className="ms-cover-option__label">Option {idx + 1}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ms-modal__actions">
|
||||
<button type="button" className="ms-btn ms-btn--accent" disabled={selected === null}
|
||||
onClick={() => { if (selected !== null) onSelect(images[selected]); }}>
|
||||
이 이미지 사용
|
||||
</button>
|
||||
<button type="button" className="ms-btn ms-btn--ghost" onClick={onClose}>취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoverArtModal;
|
||||
Reference in New Issue
Block a user