Compare commits
12 Commits
5dadd4bf2c
...
7fc2d3aaf7
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fc2d3aaf7 | |||
| b215a93c89 | |||
| 1f00866694 | |||
| 0849c70644 | |||
| 7a591bb0f1 | |||
| 312677e624 | |||
| 6786f8c883 | |||
| 45b74e672a | |||
| bf5c7ba54e | |||
| 8af2824c12 | |||
| ff0ee3757c | |||
| 0eb55fe731 |
56
src/api.js
56
src/api.js
@@ -334,6 +334,62 @@ 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);
|
||||
}
|
||||
|
||||
// ── Phase 2 API ─────────────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/music/wav body: { suno_task_id, suno_id, track_id }
|
||||
export function convertToWav(payload) {
|
||||
return apiPost('/api/music/wav', payload);
|
||||
}
|
||||
|
||||
// POST /api/music/stem-split body: { suno_task_id, suno_id, track_id }
|
||||
export function splitStems(payload) {
|
||||
return apiPost('/api/music/stem-split', payload);
|
||||
}
|
||||
|
||||
// GET /api/music/timestamped-lyrics?task_id=...&suno_id=...
|
||||
export function getTimestampedLyrics(taskId, sunoId) {
|
||||
return apiGet(`/api/music/timestamped-lyrics?task_id=${encodeURIComponent(taskId)}&suno_id=${encodeURIComponent(sunoId)}`);
|
||||
}
|
||||
|
||||
// POST /api/music/style-boost body: { content }
|
||||
export function generateStyleBoost(content) {
|
||||
return apiPost('/api/music/style-boost', { content });
|
||||
}
|
||||
|
||||
// ── Phase 3 API ─────────────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/music/upload-cover
|
||||
export function uploadAndCover(payload) {
|
||||
return apiPost('/api/music/upload-cover', payload);
|
||||
}
|
||||
|
||||
// POST /api/music/upload-extend
|
||||
export function uploadAndExtend(payload) {
|
||||
return apiPost('/api/music/upload-extend', payload);
|
||||
}
|
||||
|
||||
// POST /api/music/add-vocals
|
||||
export function addVocals(payload) {
|
||||
return apiPost('/api/music/add-vocals', payload);
|
||||
}
|
||||
|
||||
// POST /api/music/add-instrumental
|
||||
export function addInstrumental(payload) {
|
||||
return apiPost('/api/music/add-instrumental', payload);
|
||||
}
|
||||
|
||||
// POST /api/music/video
|
||||
export function generateVideo(payload) {
|
||||
return apiPost('/api/music/video', payload);
|
||||
}
|
||||
|
||||
// ── 로또 고도화 API ────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/lotto/stats/performance
|
||||
|
||||
@@ -2426,3 +2426,173 @@
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Phase 1: Credits Badge ─────────────────────────────── */
|
||||
.ms-credits-badge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 6px 14px; border-radius: 20px;
|
||||
background: rgba(245, 166, 35, 0.1);
|
||||
border: 1px solid rgba(245, 166, 35, 0.25);
|
||||
font-family: 'Courier Prime', monospace;
|
||||
font-size: 0.85rem; color: var(--ms-accent);
|
||||
}
|
||||
.ms-credits-badge__icon { font-size: 1rem; }
|
||||
.ms-credits-badge__value { font-weight: 700; font-size: 1.1rem; }
|
||||
.ms-credits-badge__label { color: var(--ms-muted); font-size: 0.75rem; text-transform: uppercase; }
|
||||
.ms-credits-badge.is-low {
|
||||
background: rgba(231, 76, 60, 0.15);
|
||||
border-color: rgba(231, 76, 60, 0.4);
|
||||
color: #e74c3c;
|
||||
animation: pulse-badge 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-badge {
|
||||
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);
|
||||
}
|
||||
|
||||
/* ── Stem Modal ─────────────────────────────────────────── */
|
||||
.ms-modal--wide { max-width: 680px; }
|
||||
.ms-modal__subtitle { font-size: 0.78rem; color: var(--ms-muted); font-family: 'Courier Prime', monospace; }
|
||||
.ms-stem-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.ms-stem-card {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||
padding: 12px 8px; border-radius: 10px;
|
||||
background: var(--ms-surface2); border: 1px solid var(--ms-line);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.ms-stem-card.is-playing { border-color: var(--ms-accent); background: rgba(245,166,35,0.08); }
|
||||
.ms-stem-card__icon { font-size: 1.4rem; }
|
||||
.ms-stem-card__name {
|
||||
font-family: 'Courier Prime', monospace; font-size: 0.72rem;
|
||||
color: var(--ms-muted); text-transform: capitalize;
|
||||
}
|
||||
.ms-stem-card__actions { display: flex; gap: 6px; }
|
||||
|
||||
/* ── Synced Lyrics Player ───────────────────────────────── */
|
||||
.ms-synced-player {
|
||||
background: var(--ms-surface); border: 1px solid var(--ms-line);
|
||||
border-radius: 12px; padding: 16px; margin-top: 12px;
|
||||
}
|
||||
.ms-synced-player__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.ms-synced-player__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; color: var(--ms-text); }
|
||||
.ms-synced-player__audio { width: 100%; margin-bottom: 12px; }
|
||||
.ms-synced-player__lyrics { line-height: 1.8; font-family: 'Syne', sans-serif; font-size: 0.95rem; }
|
||||
.ms-synced-word { color: var(--ms-dim); transition: color 0.15s; }
|
||||
.ms-synced-word.is-active { color: var(--synced-accent, var(--ms-accent)); font-weight: 600; }
|
||||
.ms-synced-word.is-past { color: var(--ms-muted); }
|
||||
|
||||
/* ── Style Boost Button ─────────────────────────────────── */
|
||||
.ms-style-boost-btn { margin-left: auto; }
|
||||
.ms-style-boost-btn.is-loading { opacity: 0.6; }
|
||||
|
||||
/* ── Remix Tab ──────────────────────────────────────────── */
|
||||
.ms-remix-tab { display: flex; flex-direction: column; gap: 20px; }
|
||||
.ms-remix-tab__header { }
|
||||
.ms-remix-tab__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.8rem; color: var(--ms-text); }
|
||||
.ms-remix-tab__desc { font-size: 0.85rem; color: var(--ms-muted); }
|
||||
|
||||
.ms-remix-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.ms-remix-card {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||
padding: 20px 12px; border-radius: 12px; cursor: pointer;
|
||||
background: var(--ms-surface); border: 1px solid var(--ms-line);
|
||||
transition: all 0.2s; text-align: center;
|
||||
}
|
||||
.ms-remix-card:hover { border-color: var(--ms-accent); background: var(--ms-surface2); }
|
||||
.ms-remix-card.is-active { border-color: var(--ms-accent); background: rgba(245,166,35,0.08); }
|
||||
.ms-remix-card__icon { font-size: 2rem; }
|
||||
.ms-remix-card__label { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; color: var(--ms-text); }
|
||||
.ms-remix-card__desc { font-size: 0.72rem; color: var(--ms-muted); font-family: 'Courier Prime', monospace; }
|
||||
|
||||
.ms-remix-params {
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
padding: 16px; border-radius: 12px;
|
||||
background: var(--ms-surface); border: 1px solid var(--ms-line);
|
||||
}
|
||||
.ms-remix-submit { align-self: flex-start; margin-top: 8px; }
|
||||
|
||||
@@ -7,15 +7,24 @@ import {
|
||||
getMusicProviders,
|
||||
getMusicStatus,
|
||||
getMusicModels,
|
||||
getMusicCredits,
|
||||
extendMusicTrack,
|
||||
removeVocals,
|
||||
getSavedLyrics,
|
||||
saveLyrics,
|
||||
updateLyrics,
|
||||
deleteLyrics,
|
||||
generateCoverImage,
|
||||
convertToWav,
|
||||
splitStems,
|
||||
getTimestampedLyrics,
|
||||
generateStyleBoost,
|
||||
generateVideo,
|
||||
} 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';
|
||||
import StemModal from './components/StemModal';
|
||||
import SyncedLyricsPlayer from './components/SyncedLyricsPlayer';
|
||||
import RemixTab from './components/RemixTab';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
데이터 상수
|
||||
@@ -83,12 +92,6 @@ const SIM_STEPS = [
|
||||
{ msg: 'Track ready!', pct: 100 },
|
||||
];
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
유틸
|
||||
───────────────────────────────────────────── */
|
||||
const pad = (n) => String(Math.floor(n)).padStart(2, '0');
|
||||
const fmtTime = (s) => `${pad(s / 60)}:${pad(s % 60)}`;
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Loading Skeleton
|
||||
───────────────────────────────────────────── */
|
||||
@@ -261,125 +264,6 @@ const GenerationProgress = ({ progress, stepMsg }) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Audio Player (실제 <audio> 기반)
|
||||
───────────────────────────────────────────── */
|
||||
const AudioPlayer = ({ audioUrl, totalSec, accentColor }) => {
|
||||
const audioRef = useRef(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [duration, setDuration] = useState(totalSec ?? 0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
|
||||
/* 실제 오디오가 없으면 가짜 타이머로 폴백 */
|
||||
const isFake = !audioUrl;
|
||||
const timerRef = useRef(null);
|
||||
|
||||
const total = duration || totalSec || 60;
|
||||
|
||||
const togglePlay = () => {
|
||||
if (isFake) {
|
||||
if (playing) {
|
||||
clearInterval(timerRef.current);
|
||||
setPlaying(false);
|
||||
} else {
|
||||
setPlaying(true);
|
||||
timerRef.current = setInterval(() => {
|
||||
setElapsed((e) => {
|
||||
if (e >= total - 1) {
|
||||
clearInterval(timerRef.current);
|
||||
setPlaying(false);
|
||||
return 0;
|
||||
}
|
||||
return e + 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
playing ? el.pause() : el.play();
|
||||
};
|
||||
|
||||
const handleSeek = (e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = (e.clientX - rect.left) / rect.width;
|
||||
const newTime = ratio * total;
|
||||
if (!isFake && audioRef.current) {
|
||||
audioRef.current.currentTime = newTime;
|
||||
}
|
||||
setElapsed(newTime);
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e) => {
|
||||
const v = Number(e.target.value);
|
||||
setVolume(v);
|
||||
if (!isFake && audioRef.current) audioRef.current.volume = v;
|
||||
};
|
||||
|
||||
useEffect(() => () => clearInterval(timerRef.current), []);
|
||||
|
||||
const progress = (elapsed / total) * 100;
|
||||
|
||||
return (
|
||||
<div className="ms-audio-player" style={{ '--player-accent': accentColor }}>
|
||||
{!isFake && (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
onLoadedMetadata={(e) => setDuration(e.target.duration)}
|
||||
onTimeUpdate={(e) => setElapsed(e.target.currentTime)}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => { setPlaying(false); setElapsed(0); }}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`ms-player__play ${playing ? 'is-playing' : ''}`}
|
||||
onClick={togglePlay}
|
||||
aria-label={playing ? '일시정지' : '재생'}
|
||||
>
|
||||
{playing ? (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<rect x="3" y="2" width="4" height="12" rx="1" />
|
||||
<rect x="9" y="2" width="4" height="12" rx="1" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M4 2l10 6-10 6V2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="ms-player__timeline">
|
||||
<div className="ms-player__bar" onClick={handleSeek} role="slider"
|
||||
aria-label="재생 위치" aria-valuenow={Math.round(elapsed)} aria-valuemin={0} aria-valuemax={Math.round(total)}>
|
||||
<div className="ms-player__fill" style={{ width: `${progress}%` }} />
|
||||
<div className="ms-player__thumb" style={{ left: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="ms-player__times">
|
||||
<span>{fmtTime(elapsed)}</span>
|
||||
<span>{fmtTime(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ms-volume">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden>
|
||||
<path d="M2 5h2.5l3-3v10l-3-3H2V5zm8.5-1.5a4.5 4.5 0 010 7" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round"/>
|
||||
</svg>
|
||||
<input
|
||||
type="range" min={0} max={1} step={0.02} value={volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="ms-volume__slider"
|
||||
aria-label="볼륨"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Track Result Card
|
||||
───────────────────────────────────────────── */
|
||||
@@ -451,7 +335,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, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, 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() : '';
|
||||
@@ -512,29 +397,35 @@ 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>
|
||||
<button type="button" onClick={() => { onWavConvert(track); setMenuOpen(false); }}
|
||||
disabled={isGenerating}>📀 WAV Download</button>
|
||||
<button type="button" onClick={() => { onStemSplit(track); setMenuOpen(false); }}
|
||||
disabled={isGenerating}>🎛 12 Stems (50cr)</button>
|
||||
<button type="button" onClick={() => { onSyncedLyrics(track); setMenuOpen(false); }}
|
||||
disabled={isGenerating || !track.lyrics}>📝 Synced Lyrics</button>
|
||||
<button type="button" onClick={() => { onVideoGenerate(track); setMenuOpen(false); }}
|
||||
disabled={isGenerating}>🎬 Music Video</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!hasSunoId && track.audio_url && (
|
||||
@@ -554,7 +445,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, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, isGenerating, loading }) => {
|
||||
const [playingId, setPlayingId] = useState(null);
|
||||
|
||||
const handlePlay = (track) => {
|
||||
@@ -603,6 +494,11 @@ const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, isGene
|
||||
isPlaying={playingId === track.id}
|
||||
onExtend={onExtend}
|
||||
onVocalRemoval={onVocalRemoval}
|
||||
onCoverArt={onCoverArt}
|
||||
onWavConvert={onWavConvert}
|
||||
onStemSplit={onStemSplit}
|
||||
onSyncedLyrics={onSyncedLyrics}
|
||||
onVideoGenerate={onVideoGenerate}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
))}
|
||||
@@ -611,241 +507,6 @@ const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, isGene
|
||||
);
|
||||
};
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Lyrics Tab
|
||||
───────────────────────────────────────────── */
|
||||
const LyricsTab = ({ onUseInCreate }) => {
|
||||
const [lyrPrompt, setLyrPrompt] = useState('');
|
||||
const [lyrLoading, setLyrLoading] = useState(false);
|
||||
const [lyrError, setLyrError] = useState(null);
|
||||
const [copied, setCopied] = useState(null); // id
|
||||
const [saved, setSaved] = useState([]); // DB에 저장된 가사
|
||||
const [loadingSaved, setLoadingSaved] = useState(true);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editText, setEditText] = useState('');
|
||||
|
||||
/* ── 저장된 가사 로드 ── */
|
||||
useEffect(() => {
|
||||
setLoadingSaved(true);
|
||||
getSavedLyrics()
|
||||
.then((data) => setSaved(data.lyrics ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingSaved(false));
|
||||
}, []);
|
||||
|
||||
/* ── AI 생성 → 즉시 저장 ── */
|
||||
const handleGenerate = async () => {
|
||||
if (!lyrPrompt.trim() || lyrLoading) return;
|
||||
setLyrLoading(true);
|
||||
setLyrError(null);
|
||||
try {
|
||||
const res = await generateMusicLyrics(lyrPrompt.trim());
|
||||
if (res?.text) {
|
||||
const record = await saveLyrics({
|
||||
title: res.title || '',
|
||||
text: res.text,
|
||||
prompt: lyrPrompt.trim(),
|
||||
});
|
||||
setSaved((prev) => [record, ...prev]);
|
||||
} else {
|
||||
setLyrError('가사 생성 결과가 없습니다');
|
||||
}
|
||||
} catch (e) {
|
||||
setLyrError(e.message || '가사 생성에 실패했습니다');
|
||||
} finally {
|
||||
setLyrLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 복사 ── */
|
||||
const handleCopy = (text, id) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(id);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
/* ── 삭제 ── */
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await deleteLyrics(id);
|
||||
setSaved((prev) => prev.filter((l) => l.id !== id));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
/* ── 수정 시작 ── */
|
||||
const startEdit = (item) => {
|
||||
setEditingId(item.id);
|
||||
setEditTitle(item.title);
|
||||
setEditText(item.text);
|
||||
};
|
||||
|
||||
/* ── 수정 저장 ── */
|
||||
const handleSaveEdit = async () => {
|
||||
if (editingId == null) return;
|
||||
try {
|
||||
const updated = await updateLyrics(editingId, { title: editTitle, text: editText });
|
||||
setSaved((prev) => prev.map((l) => l.id === editingId ? updated : l));
|
||||
setEditingId(null);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
/* ── 수정 취소 ── */
|
||||
const cancelEdit = () => setEditingId(null);
|
||||
|
||||
return (
|
||||
<div className="ms-lyrics-tab">
|
||||
<div className="ms-lyrics-tab__form">
|
||||
<div className="ms-lyrics-tab__head">
|
||||
<h2 className="ms-lyrics-tab__title">AI Lyrics Generator</h2>
|
||||
<p className="ms-lyrics-tab__desc">
|
||||
원하는 분위기, 주제, 스타일을 설명하면 AI가 가사를 작성합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="ms-lyrics-tab__input-wrap">
|
||||
<textarea
|
||||
className="ms-lyrics-tab__input"
|
||||
placeholder="예: 비 오는 밤, 혼자 걷는 도시의 거리를 배경으로 한 감성적인 발라드 가사"
|
||||
value={lyrPrompt}
|
||||
onChange={(e) => setLyrPrompt(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleGenerate(); } }}
|
||||
/>
|
||||
<div className="ms-lyrics-tab__input-footer">
|
||||
<span className="ms-lyrics-tab__count">{lyrPrompt.length}/200</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`ms-btn ms-btn--accent ${lyrLoading ? 'is-loading' : ''}`}
|
||||
onClick={handleGenerate}
|
||||
disabled={!lyrPrompt.trim() || lyrLoading}
|
||||
>
|
||||
{lyrLoading ? (
|
||||
<><span className="ms-btn__spinner" /> 생성 중...</>
|
||||
) : (
|
||||
'✨ 가사 생성'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lyrError && (
|
||||
<div className="ms-error-banner">
|
||||
<span>⚠ {lyrError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lyrLoading && (
|
||||
<div className="ms-lyrics-tab__loading">
|
||||
<div className="ms-lyrics-tab__loading-bar" />
|
||||
<p>AI가 가사를 작성하고 있습니다...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 저장된 가사 목록 */}
|
||||
{loadingSaved && (
|
||||
<div className="ms-lyrics-tab__loading">
|
||||
<div className="ms-lyrics-tab__loading-bar" />
|
||||
<p>저장된 가사를 불러오는 중...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loadingSaved && saved.length === 0 && !lyrLoading && (
|
||||
<div className="ms-lyrics-tab__empty">
|
||||
<span className="ms-lyrics-tab__empty-icon">🎤</span>
|
||||
<p>저장된 가사가 없습니다</p>
|
||||
<p className="ms-lyrics-tab__empty-hint">
|
||||
프롬프트를 입력하면 AI가 [Verse], [Chorus] 등 섹션이 포함된 가사를 작성합니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ms-lyrics-tab__results">
|
||||
{saved.map((item) => (
|
||||
<div key={item.id} className={`ms-lyrics-card ${editingId === item.id ? 'is-editing' : ''}`}>
|
||||
<div className="ms-lyrics-card__header">
|
||||
{editingId === item.id ? (
|
||||
<input
|
||||
className="ms-lyrics-card__title-input"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
placeholder="제목"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{item.title && <h3 className="ms-lyrics-card__title">{item.title}</h3>}
|
||||
<span className="ms-lyrics-card__prompt">{item.prompt}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="ms-lyrics-card__date">
|
||||
{item.created_at ? new Date(item.created_at).toLocaleDateString('ko-KR') : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{editingId === item.id ? (
|
||||
<textarea
|
||||
className="ms-lyrics-card__text-input"
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
rows={12}
|
||||
/>
|
||||
) : (
|
||||
<pre className="ms-lyrics-card__text">{item.text}</pre>
|
||||
)}
|
||||
|
||||
<div className="ms-lyrics-card__actions">
|
||||
{editingId === item.id ? (
|
||||
<>
|
||||
<button type="button" className="ms-btn ms-btn--accent ms-btn--sm" onClick={handleSaveEdit}>
|
||||
✓ 저장
|
||||
</button>
|
||||
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm" onClick={cancelEdit}>
|
||||
취소
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||
onClick={() => handleCopy(item.text, item.id)}
|
||||
>
|
||||
{copied === item.id ? '✓ 복사됨' : '📋 복사'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||
onClick={() => onUseInCreate(item.text)}
|
||||
>
|
||||
🎵 Create에서 사용
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||
onClick={() => startEdit(item)}
|
||||
>
|
||||
✏️ 수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ms-btn ms-btn--ghost ms-btn--sm ms-btn--danger-text"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
>
|
||||
🗑 삭제
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Main Page
|
||||
───────────────────────────────────────────── */
|
||||
@@ -874,7 +535,20 @@ export default function MusicStudio() {
|
||||
const [lyricsLoading, setLyricsLoading] = useState(false);
|
||||
const [model, setModel] = useState('V4');
|
||||
const [models, setModels] = useState([]);
|
||||
const [credits, setCredits] = useState(null);
|
||||
|
||||
/* ── 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 }
|
||||
|
||||
/* ── Phase 2 상태 ── */
|
||||
const [stemModal, setStemModal] = useState(null); // { stems: {} }
|
||||
const [syncedLyrics, setSyncedLyrics] = useState(null); // { audioUrl, words }
|
||||
const [styleBoostLoading, setStyleBoostLoading] = useState(false);
|
||||
|
||||
/* ── 생성 상태 ── */
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
@@ -917,14 +591,11 @@ export default function MusicStudio() {
|
||||
.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 생성 ── */
|
||||
@@ -1075,6 +746,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,
|
||||
} : {}),
|
||||
};
|
||||
|
||||
@@ -1163,6 +838,224 @@ 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);
|
||||
};
|
||||
|
||||
/* ── WAV 변환 핸들러 ── */
|
||||
const handleWavConvert = async (track) => {
|
||||
if (!track.task_id || !track.suno_id || isGenerating) return;
|
||||
setTab('create');
|
||||
setIsGenerating(true);
|
||||
setTrack(null);
|
||||
setGenProgress(0);
|
||||
setGenStep('WAV 변환 요청 중…');
|
||||
setGenError(null);
|
||||
try {
|
||||
const res = await convertToWav({
|
||||
suno_task_id: track.task_id,
|
||||
suno_id: track.suno_id,
|
||||
track_id: track.id,
|
||||
});
|
||||
if (res?.task_id) {
|
||||
taskIdRef.current = res.task_id;
|
||||
setGenStep('WAV 변환 처리 중…');
|
||||
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 wavUrl = status.audio_url;
|
||||
if (wavUrl) {
|
||||
const a = document.createElement('a');
|
||||
a.href = wavUrl;
|
||||
a.download = `${track.title || 'track'}.wav`;
|
||||
a.click();
|
||||
}
|
||||
setGenStep('WAV 변환 완료!');
|
||||
} else if (status.status === 'failed') {
|
||||
clearInterval(pollRef.current);
|
||||
setIsGenerating(false);
|
||||
setGenError(`WAV 변환 실패: ${status.error ?? '알 수 없는 오류'}`);
|
||||
}
|
||||
} catch {
|
||||
clearInterval(pollRef.current);
|
||||
setIsGenerating(false);
|
||||
setGenError('WAV 변환 상태 조회 실패');
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
} catch {
|
||||
setIsGenerating(false);
|
||||
setGenError('WAV 변환에 실패했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 12스템 분리 핸들러 ── */
|
||||
const handleStemSplit = async (track) => {
|
||||
if (!track.task_id || !track.suno_id || isGenerating) return;
|
||||
setTab('create');
|
||||
setIsGenerating(true);
|
||||
setTrack(null);
|
||||
setGenProgress(0);
|
||||
setGenStep('12스템 분리 요청 중…');
|
||||
setGenError(null);
|
||||
try {
|
||||
const res = await splitStems({
|
||||
suno_task_id: track.task_id,
|
||||
suno_id: track.suno_id,
|
||||
track_id: track.id,
|
||||
});
|
||||
if (res?.task_id) {
|
||||
taskIdRef.current = res.task_id;
|
||||
setGenStep('12스템 분리 처리 중 (약 2~3분)…');
|
||||
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 stems = JSON.parse(status.audio_url || '{}');
|
||||
setStemModal({ stems });
|
||||
} 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('12스템 분리에 실패했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 타임스탬프 가사 핸들러 ── */
|
||||
const handleSyncedLyrics = async (track) => {
|
||||
if (!track.task_id || !track.suno_id) return;
|
||||
try {
|
||||
const result = await getTimestampedLyrics(track.task_id, track.suno_id);
|
||||
if (result?.alignedWords || result?.aligned_words) {
|
||||
setSyncedLyrics({
|
||||
audioUrl: track.audio_url,
|
||||
words: result.alignedWords || result.aligned_words,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setGenError('타임스탬프 가사 조회에 실패했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 스타일 부스트 핸들러 ── */
|
||||
const handleStyleBoost = async () => {
|
||||
if (!genre || styleBoostLoading) return;
|
||||
setStyleBoostLoading(true);
|
||||
try {
|
||||
const content = [
|
||||
GENRES.find(g => g.id === genre)?.label,
|
||||
...moods.map(id => MOODS.find(m => m.id === id)?.label).filter(Boolean),
|
||||
].join(', ');
|
||||
const result = await generateStyleBoost(content);
|
||||
if (result?.result) {
|
||||
setPrompt(result.result);
|
||||
}
|
||||
} catch {}
|
||||
finally { setStyleBoostLoading(false); }
|
||||
};
|
||||
|
||||
/* ── 뮤직비디오 핸들러 ── */
|
||||
const handleVideoGenerate = async (track) => {
|
||||
if (!track.task_id || !track.suno_id || isGenerating) return;
|
||||
setTab('create');
|
||||
setIsGenerating(true);
|
||||
setTrack(null);
|
||||
setGenProgress(0);
|
||||
setGenStep('뮤직비디오 생성 요청 중…');
|
||||
setGenError(null);
|
||||
try {
|
||||
const res = await generateVideo({
|
||||
suno_task_id: track.task_id,
|
||||
suno_id: track.suno_id,
|
||||
track_id: track.id,
|
||||
});
|
||||
if (res?.task_id) {
|
||||
taskIdRef.current = res.task_id;
|
||||
startPolling(res.task_id, `${track.title} (Video)`);
|
||||
}
|
||||
} catch {
|
||||
setIsGenerating(false);
|
||||
setGenError('뮤직비디오 생성에 실패했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewTrack = () => {
|
||||
setTrack(null);
|
||||
setGenProgress(0);
|
||||
@@ -1188,14 +1081,7 @@ export default function MusicStudio() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="ms-header__right">
|
||||
{credits && (
|
||||
<div className="ms-credits">
|
||||
<span className="ms-credits__label">Credits</span>
|
||||
<span className="ms-credits__value">
|
||||
{credits.credits_left ?? credits.remaining ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<CreditsBadge />
|
||||
<SonicRadar isGenerating={isGenerating} accentColor={accentColor} />
|
||||
</div>
|
||||
</header>
|
||||
@@ -1226,6 +1112,13 @@ export default function MusicStudio() {
|
||||
<span className="ms-tab__badge">{library.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ms-tab ${tab === 'remix' ? 'is-active' : ''}`}
|
||||
onClick={() => setTab('remix')}
|
||||
>
|
||||
<span className="ms-tab__icon">🔄</span> Remix
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* ═══ LIBRARY TAB ═══ */}
|
||||
@@ -1237,6 +1130,11 @@ export default function MusicStudio() {
|
||||
onRefresh={loadLibrary}
|
||||
onExtend={handleExtend}
|
||||
onVocalRemoval={handleVocalRemoval}
|
||||
onCoverArt={handleCoverArt}
|
||||
onWavConvert={handleWavConvert}
|
||||
onStemSplit={handleStemSplit}
|
||||
onSyncedLyrics={handleSyncedLyrics}
|
||||
onVideoGenerate={handleVideoGenerate}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
)}
|
||||
@@ -1246,6 +1144,24 @@ export default function MusicStudio() {
|
||||
<LyricsTab onUseInCreate={(text) => { setLyrics(text); setInstrumental(false); setProvider('suno'); setTab('create'); }} />
|
||||
)}
|
||||
|
||||
{/* ═══ REMIX TAB ═══ */}
|
||||
{tab === 'remix' && (
|
||||
<RemixTab
|
||||
onTaskStarted={(taskId, title) => {
|
||||
setTab('create');
|
||||
setIsGenerating(true);
|
||||
setTrack(null);
|
||||
setGenProgress(0);
|
||||
setGenStep(`${title} 처리 중…`);
|
||||
setGenError(null);
|
||||
taskIdRef.current = taskId;
|
||||
startPolling(taskId, title);
|
||||
}}
|
||||
model={model}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ CREATE TAB ═══ */}
|
||||
{tab === 'create' && (
|
||||
<div className="ms-layout">
|
||||
@@ -1316,6 +1232,17 @@ export default function MusicStudio() {
|
||||
<span className="ms-section__step">01</span>
|
||||
<h2 className="ms-section__title">Genre</h2>
|
||||
<span className="ms-section__hint">장르를 선택하세요</span>
|
||||
{provider === 'suno' && (
|
||||
<button
|
||||
type="button"
|
||||
className={`ms-btn ms-btn--ghost ms-btn--sm ms-style-boost-btn ${styleBoostLoading ? 'is-loading' : ''}`}
|
||||
onClick={handleStyleBoost}
|
||||
disabled={styleBoostLoading || !genre}
|
||||
title="현재 설정으로 최적 스타일 프롬프트 생성"
|
||||
>
|
||||
{styleBoostLoading ? '생성 중...' : '✨ Style Boost'}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className={`ms-desc-toggle ${isOpen('genre') ? 'is-open' : ''}`} onClick={() => toggleDesc('genre')} aria-label="설명 펼치기">ℹ</button>
|
||||
</div>
|
||||
<div className={`ms-desc-wrap ${isOpen('genre') ? 'is-open' : ''}`}>
|
||||
@@ -1529,6 +1456,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 */}
|
||||
@@ -1720,6 +1737,29 @@ export default function MusicStudio() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{coverArtModal && (
|
||||
<CoverArtModal
|
||||
images={coverArtModal.images}
|
||||
onSelect={handleCoverSelect}
|
||||
onClose={() => setCoverArtModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ Stem Modal ═══ */}
|
||||
{stemModal && (
|
||||
<StemModal stems={stemModal.stems} onClose={() => setStemModal(null)} />
|
||||
)}
|
||||
|
||||
{/* ═══ Synced Lyrics Player ═══ */}
|
||||
{syncedLyrics && (
|
||||
<SyncedLyricsPlayer
|
||||
audioUrl={syncedLyrics.audioUrl}
|
||||
alignedWords={syncedLyrics.words}
|
||||
onClose={() => setSyncedLyrics(null)}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
128
src/pages/music/components/AudioPlayer.jsx
Normal file
128
src/pages/music/components/AudioPlayer.jsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
유틸
|
||||
───────────────────────────────────────────── */
|
||||
const pad = (n) => String(Math.floor(n)).padStart(2, '0');
|
||||
export const fmtTime = (s) => `${pad(s / 60)}:${pad(s % 60)}`;
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Audio Player (실제 <audio> 기반)
|
||||
───────────────────────────────────────────── */
|
||||
const AudioPlayer = ({ audioUrl, totalSec, accentColor }) => {
|
||||
const audioRef = useRef(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [duration, setDuration] = useState(totalSec ?? 0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
|
||||
/* 실제 오디오가 없으면 가짜 타이머로 폴백 */
|
||||
const isFake = !audioUrl;
|
||||
const timerRef = useRef(null);
|
||||
|
||||
const total = duration || totalSec || 60;
|
||||
|
||||
const togglePlay = () => {
|
||||
if (isFake) {
|
||||
if (playing) {
|
||||
clearInterval(timerRef.current);
|
||||
setPlaying(false);
|
||||
} else {
|
||||
setPlaying(true);
|
||||
timerRef.current = setInterval(() => {
|
||||
setElapsed((e) => {
|
||||
if (e >= total - 1) {
|
||||
clearInterval(timerRef.current);
|
||||
setPlaying(false);
|
||||
return 0;
|
||||
}
|
||||
return e + 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
playing ? el.pause() : el.play();
|
||||
};
|
||||
|
||||
const handleSeek = (e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = (e.clientX - rect.left) / rect.width;
|
||||
const newTime = ratio * total;
|
||||
if (!isFake && audioRef.current) {
|
||||
audioRef.current.currentTime = newTime;
|
||||
}
|
||||
setElapsed(newTime);
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e) => {
|
||||
const v = Number(e.target.value);
|
||||
setVolume(v);
|
||||
if (!isFake && audioRef.current) audioRef.current.volume = v;
|
||||
};
|
||||
|
||||
useEffect(() => () => clearInterval(timerRef.current), []);
|
||||
|
||||
const progress = (elapsed / total) * 100;
|
||||
|
||||
return (
|
||||
<div className="ms-audio-player" style={{ '--player-accent': accentColor }}>
|
||||
{!isFake && (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
onLoadedMetadata={(e) => setDuration(e.target.duration)}
|
||||
onTimeUpdate={(e) => setElapsed(e.target.currentTime)}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => { setPlaying(false); setElapsed(0); }}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`ms-player__play ${playing ? 'is-playing' : ''}`}
|
||||
onClick={togglePlay}
|
||||
aria-label={playing ? '일시정지' : '재생'}
|
||||
>
|
||||
{playing ? (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<rect x="3" y="2" width="4" height="12" rx="1" />
|
||||
<rect x="9" y="2" width="4" height="12" rx="1" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M4 2l10 6-10 6V2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="ms-player__timeline">
|
||||
<div className="ms-player__bar" onClick={handleSeek} role="slider"
|
||||
aria-label="재생 위치" aria-valuenow={Math.round(elapsed)} aria-valuemin={0} aria-valuemax={Math.round(total)}>
|
||||
<div className="ms-player__fill" style={{ width: `${progress}%` }} />
|
||||
<div className="ms-player__thumb" style={{ left: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="ms-player__times">
|
||||
<span>{fmtTime(elapsed)}</span>
|
||||
<span>{fmtTime(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ms-volume">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden>
|
||||
<path d="M2 5h2.5l3-3v10l-3-3H2V5zm8.5-1.5a4.5 4.5 0 010 7" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round"/>
|
||||
</svg>
|
||||
<input
|
||||
type="range" min={0} max={1} step={0.02} value={volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="ms-volume__slider"
|
||||
aria-label="볼륨"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioPlayer;
|
||||
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;
|
||||
36
src/pages/music/components/CreditsBadge.jsx
Normal file
36
src/pages/music/components/CreditsBadge.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { getMusicCredits } from '../../../api';
|
||||
|
||||
const CreditsBadge = () => {
|
||||
const [credits, setCredits] = useState(null);
|
||||
|
||||
const fetchCredits = useCallback(async () => {
|
||||
try {
|
||||
const data = await getMusicCredits();
|
||||
setCredits(data);
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCredits();
|
||||
const interval = setInterval(fetchCredits, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchCredits]);
|
||||
|
||||
if (!credits) return null;
|
||||
|
||||
const remaining = credits.credits_left ?? credits.remaining ?? credits.data ?? null;
|
||||
if (remaining == null) return null;
|
||||
|
||||
const isLow = remaining <= 10;
|
||||
|
||||
return (
|
||||
<div className={`ms-credits-badge ${isLow ? 'is-low' : ''}`}>
|
||||
<span className="ms-credits-badge__icon">⚡</span>
|
||||
<span className="ms-credits-badge__value">{remaining}</span>
|
||||
<span className="ms-credits-badge__label">credits</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreditsBadge;
|
||||
245
src/pages/music/components/LyricsTab.jsx
Normal file
245
src/pages/music/components/LyricsTab.jsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
generateMusicLyrics,
|
||||
getSavedLyrics,
|
||||
saveLyrics,
|
||||
updateLyrics,
|
||||
deleteLyrics,
|
||||
} from '../../../api';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Lyrics Tab
|
||||
───────────────────────────────────────────── */
|
||||
const LyricsTab = ({ onUseInCreate }) => {
|
||||
const [lyrPrompt, setLyrPrompt] = useState('');
|
||||
const [lyrLoading, setLyrLoading] = useState(false);
|
||||
const [lyrError, setLyrError] = useState(null);
|
||||
const [copied, setCopied] = useState(null); // id
|
||||
const [saved, setSaved] = useState([]); // DB에 저장된 가사
|
||||
const [loadingSaved, setLoadingSaved] = useState(true);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editText, setEditText] = useState('');
|
||||
|
||||
/* ── 저장된 가사 로드 ── */
|
||||
useEffect(() => {
|
||||
setLoadingSaved(true);
|
||||
getSavedLyrics()
|
||||
.then((data) => setSaved(data.lyrics ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingSaved(false));
|
||||
}, []);
|
||||
|
||||
/* ── AI 생성 → 즉시 저장 ── */
|
||||
const handleGenerate = async () => {
|
||||
if (!lyrPrompt.trim() || lyrLoading) return;
|
||||
setLyrLoading(true);
|
||||
setLyrError(null);
|
||||
try {
|
||||
const res = await generateMusicLyrics(lyrPrompt.trim());
|
||||
if (res?.text) {
|
||||
const record = await saveLyrics({
|
||||
title: res.title || '',
|
||||
text: res.text,
|
||||
prompt: lyrPrompt.trim(),
|
||||
});
|
||||
setSaved((prev) => [record, ...prev]);
|
||||
} else {
|
||||
setLyrError('가사 생성 결과가 없습니다');
|
||||
}
|
||||
} catch (e) {
|
||||
setLyrError(e.message || '가사 생성에 실패했습니다');
|
||||
} finally {
|
||||
setLyrLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 복사 ── */
|
||||
const handleCopy = (text, id) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(id);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
/* ── 삭제 ── */
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await deleteLyrics(id);
|
||||
setSaved((prev) => prev.filter((l) => l.id !== id));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
/* ── 수정 시작 ── */
|
||||
const startEdit = (item) => {
|
||||
setEditingId(item.id);
|
||||
setEditTitle(item.title);
|
||||
setEditText(item.text);
|
||||
};
|
||||
|
||||
/* ── 수정 저장 ── */
|
||||
const handleSaveEdit = async () => {
|
||||
if (editingId == null) return;
|
||||
try {
|
||||
const updated = await updateLyrics(editingId, { title: editTitle, text: editText });
|
||||
setSaved((prev) => prev.map((l) => l.id === editingId ? updated : l));
|
||||
setEditingId(null);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
/* ── 수정 취소 ── */
|
||||
const cancelEdit = () => setEditingId(null);
|
||||
|
||||
return (
|
||||
<div className="ms-lyrics-tab">
|
||||
<div className="ms-lyrics-tab__form">
|
||||
<div className="ms-lyrics-tab__head">
|
||||
<h2 className="ms-lyrics-tab__title">AI Lyrics Generator</h2>
|
||||
<p className="ms-lyrics-tab__desc">
|
||||
원하는 분위기, 주제, 스타일을 설명하면 AI가 가사를 작성합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="ms-lyrics-tab__input-wrap">
|
||||
<textarea
|
||||
className="ms-lyrics-tab__input"
|
||||
placeholder="예: 비 오는 밤, 혼자 걷는 도시의 거리를 배경으로 한 감성적인 발라드 가사"
|
||||
value={lyrPrompt}
|
||||
onChange={(e) => setLyrPrompt(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleGenerate(); } }}
|
||||
/>
|
||||
<div className="ms-lyrics-tab__input-footer">
|
||||
<span className="ms-lyrics-tab__count">{lyrPrompt.length}/200</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`ms-btn ms-btn--accent ${lyrLoading ? 'is-loading' : ''}`}
|
||||
onClick={handleGenerate}
|
||||
disabled={!lyrPrompt.trim() || lyrLoading}
|
||||
>
|
||||
{lyrLoading ? (
|
||||
<><span className="ms-btn__spinner" /> 생성 중...</>
|
||||
) : (
|
||||
'✨ 가사 생성'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lyrError && (
|
||||
<div className="ms-error-banner">
|
||||
<span>⚠ {lyrError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lyrLoading && (
|
||||
<div className="ms-lyrics-tab__loading">
|
||||
<div className="ms-lyrics-tab__loading-bar" />
|
||||
<p>AI가 가사를 작성하고 있습니다...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 저장된 가사 목록 */}
|
||||
{loadingSaved && (
|
||||
<div className="ms-lyrics-tab__loading">
|
||||
<div className="ms-lyrics-tab__loading-bar" />
|
||||
<p>저장된 가사를 불러오는 중...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loadingSaved && saved.length === 0 && !lyrLoading && (
|
||||
<div className="ms-lyrics-tab__empty">
|
||||
<span className="ms-lyrics-tab__empty-icon">🎤</span>
|
||||
<p>저장된 가사가 없습니다</p>
|
||||
<p className="ms-lyrics-tab__empty-hint">
|
||||
프롬프트를 입력하면 AI가 [Verse], [Chorus] 등 섹션이 포함된 가사를 작성합니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ms-lyrics-tab__results">
|
||||
{saved.map((item) => (
|
||||
<div key={item.id} className={`ms-lyrics-card ${editingId === item.id ? 'is-editing' : ''}`}>
|
||||
<div className="ms-lyrics-card__header">
|
||||
{editingId === item.id ? (
|
||||
<input
|
||||
className="ms-lyrics-card__title-input"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
placeholder="제목"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{item.title && <h3 className="ms-lyrics-card__title">{item.title}</h3>}
|
||||
<span className="ms-lyrics-card__prompt">{item.prompt}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="ms-lyrics-card__date">
|
||||
{item.created_at ? new Date(item.created_at).toLocaleDateString('ko-KR') : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{editingId === item.id ? (
|
||||
<textarea
|
||||
className="ms-lyrics-card__text-input"
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
rows={12}
|
||||
/>
|
||||
) : (
|
||||
<pre className="ms-lyrics-card__text">{item.text}</pre>
|
||||
)}
|
||||
|
||||
<div className="ms-lyrics-card__actions">
|
||||
{editingId === item.id ? (
|
||||
<>
|
||||
<button type="button" className="ms-btn ms-btn--accent ms-btn--sm" onClick={handleSaveEdit}>
|
||||
✓ 저장
|
||||
</button>
|
||||
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm" onClick={cancelEdit}>
|
||||
취소
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||
onClick={() => handleCopy(item.text, item.id)}
|
||||
>
|
||||
{copied === item.id ? '✓ 복사됨' : '📋 복사'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||
onClick={() => onUseInCreate(item.text)}
|
||||
>
|
||||
🎵 Create에서 사용
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||
onClick={() => startEdit(item)}
|
||||
>
|
||||
✏️ 수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ms-btn ms-btn--ghost ms-btn--sm ms-btn--danger-text"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
>
|
||||
🗑 삭제
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LyricsTab;
|
||||
193
src/pages/music/components/RemixTab.jsx
Normal file
193
src/pages/music/components/RemixTab.jsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import React, { useState } from 'react';
|
||||
import { uploadAndCover, uploadAndExtend, addVocals, addInstrumental } from '../../../api';
|
||||
|
||||
const REMIX_ACTIONS = [
|
||||
{ id: 'cover', label: 'AI Cover', icon: '🎨', desc: '외부 음원을 Suno AI 스타일로 리메이크' },
|
||||
{ id: 'extend', label: 'Extend', icon: '⏩', desc: '외부 음원을 이어서 확장' },
|
||||
{ id: 'add-vocals', label: 'Add Vocals', icon: '🎤', desc: '인스트루멘탈에 AI 보컬 입히기' },
|
||||
{ id: 'add-instrumental', label: 'Add Instrumental', icon: '🎹', desc: '보컬에 AI 반주 입히기' },
|
||||
];
|
||||
|
||||
const RemixTab = ({ onTaskStarted, model, isGenerating }) => {
|
||||
const [uploadUrl, setUploadUrl] = useState('');
|
||||
const [activeAction, setActiveAction] = useState(null);
|
||||
|
||||
// 각 액션별 파라미터
|
||||
const [title, setTitle] = useState('');
|
||||
const [style, setStyle] = useState('');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [negativeTags, setNegativeTags] = useState('');
|
||||
const [vocalGender, setVocalGender] = useState(null);
|
||||
const [continueAt, setContinueAt] = useState(0);
|
||||
const [instrumental, setInstrumental] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!uploadUrl || !activeAction || isGenerating) return;
|
||||
|
||||
let apiCall;
|
||||
let payload = {};
|
||||
|
||||
switch (activeAction) {
|
||||
case 'cover':
|
||||
apiCall = uploadAndCover;
|
||||
payload = {
|
||||
upload_url: uploadUrl, model, custom_mode: true,
|
||||
instrumental, prompt, style, title,
|
||||
vocal_gender: vocalGender || undefined,
|
||||
negative_tags: negativeTags || undefined,
|
||||
};
|
||||
break;
|
||||
case 'extend':
|
||||
apiCall = uploadAndExtend;
|
||||
payload = {
|
||||
upload_url: uploadUrl, model,
|
||||
default_param_flag: !prompt,
|
||||
continue_at: continueAt || undefined,
|
||||
prompt, style, title, instrumental,
|
||||
vocal_gender: vocalGender || undefined,
|
||||
negative_tags: negativeTags || undefined,
|
||||
};
|
||||
break;
|
||||
case 'add-vocals':
|
||||
apiCall = addVocals;
|
||||
payload = {
|
||||
upload_url: uploadUrl, prompt, title, style,
|
||||
negative_tags: negativeTags,
|
||||
vocal_gender: vocalGender || undefined,
|
||||
model: 'V4_5PLUS',
|
||||
};
|
||||
break;
|
||||
case 'add-instrumental':
|
||||
apiCall = addInstrumental;
|
||||
payload = {
|
||||
upload_url: uploadUrl, title, tags,
|
||||
negative_tags: negativeTags,
|
||||
vocal_gender: vocalGender || undefined,
|
||||
model: 'V4_5PLUS',
|
||||
};
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiCall(payload);
|
||||
if (res?.task_id) {
|
||||
onTaskStarted(res.task_id, `Remix: ${REMIX_ACTIONS.find(a => a.id === activeAction)?.label}`);
|
||||
}
|
||||
} catch (e) {
|
||||
// 에러는 부모 컴포넌트에서 처리
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ms-remix-tab">
|
||||
<div className="ms-remix-tab__header">
|
||||
<h2 className="ms-remix-tab__title">Remix Studio</h2>
|
||||
<p className="ms-remix-tab__desc">외부 음원을 AI로 리메이크, 확장, 보컬/반주 추가</p>
|
||||
</div>
|
||||
|
||||
<div className="ms-param-group">
|
||||
<label className="ms-param-label">Audio URL</label>
|
||||
<input
|
||||
type="url"
|
||||
className="ms-negative-tags__input"
|
||||
placeholder="리믹스할 오디오 파일 URL (예: /media/music/track.mp3)"
|
||||
value={uploadUrl}
|
||||
onChange={(e) => setUploadUrl(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ms-remix-actions">
|
||||
{REMIX_ACTIONS.map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
className={`ms-remix-card ${activeAction === action.id ? 'is-active' : ''}`}
|
||||
onClick={() => setActiveAction(activeAction === action.id ? null : action.id)}
|
||||
>
|
||||
<span className="ms-remix-card__icon">{action.icon}</span>
|
||||
<span className="ms-remix-card__label">{action.label}</span>
|
||||
<span className="ms-remix-card__desc">{action.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeAction && (
|
||||
<div className="ms-remix-params">
|
||||
{/* 공통 파라미터 */}
|
||||
<div className="ms-param-group">
|
||||
<label className="ms-param-label">Title</label>
|
||||
<input type="text" className="ms-negative-tags__input" value={title}
|
||||
onChange={(e) => setTitle(e.target.value)} placeholder="곡 제목" style={{ width: '100%' }} />
|
||||
</div>
|
||||
|
||||
{(activeAction === 'cover' || activeAction === 'extend' || activeAction === 'add-vocals') && (
|
||||
<div className="ms-param-group">
|
||||
<label className="ms-param-label">Prompt / Lyrics</label>
|
||||
<textarea className="ms-prompt" value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)} rows={3}
|
||||
placeholder="가사 또는 스타일 설명" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(activeAction === 'cover' || activeAction === 'extend' || activeAction === 'add-vocals') && (
|
||||
<div className="ms-param-group">
|
||||
<label className="ms-param-label">Style</label>
|
||||
<input type="text" className="ms-negative-tags__input" value={style}
|
||||
onChange={(e) => setStyle(e.target.value)} placeholder="예: Pop, Energetic, Piano" style={{ width: '100%' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeAction === 'add-instrumental' && (
|
||||
<div className="ms-param-group">
|
||||
<label className="ms-param-label">Tags (스타일/특성)</label>
|
||||
<input type="text" className="ms-negative-tags__input" value={tags}
|
||||
onChange={(e) => setTags(e.target.value)} placeholder="예: acoustic, warm, dreamy" style={{ width: '100%' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeAction === 'extend' && (
|
||||
<div className="ms-param-group">
|
||||
<label className="ms-param-label">Continue At (초)</label>
|
||||
<input type="number" className="ms-negative-tags__input" value={continueAt}
|
||||
onChange={(e) => setContinueAt(Number(e.target.value))} min={0} style={{ width: '120px' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ms-param-group">
|
||||
<label className="ms-param-label">Exclude Styles</label>
|
||||
<input type="text" className="ms-negative-tags__input" value={negativeTags}
|
||||
onChange={(e) => setNegativeTags(e.target.value)} placeholder="제외할 스타일" style={{ width: '100%' }} />
|
||||
</div>
|
||||
|
||||
<div className="ms-param-group">
|
||||
<label className="ms-param-label">Vocal Gender</label>
|
||||
<div className="ms-gender-toggle">
|
||||
{[{ value: null, label: 'Auto' }, { value: 'm', label: 'Male' }, { value: 'f', label: 'Female' }].map((opt) => (
|
||||
<button key={opt.label} type="button"
|
||||
className={`ms-gender-btn ${vocalGender === opt.value ? 'is-active' : ''}`}
|
||||
onClick={() => setVocalGender(opt.value)}>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="ms-btn ms-btn--accent ms-remix-submit"
|
||||
disabled={!uploadUrl || isGenerating}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isGenerating ? 'Processing...' : `Start ${REMIX_ACTIONS.find(a => a.id === activeAction)?.label}`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RemixTab;
|
||||
55
src/pages/music/components/StemModal.jsx
Normal file
55
src/pages/music/components/StemModal.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const STEM_ICONS = {
|
||||
vocal: '🎤', backing_vocals: '🎶', drums: '🥁', bass: '🎸',
|
||||
guitar: '🎸', keyboard: '🎹', strings: '🎻', brass: '🎺',
|
||||
woodwinds: '🪈', percussion: '🪘', synth: '🎛', fx: '✨',
|
||||
};
|
||||
|
||||
const StemModal = ({ stems, onClose }) => {
|
||||
const [playingStem, setPlayingStem] = useState(null);
|
||||
|
||||
if (!stems || Object.keys(stems).length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="ms-modal-overlay" onClick={onClose}>
|
||||
<div className="ms-modal ms-modal--wide" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="ms-modal__header">
|
||||
<h3 className="ms-modal__title">12 Stems</h3>
|
||||
<span className="ms-modal__subtitle">각 스템을 개별 재생 및 다운로드할 수 있습니다</span>
|
||||
<button type="button" className="ms-modal__close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<div className="ms-stem-grid">
|
||||
{Object.entries(stems).map(([name, url]) => {
|
||||
if (!url) return null;
|
||||
const isPlaying = playingStem === name;
|
||||
return (
|
||||
<div key={name} className={`ms-stem-card ${isPlaying ? 'is-playing' : ''}`}>
|
||||
<span className="ms-stem-card__icon">{STEM_ICONS[name] || '🎵'}</span>
|
||||
<span className="ms-stem-card__name">{name.replace(/_/g, ' ')}</span>
|
||||
<div className="ms-stem-card__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ms-btn--icon"
|
||||
onClick={() => setPlayingStem(isPlaying ? null : name)}
|
||||
>
|
||||
{isPlaying ? '■' : '▶'}
|
||||
</button>
|
||||
<a href={url} download className="ms-btn--icon" aria-label="다운로드">↓</a>
|
||||
</div>
|
||||
{isPlaying && (
|
||||
<audio src={url} autoPlay onEnded={() => setPlayingStem(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="ms-modal__actions">
|
||||
<button type="button" className="ms-btn ms-btn--ghost" onClick={onClose}>닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StemModal;
|
||||
51
src/pages/music/components/SyncedLyricsPlayer.jsx
Normal file
51
src/pages/music/components/SyncedLyricsPlayer.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const SyncedLyricsPlayer = ({ audioUrl, alignedWords, onClose, accentColor }) => {
|
||||
const audioRef = useRef(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
const handler = () => setCurrentTime(el.currentTime);
|
||||
el.addEventListener('timeupdate', handler);
|
||||
return () => el.removeEventListener('timeupdate', handler);
|
||||
}, []);
|
||||
|
||||
if (!alignedWords || alignedWords.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="ms-synced-player" style={{ '--synced-accent': accentColor }}>
|
||||
<div className="ms-synced-player__header">
|
||||
<h4 className="ms-synced-player__title">Synced Lyrics</h4>
|
||||
<button type="button" className="ms-modal__close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => setPlaying(false)}
|
||||
controls
|
||||
className="ms-synced-player__audio"
|
||||
/>
|
||||
<div className="ms-synced-player__lyrics">
|
||||
{alignedWords.map((word, idx) => {
|
||||
const isActive = currentTime >= word.startS && currentTime < word.endS;
|
||||
const isPast = currentTime >= word.endS;
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className={`ms-synced-word ${isActive ? 'is-active' : ''} ${isPast ? 'is-past' : ''}`}
|
||||
>
|
||||
{word.word}{' '}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SyncedLyricsPlayer;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,6 @@ const Lotto = lazy(() => import('./pages/lotto/Lotto'));
|
||||
const Travel = lazy(() => import('./pages/travel/Travel'));
|
||||
const Stock = lazy(() => import('./pages/stock/Stock'));
|
||||
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
|
||||
const RealEstate = lazy(() => import('./pages/realestate/RealEstate'));
|
||||
const Subscription = lazy(() => import('./pages/subscription/Subscription'));
|
||||
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
|
||||
const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream'));
|
||||
@@ -69,7 +68,7 @@ export const navLinks = [
|
||||
label: 'Realestate',
|
||||
path: '/realestate',
|
||||
subtitle: '부동산',
|
||||
description: '청약 자격 비교, 일정 관리, 관심 단지 정보를 관리하는 공간',
|
||||
description: '청약 공고 자동 수집, 매칭, 프로필 기반 자격 분석',
|
||||
icon: <IconBuilding />,
|
||||
accent: '#f43f5e',
|
||||
},
|
||||
@@ -145,10 +144,6 @@ export const appRoutes = [
|
||||
path: 'realestate',
|
||||
element: <Subscription />,
|
||||
},
|
||||
{
|
||||
path: 'realestate/property',
|
||||
element: <RealEstate />,
|
||||
},
|
||||
{
|
||||
path: 'travel',
|
||||
element: <Travel />,
|
||||
|
||||
Reference in New Issue
Block a user