diff --git a/src/api.js b/src/api.js index 3159fea..d617ee9 100644 --- a/src/api.js +++ b/src/api.js @@ -290,6 +290,28 @@ export function deleteMusicTrack(id) { return apiDelete(`/api/music/library/${id}`); } +// GET /api/music/models → { models: [{ id, name, max_duration, description }] } +export function getMusicModels() { + return apiGet('/api/music/models'); +} + +// GET /api/music/credits → { remaining, total, ... } +export function getMusicCredits() { + return apiGet('/api/music/credits'); +} + +// POST /api/music/extend body: { suno_id, continue_at, prompt, style, title, model } +// → { task_id, provider } +export function extendMusicTrack(payload) { + return apiPost('/api/music/extend', payload); +} + +// POST /api/music/vocal-removal body: { suno_id, title } +// → { task_id, provider } +export function removeVocals(payload) { + return apiPost('/api/music/vocal-removal', payload); +} + // ── 로또 고도화 API ──────────────────────────────────────────────────────────── // GET /api/lotto/stats/performance diff --git a/src/components/Icons.jsx b/src/components/Icons.jsx index 4a872b7..0252100 100644 --- a/src/components/Icons.jsx +++ b/src/components/Icons.jsx @@ -51,6 +51,15 @@ export const IconStock = () => export const IconTravel = () => svg(); +export const IconMusic = () => + svg( + <> + + + + + ); + export const IconLab = () => svg( <> diff --git a/src/pages/effect-lab/EffectLab.jsx b/src/pages/effect-lab/EffectLab.jsx index cab3bce..2ee715c 100644 --- a/src/pages/effect-lab/EffectLab.jsx +++ b/src/pages/effect-lab/EffectLab.jsx @@ -25,17 +25,6 @@ const LAB_ITEMS = [ icon: '📅', status: 'live', }, - { - id: 'music', - path: '/lab/music', - title: 'Sonic Forge', - category: 'AI · 음악 제작', - desc: 'AI가 장르·분위기·악기를 조합해 완성된 트랙을 만들어줍니다. 유튜브 수익화를 위한 음악 제작 스튜디오.', - tags: ['AI 음악', '생성', 'YouTube'], - accent: '#f5a623', - icon: '🎵', - status: 'wip', - }, ]; const STATUS_LABEL = { diff --git a/src/pages/music/MusicStudio.css b/src/pages/music/MusicStudio.css index 781eab9..7a6ebe1 100644 --- a/src/pages/music/MusicStudio.css +++ b/src/pages/music/MusicStudio.css @@ -74,6 +74,7 @@ } .ms-header__right { + position: relative; display: flex; align-items: center; justify-content: center; @@ -1597,17 +1598,18 @@ .ms-library__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + align-items: start; gap: 14px; } /* ── Library Card ─────────────────────────────────── */ .ms-lib-card { - padding: 16px; + padding: 14px; border-radius: 16px; border: 1px solid var(--ms-line); background: var(--ms-surface); display: grid; - gap: 12px; + gap: 10px; transition: border-color 0.2s ease, box-shadow 0.2s ease; } @@ -1620,48 +1622,63 @@ border-color: color-mix(in srgb, var(--lib-accent, var(--ms-accent)) 60%, transparent); } -.ms-lib-card__top { +.ms-lib-card__header { display: flex; align-items: center; - gap: 10px; + gap: 8px; + min-width: 0; } .ms-lib-card__icon { - font-size: 22px; + font-size: 20px; flex-shrink: 0; filter: drop-shadow(0 0 6px var(--lib-accent, var(--ms-accent))); } -.ms-lib-card__info { +.ms-lib-card__title { flex: 1; min-width: 0; -} - -.ms-lib-card__title { font-family: var(--ms-ff-disp); - font-size: 15px; + font-size: 16px; + font-weight: 600; letter-spacing: 0.04em; color: var(--ms-text); - margin: 0 0 3px; + margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ms-lib-card__controls { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.ms-lib-card__sub { + padding-left: 28px; +} + +.ms-lib-card__filename { + font-family: var(--ms-ff-mono); + font-size: 10px; + color: var(--ms-dim); + margin: 0 0 2px; + letter-spacing: 0.02em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0.7; +} + .ms-lib-card__meta { font-family: var(--ms-ff-mono); font-size: 10px; - color: var(--ms-muted); + color: var(--ms-dim); margin: 0; letter-spacing: 0.06em; } -.ms-lib-card__controls { - display: flex; - gap: 6px; - flex-shrink: 0; -} - .ms-lib-card__tags { display: flex; flex-wrap: wrap; @@ -1699,6 +1716,42 @@ .ms-bpm-presets { flex-wrap: wrap; } + + /* 크레딧 뱃지 */ + .ms-credits { + position: static; + margin-bottom: 8px; + justify-content: center; + } + + /* 모델 바 */ + .ms-model-bar { + flex-direction: column; + align-items: stretch; + } + + .ms-model-bar__options { + justify-content: center; + } + + /* 프로바이더 바 */ + .ms-provider-bar { + flex-direction: column; + } + + .ms-provider-btn__desc { + display: none; + } + + /* 라이브러리 그리드 1열 */ + .ms-library__grid { + grid-template-columns: 1fr; + } + + /* 카드 액션 버튼 */ + .ms-lib-card__actions { + justify-content: center; + } } /* ═══════════════════════════════════════════════════ @@ -1879,6 +1932,184 @@ overflow-y: auto; } +/* ═══════════════════════════════════════════════════ + ERROR BANNER +═══════════════════════════════════════════════════ */ +.ms-error-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-radius: 8px; + border: 1px solid rgba(232, 92, 58, 0.3); + background: rgba(232, 92, 58, 0.08); + color: #e85c3a; + font-size: 12px; + font-family: var(--ms-ff-mono); +} + +/* ═══════════════════════════════════════════════════ + SKELETON LOADING +═══════════════════════════════════════════════════ */ +.ms-lib-card--skeleton { + pointer-events: none; +} + +.ms-skel { + display: block; + border-radius: 4px; + background: linear-gradient(90deg, var(--ms-surface2) 25%, rgba(255,255,255,0.06) 50%, var(--ms-surface2) 75%); + background-size: 200% 100%; + animation: ms-shimmer 1.5s ease-in-out infinite; +} + +.ms-skel--icon { + width: 20px; + height: 20px; + border-radius: 50%; + flex-shrink: 0; +} + +.ms-skel--title { + flex: 1; + height: 16px; +} + +.ms-skel--btn { + width: 48px; + height: 20px; + flex-shrink: 0; +} + +.ms-skel--filename { + width: 70%; + height: 10px; + margin-bottom: 4px; +} + +.ms-skel--meta { + width: 45%; + height: 10px; +} + +.ms-skel--tag { + width: 52px; + height: 18px; + border-radius: 999px; +} + +@keyframes ms-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* ═══════════════════════════════════════════════════ + CREDITS BADGE (header) +═══════════════════════════════════════════════════ */ +.ms-credits { + position: absolute; + top: 0; + right: 0; + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: 1px solid var(--ms-line); + border-radius: 6px; + background: var(--ms-surface); + font-size: 11px; + color: var(--ms-muted); + z-index: 2; +} + +.ms-credits__label { + font-family: var(--ms-ff-mono); + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 9px; +} + +.ms-credits__value { + font-weight: 700; + color: var(--ms-accent); + font-size: 13px; +} + +/* ═══════════════════════════════════════════════════ + MODEL SELECTOR BAR +═══════════════════════════════════════════════════ */ +.ms-model-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--ms-surface); + border: 1px solid var(--ms-line); + border-radius: 8px; +} + +.ms-model-bar__label { + font-family: var(--ms-ff-mono); + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ms-muted); + flex-shrink: 0; +} + +.ms-model-bar__options { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.ms-model-btn { + padding: 4px 10px; + font-size: 11px; + font-family: var(--ms-ff-mono); + background: transparent; + border: 1px solid var(--ms-line); + border-radius: 4px; + color: var(--ms-muted); + cursor: pointer; + transition: all 0.2s; +} + +.ms-model-btn:hover { + border-color: var(--ms-accent); + color: var(--ms-text); +} + +.ms-model-btn.is-active { + background: var(--ms-accent); + border-color: var(--ms-accent); + color: #0c0b09; + font-weight: 600; +} + +/* ═══════════════════════════════════════════════════ + LIBRARY CARD — ACTION BUTTONS +═══════════════════════════════════════════════════ */ +.ms-lib-card__actions { + display: flex; + gap: 6px; + padding: 6px 0 0; + border-top: 1px solid var(--ms-line-2); + margin-top: 6px; + flex-wrap: wrap; +} + +.ms-lib-card__actions .ms-btn--sm { + font-size: 10px; + padding: 3px 8px; +} + +.ms-lib-card__actions .ms-btn--sm:disabled { + opacity: 0.4; + cursor: not-allowed; +} + /* ═══════════════════════════════════════════════════ REDUCED MOTION ═══════════════════════════════════════════════════ */ diff --git a/src/pages/music/MusicStudio.jsx b/src/pages/music/MusicStudio.jsx index 893be60..6bc1af0 100644 --- a/src/pages/music/MusicStudio.jsx +++ b/src/pages/music/MusicStudio.jsx @@ -6,6 +6,10 @@ import { getMusicLibrary, getMusicProviders, getMusicStatus, + getMusicModels, + getMusicCredits, + extendMusicTrack, + removeVocals, } from '../../api'; import './MusicStudio.css'; @@ -81,6 +85,27 @@ const SIM_STEPS = [ const pad = (n) => String(Math.floor(n)).padStart(2, '0'); const fmtTime = (s) => `${pad(s / 60)}:${pad(s % 60)}`; +/* ───────────────────────────────────────────── + Loading Skeleton +───────────────────────────────────────────── */ +const SkeletonCard = () => ( +
+
+ + + +
+
+ + +
+
+ + +
+
+); + /* ───────────────────────────────────────────── Waveform Canvas ───────────────────────────────────────────── */ @@ -422,23 +447,20 @@ const TrackResult = ({ track, onDownload, onNew }) => { /* ───────────────────────────────────────────── Library Card ───────────────────────────────────────────── */ -const LibraryCard = ({ track, onDelete, onPlay, isPlaying }) => { +const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, isGenerating }) => { const genre = GENRES.find((g) => g.id === track.genre); - const totalSec = track.duration_sec ?? DURATIONS.find((d) => d.id === track.duration)?.sec ?? 60; + const totalSec = track.duration_sec ?? null; + const filename = track.audio_url ? track.audio_url.split('/').pop() : ''; + const hasSunoId = !!track.suno_id; return (
-
+
{genre?.icon ?? '🎵'} -
-

{track.title}

-

- {fmtTime(totalSec)} · {track.bpm} BPM · {track.key} {track.scale} -

-
+

{track.title}

+
+

{filename}

+

+ {totalSec != null ? fmtTime(totalSec) : '--:--'} · {track.bpm ? `${track.bpm} BPM` : ''} {track.key} {track.scale} +

+
{isPlaying && ( { {m} ))}
+ {hasSunoId && ( +
+ + + {track.audio_url && ( + + ↓ Download + + )} +
+ )} + {!hasSunoId && track.audio_url && ( + + )}

{track.created_at ? new Date(track.created_at).toLocaleDateString('ko-KR') : ''}

@@ -488,13 +550,26 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying }) => { /* ───────────────────────────────────────────── Library Section ───────────────────────────────────────────── */ -const Library = ({ tracks, onDelete, onRefresh }) => { +const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, isGenerating, loading }) => { const [playingId, setPlayingId] = useState(null); const handlePlay = (track) => { setPlayingId((prev) => (prev === track.id ? null : track.id)); }; + if (loading) { + return ( +
+
+

My Library

+
+
+ {Array.from({ length: 4 }, (_, i) => )} +
+
+ ); + } + if (tracks.length === 0) { return (
@@ -522,6 +597,9 @@ const Library = ({ tracks, onDelete, onRefresh }) => { onDelete={onDelete} onPlay={handlePlay} isPlaying={playingId === track.id} + onExtend={onExtend} + onVocalRemoval={onVocalRemoval} + isGenerating={isGenerating} /> ))}
@@ -539,6 +617,7 @@ export default function MusicStudio() { /* ── Provider 상태 ── */ const [providers, setProviders] = useState([]); const [provider, setProvider] = useState('suno'); + const [providerError, setProviderError] = useState(false); /* ── 컨트롤 상태 ── */ const [genre, setGenre] = useState(null); @@ -554,6 +633,9 @@ export default function MusicStudio() { const [lyrics, setLyrics] = useState(''); const [instrumental, setInstrumental] = useState(false); const [lyricsLoading, setLyricsLoading] = useState(false); + const [model, setModel] = useState('V4'); + const [models, setModels] = useState([]); + const [credits, setCredits] = useState(null); /* ── 생성 상태 ── */ const [isGenerating, setIsGenerating] = useState(false); @@ -588,13 +670,24 @@ export default function MusicStudio() { .then((data) => { const list = data.providers ?? []; setProviders(list); + setProviderError(false); if (list.length > 0 && !list.find((p) => p.id === provider)) { setProvider(list[0].id); } }) - .catch(() => {}); + .catch(() => setProviderError(true)); }, []); // eslint-disable-line react-hooks/exhaustive-deps + /* ── 모델 & 크레딧 로드 ── */ + useEffect(() => { + getMusicModels() + .then((data) => setModels(data.models ?? [])) + .catch(() => {}); + getMusicCredits() + .then((data) => setCredits(data)) + .catch(() => {}); + }, []); + /* ── 가사 AI 생성 ── */ const handleGenerateLyrics = async () => { if (!prompt && !genre) return; @@ -730,6 +823,7 @@ export default function MusicStudio() { const payload = { provider, + model, title, genre, moods, @@ -774,6 +868,62 @@ export default function MusicStudio() { setLibrary((prev) => prev.filter((t) => t.id !== id)); }; + /* ── 곡 연장 핸들러 ── */ + const handleExtend = async (track) => { + if (!track.suno_id || isGenerating) return; + setTab('create'); + setIsGenerating(true); + setTrack(null); + setGenProgress(0); + setGenStep('곡 연장 요청 중…'); + setGenError(null); + try { + const res = await extendMusicTrack({ + suno_id: track.suno_id, + continue_at: track.duration_sec ?? 60, + prompt: '', + style: track.genre ?? '', + title: `${track.title} (Extended)`, + model, + }); + if (res?.task_id) { + taskIdRef.current = res.task_id; + setGenStep('AI가 곡을 연장하고 있습니다…'); + setGenProgress(5); + startPolling(res.task_id, `${track.title} (Extended)`); + } + } catch { + setIsGenerating(false); + setGenError('곡 연장에 실패했습니다'); + } + }; + + /* ── 보컬 분리 핸들러 ── */ + const handleVocalRemoval = async (track) => { + if (!track.suno_id || isGenerating) return; + setTab('create'); + setIsGenerating(true); + setTrack(null); + setGenProgress(0); + setGenStep('보컬 분리 요청 중…'); + setGenError(null); + try { + const res = await removeVocals({ + suno_id: track.suno_id, + title: track.title, + }); + if (res?.task_id) { + taskIdRef.current = res.task_id; + setGenStep('AI가 보컬을 분리하고 있습니다…'); + setGenProgress(5); + startPolling(res.task_id, `${track.title} (Vocal Removed)`); + } + } catch { + setIsGenerating(false); + setGenError('보컬 분리에 실패했습니다'); + } + }; + const handleNewTrack = () => { setTrack(null); setGenProgress(0); @@ -799,6 +949,14 @@ export default function MusicStudio() {

+ {credits && ( +
+ Credits + + {credits.credits_left ?? credits.remaining ?? '—'} + +
+ )}
@@ -828,8 +986,12 @@ export default function MusicStudio() { {tab === 'library' && ( )} @@ -840,6 +1002,23 @@ export default function MusicStudio() { {/* ─── LEFT: Controls ─── */}
+ {/* Provider Error */} + {providerError && ( +
+ ⚠ 음악 서비스 연결 실패 + +
+ )} + {/* Provider Selector */} {providers.length > 0 && (
@@ -860,6 +1039,26 @@ export default function MusicStudio() {
)} + {/* Model Selector (Suno only) */} + {provider === 'suno' && models.length > 0 && ( +
+ Model +
+ {models.map((m) => ( + + ))} +
+
+ )} + {/* Step 1: Genre */}
diff --git a/src/routes.jsx b/src/routes.jsx index 8cfff7f..8f0d8e5 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -6,6 +6,7 @@ import { IconStock, IconBuilding, IconTravel, + IconMusic, IconLab, IconTodo, } from './components/Icons'; @@ -79,6 +80,15 @@ export const navLinks = [ icon: , accent: '#fb923c', }, + { + id: 'music', + label: 'Music', + path: '/music', + subtitle: 'SONIC FORGE', + description: 'AI로 세상에 하나뿐인 음악을 만드는 스튜디오', + icon: , + accent: '#f5a623', + }, { id: 'lab', label: 'Lab', @@ -145,7 +155,7 @@ export const appRoutes = [ element: , }, { - path: 'lab/music', + path: 'music', element: , }, {