diff --git a/src/api.js b/src/api.js index d0a2645..d570b3d 100644 --- a/src/api.js +++ b/src/api.js @@ -674,3 +674,8 @@ export const getYoutubeAuthUrl = () => apiGet('/api/music/youtub export const getYoutubeStatus = () => apiGet('/api/music/youtube/status'); export const disconnectYoutube = () => apiPost('/api/music/youtube/disconnect'); +// === Batch generation === +export const startBatchGen = (payload) => apiPost('/api/music/generate-batch', payload); +export const getBatchJob = (id) => apiGet(`/api/music/generate-batch/${id}`); +export const listBatchJobs = (status='all') => apiGet(`/api/music/generate-batch?status=${status}`); + diff --git a/src/pages/music/MusicStudio.css b/src/pages/music/MusicStudio.css index 2354f9b..0241bbf 100644 --- a/src/pages/music/MusicStudio.css +++ b/src/pages/music/MusicStudio.css @@ -3417,3 +3417,116 @@ .psm-keyword-main small { display: block; color: var(--ms-muted, #a0a0b0); font-size: 11px; margin-top: 4px; } + +/* === Batch Generation Section === */ +.ms-batch-section { + margin: 16px 0; + padding: 12px 16px; + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--ms-line, #2a2a3a); + border-radius: 12px; +} +.ms-batch-section summary { + cursor: pointer; + font-weight: 600; + color: var(--ms-text, #f0f0f5); + user-select: none; + padding: 4px 0; +} +.ms-batch-section[open] summary { + margin-bottom: 8px; +} +.ms-batch-form { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px 0; +} +.ms-batch-form label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; + color: var(--ms-text, #f0f0f5); +} +.ms-batch-form select, +.ms-batch-form input[type="range"] { + padding: 6px 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--ms-line, #2a2a3a); + border-radius: 6px; + color: var(--ms-text, #f0f0f5); +} +.ms-batch-form input[type="range"] { + padding: 0; + background: transparent; +} +.ms-batch-checkbox { + flex-direction: row !important; + align-items: center; + gap: 8px; +} +.ms-batch-checkbox input { + width: auto; + margin: 0; +} +.ms-batch-estimate { + font-size: 12px; + color: var(--ms-muted, #a0a0b0); + margin: 4px 0; +} +.ms-batch-form button { + padding: 8px 16px; + background: rgba(56, 189, 248, 0.2); + color: #bae6fd; + border: 1px solid rgba(56, 189, 248, 0.4); + border-radius: 8px; + cursor: pointer; + font-size: 13px; + font-weight: bold; + align-self: flex-start; +} +.ms-batch-form button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.ms-batch-progress { + margin-top: 12px; + padding: 12px; + background: rgba(0, 0, 0, 0.35); + border-radius: 8px; + border: 1px solid var(--ms-line, #2a2a3a); +} +.ms-batch-header { + font-size: 13px; + margin-bottom: 8px; +} +.ms-batch-status--generating { color: var(--ms-accent, #38bdf8); } +.ms-batch-status--compiling { color: #fb923c; } +.ms-batch-status--piped { color: #86efac; } +.ms-batch-status--failed { color: #fca5a5; } +.ms-batch-status--cancelled { color: var(--ms-muted, #a0a0b0); } +.ms-batch-tracks { + padding-left: 24px; + font-size: 12px; + margin: 8px 0; +} +.ms-batch-tracks li { + margin: 2px 0; +} +.ms-batch-tracks li.done { + color: #86efac; +} +.ms-batch-tracks li.current { + color: var(--ms-accent, #38bdf8); + font-weight: bold; +} +.ms-batch-tracks li.pending { + color: var(--ms-muted, #a0a0b0); +} +.ms-batch-link { + margin-top: 6px; + font-size: 12px; + color: var(--ms-muted, #a0a0b0); +} diff --git a/src/pages/music/MusicStudio.jsx b/src/pages/music/MusicStudio.jsx index 5d35d44..41728a7 100644 --- a/src/pages/music/MusicStudio.jsx +++ b/src/pages/music/MusicStudio.jsx @@ -15,6 +15,8 @@ import { getTimestampedLyrics, generateStyleBoost, generateVideo, + startBatchGen, + getBatchJob, } from '../../api'; import PullToRefresh from '../../components/PullToRefresh'; import FAB from '../../components/FAB'; @@ -28,6 +30,7 @@ import StemModal from './components/StemModal'; import SyncedLyricsPlayer from './components/SyncedLyricsPlayer'; import RemixTab from './components/RemixTab'; import YoutubeTab from './components/YoutubeTab'; +import BatchProgress from './components/BatchProgress'; /* ───────────────────────────────────────────── 데이터 상수 @@ -592,6 +595,16 @@ export default function MusicStudio() { const pollRef = useRef(null); const taskIdRef = useRef(null); + /* ── 배치 생성 상태 ── */ + const [batchOpen, setBatchOpen] = useState(false); + const [batchGenre, setBatchGenre] = useState('lo-fi'); + const [batchCount, setBatchCount] = useState(10); + const [batchDuration, setBatchDuration] = useState(180); + const [batchAutoPipe, setBatchAutoPipe] = useState(true); + const [currentBatch, setCurrentBatch] = useState(null); + const [batchPolling, setBatchPolling] = useState(false); + const batchPollRef = useRef(null); + const activeGenre = GENRES.find((g) => g.id === genre); const accentColor = activeGenre?.color ?? '#f5a623'; @@ -651,6 +664,43 @@ export default function MusicStudio() { /* ── 언마운트 시 폴링 정리 ── */ useEffect(() => () => clearInterval(pollRef.current), []); + /* ── 배치 생성 시작 ── */ + const startBatch = async () => { + try { + const res = await startBatchGen({ + genre: batchGenre, + count: batchCount, + target_duration_sec: batchDuration, + auto_pipeline: batchAutoPipe, + }); + setCurrentBatch(res); + setBatchPolling(true); + } catch (e) { + alert(`배치 시작 실패: ${e.message || e}`); + } + }; + + /* ── 배치 폴링 ── */ + useEffect(() => { + if (!batchPolling || !currentBatch?.id) return; + const tick = async () => { + try { + const j = await getBatchJob(currentBatch.id); + if (j) { + setCurrentBatch(j); + if (['piped', 'failed', 'cancelled'].includes(j.status)) { + setBatchPolling(false); + // library 갱신 (새 트랙들 표시되도록) + if (typeof loadLibrary === 'function') loadLibrary(); + } + } + } catch { /* swallow */ } + }; + batchPollRef.current = setInterval(tick, 5000); + return () => clearInterval(batchPollRef.current); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [batchPolling, currentBatch?.id]); + /* ── helpers ── */ const toggleMood = (id) => setMoods((prev) => @@ -1276,6 +1326,43 @@ export default function MusicStudio() { )} + {/* Batch Generation Section */} +
setBatchOpen(e.currentTarget.open)}> + 🎲 배치 생성 (장르 → 1-10트랙 + 자동 영상) +
+ + + + +

+ 예상: 약 {Math.ceil(batchCount * 1.5)}–{batchCount * 2}분 · + {' '}비용 ~${(batchCount * 0.005 + (batchAutoPipe ? 0.05 : 0)).toFixed(2)} +

+ +
+ {currentBatch && } +
+ {/* Step 1: Genre */}
diff --git a/src/pages/music/components/BatchProgress.jsx b/src/pages/music/components/BatchProgress.jsx new file mode 100644 index 0000000..5ca15bf --- /dev/null +++ b/src/pages/music/components/BatchProgress.jsx @@ -0,0 +1,48 @@ +const STATUS_LABELS = { + queued: '대기 중', + generating: '음악 생성 중', + generated: '음악 완료, 컴파일 대기', + compiling: '컴파일 중', + piped: '영상 파이프라인 시작됨 — YouTube 탭 진행 탭에서 확인', + failed: '실패', + cancelled: '취소', +}; + +export default function BatchProgress({ batch }) { + if (!batch) return null; + const trackList = Array.from({ length: batch.count }, (_, i) => i + 1); + return ( +
+
+ 배치 #{batch.id} — {batch.genre} ·{' '} + {batch.completed}/{batch.count} 완료 ·{' '} + 상태: + {STATUS_LABELS[batch.status] || batch.status} + +
+ {batch.error &&
에러: {batch.error}
} +
    + {trackList.map(n => { + const completed = n <= batch.completed; + const current = n === batch.current_track_index && batch.status === 'generating'; + const tr = (batch.tracks || [])[n - 1]; + return ( +
  1. + {completed ? '✓' : current ? '⏳' : '○'} + {' '}Track {n}: {tr?.title || (current ? '생성 중...' : '대기')} +
  2. + ); + })} +
+ {batch.compile_job_id && ( +
📀 컴파일 #{batch.compile_job_id}
+ )} + {batch.pipeline_id && ( +
+ 🎬 영상 파이프라인 #{batch.pipeline_id} —{' '} + YouTube 탭 → 진행 탭에서 확인 +
+ )} +
+ ); +}