From 7a591bb0f120a59652eaf20d182a2ac4ffef5c27 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 8 Apr 2026 08:53:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(music-lab):=20Phase=201=20UI=20=E2=80=94?= =?UTF-8?q?=20=EB=B3=B4=EC=BB=AC=20=EC=84=B1=EB=B3=84,=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8=20=EC=8A=A4=ED=83=80=EC=9D=BC,=20weight=20=EC=8A=AC?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=8D=94,=20=EB=8D=94=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4,=20CoverArtModal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/api.js | 7 + src/pages/music/MusicStudio.css | 86 ++++++++ src/pages/music/MusicStudio.jsx | 210 +++++++++++++++++-- src/pages/music/components/CoverArtModal.jsx | 40 ++++ 4 files changed, 324 insertions(+), 19 deletions(-) create mode 100644 src/pages/music/components/CoverArtModal.jsx diff --git a/src/api.js b/src/api.js index 9de8f3f..4ec3b55 100644 --- a/src/api.js +++ b/src/api.js @@ -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 diff --git a/src/pages/music/MusicStudio.css b/src/pages/music/MusicStudio.css index 3c7adff..beb6f6d 100644 --- a/src/pages/music/MusicStudio.css +++ b/src/pages/music/MusicStudio.css @@ -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); +} diff --git a/src/pages/music/MusicStudio.jsx b/src/pages/music/MusicStudio.jsx index 57ef7eb..0037ecf 100644 --- a/src/pages/music/MusicStudio.jsx +++ b/src/pages/music/MusicStudio.jsx @@ -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 {hasSunoId && (
- - {track.audio_url && ( - - ↓ Download - + ↓ Download )} +
+ + {menuOpen && ( +
+ +
+ )} +
)} {!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() { + + {/* Vocal Gender (Suno only) */} + {provider === 'suno' && ( +
+ +
+ {[ + { value: null, label: 'Auto', icon: '🎵' }, + { value: 'm', label: 'Male', icon: '♂' }, + { value: 'f', label: 'Female', icon: '♀' }, + ].map((opt) => ( + + ))} +
+
+ )} + + {/* Negative Tags (Suno only) */} + {provider === 'suno' && ( +
+ +
+
+ {['screaming', 'autotune', 'distortion', 'whisper', 'falsetto', 'rap'].map((tag) => ( + + ))} +
+ setNegativeTags(e.target.value)} + /> +
+
+ )} + + {/* Style Weight / Audio Weight (Suno only) */} + {provider === 'suno' && ( +
+
+
+ + {styleWeight}% +
+

Prompt ↔ Style 밸런스

+ setStyleWeight(Number(e.target.value))} + className="ms-bpm-slider" + aria-label="Style Weight" + /> +
+
+
+ + {audioWeight}% +
+

Original ↔ AI 밸런스

+ setAudioWeight(Number(e.target.value))} + className="ms-bpm-slider" + aria-label="Audio Weight" + /> +
+
+ )} {/* Step 5: Prompt */} @@ -1348,6 +1512,14 @@ export default function MusicStudio() { )} + + {coverArtModal && ( + setCoverArtModal(null)} + /> + )} ); } diff --git a/src/pages/music/components/CoverArtModal.jsx b/src/pages/music/components/CoverArtModal.jsx new file mode 100644 index 0000000..5d9cab3 --- /dev/null +++ b/src/pages/music/components/CoverArtModal.jsx @@ -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 ( +
+
e.stopPropagation()}> +
+

Cover Art 선택

+ +
+
+ {images.map((url, idx) => ( + + ))} +
+
+ + +
+
+
+ ); +}; + +export default CoverArtModal;