diff --git a/src/api.js b/src/api.js index e522592..5f74a4f 100644 --- a/src/api.js +++ b/src/api.js @@ -646,3 +646,10 @@ export const getTrendReports = () => apiGet('/api/music/market/report') export const getMarketSuggestions = () => apiGet('/api/music/market/suggest'); export const triggerYoutubeResearch = () => apiPost('/api/agent-office/youtube/research', {}); +// ── Music Lab — Compile ────────────────────────────────── +export const createCompileJob = (data) => apiPost('/api/music/compile', data); +export const getCompileJobs = () => apiGet('/api/music/compiles'); +export const getCompileJob = (id) => apiGet(`/api/music/compile/${id}`); +export const deleteCompileJob = (id) => apiDelete(`/api/music/compile/${id}`); +export const exportCompileJob = (id) => apiGet(`/api/music/compile/${id}/export`); + diff --git a/src/pages/music/MusicStudio.css b/src/pages/music/MusicStudio.css index 8956cf5..480a955 100644 --- a/src/pages/music/MusicStudio.css +++ b/src/pages/music/MusicStudio.css @@ -3085,3 +3085,98 @@ .yt-table__row span:nth-child(4), .yt-table__row span:nth-child(5) { display: none; } } + +/* ── Compile subtab ── */ +.yt-compile-tracklist { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 280px; + overflow-y: auto; +} + +.yt-compile-track { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-radius: 6px; + cursor: pointer; + transition: background 0.1s; +} + +.yt-compile-track:hover { background: #1f2937; } +.yt-compile-track.is-selected { background: #0a2e18; } + +.yt-compile-track__check { + width: 16px; + font-size: 11px; + color: #22c55e; + flex-shrink: 0; +} + +.yt-compile-track__title { + flex: 1; + font-size: 12px; + color: #ccc; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.yt-compile-track__dur { + font-size: 10px; + color: #6b7280; + flex-shrink: 0; +} + +.yt-compile-order { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 14px; +} + +.yt-compile-order__row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + background: #1f2937; + border-radius: 6px; +} + +.yt-compile-order__num { + width: 20px; + font-size: 11px; + color: #6b7280; + text-align: center; + flex-shrink: 0; +} + +.yt-compile-order__title { + flex: 1; + font-size: 12px; + color: #ccc; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.yt-compile-order__btns { + display: flex; + gap: 3px; + flex-shrink: 0; +} + +.yt-compile-settings { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 12px; +} + +.yt-compile-slider { + width: 100%; + accent-color: #22c55e; +} diff --git a/src/pages/music/components/CompileTab.jsx b/src/pages/music/components/CompileTab.jsx new file mode 100644 index 0000000..d709d12 --- /dev/null +++ b/src/pages/music/components/CompileTab.jsx @@ -0,0 +1,261 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { + createCompileJob, getCompileJobs, deleteCompileJob, exportCompileJob, +} from '../../../api'; + +const fmtDuration = (sec) => { + if (!sec) return ''; + const m = Math.floor(sec / 60); + const s = Math.round(sec % 60); + return `${m}분 ${s}초`; +}; + +export default function CompileTab({ library }) { + const [jobs, setJobs] = useState([]); + const [selected, setSelected] = useState([]); // [{id, title, audio_url}] in order + const [crossfade, setCrossfade] = useState(3); + const [title, setTitle] = useState(''); + const [creating, setCreating] = useState(false); + const [exportData, setExportData] = useState(null); // {mp4_url, duration_sec, title} + const [exportingId, setExportingId] = useState(null); + const pollRef = useRef(null); + + const loadJobs = useCallback(async () => { + const res = await getCompileJobs().catch(() => ({ jobs: [] })); + setJobs(Array.isArray(res.jobs) ? res.jobs : []); + }, []); + + useEffect(() => { loadJobs(); }, [loadJobs]); + + // Poll while any job is rendering + useEffect(() => { + const hasRendering = jobs.some(j => j.status === 'rendering'); + if (hasRendering && !pollRef.current) { + pollRef.current = setInterval(loadJobs, 5000); + } else if (!hasRendering && pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + return () => { clearInterval(pollRef.current); pollRef.current = null; }; + }, [jobs, loadJobs]); + + const toggleTrack = (track) => { + setSelected(prev => { + const exists = prev.find(t => t.id === track.id); + if (exists) return prev.filter(t => t.id !== track.id); + return [...prev, { id: track.id, title: track.title, audio_url: track.audio_url }]; + }); + }; + + const moveUp = (idx) => setSelected(prev => { const a = [...prev]; [a[idx-1], a[idx]] = [a[idx], a[idx-1]]; return a; }); + const moveDown = (idx) => setSelected(prev => { const a = [...prev]; [a[idx], a[idx+1]] = [a[idx+1], a[idx]]; return a; }); + const remove = (idx) => setSelected(prev => prev.filter((_, i) => i !== idx)); + + const handleCreate = async () => { + if (selected.length < 2 || creating) return; + setCreating(true); + try { + await createCompileJob({ + title: title.trim() || `컴파일 ${new Date().toLocaleDateString('ko-KR')}`, + track_ids: selected.map(t => t.id), + crossfade_sec: crossfade, + }); + setSelected([]); + setTitle(''); + await loadJobs(); + } catch (e) { + console.error('createCompileJob:', e); + } finally { + setCreating(false); + } + }; + + const handleExport = async (jobId) => { + setExportingId(jobId); + try { + const data = await exportCompileJob(jobId); + setExportData(data); + } catch (e) { + console.error('exportCompileJob:', e); + } finally { + setExportingId(null); + } + }; + + const handleDelete = async (jobId) => { + if (!window.confirm('컴파일 영상을 삭제할까요?')) return; + await deleteCompileJob(jobId).catch(() => {}); + setJobs(prev => prev.filter(j => j.id !== jobId)); + if (exportData && exportData.id === jobId) setExportData(null); + }; + + const totalMin = selected.length > 0 + ? Math.round(selected.reduce((acc, t) => { + const match = library.find(l => l.id === t.id); + return acc + (match?.duration_sec ?? 180); + }, 0) / 60) + : 0; + + return ( +
라이브러리에 트랙이 없습니다
+ ) : ( +
+{JSON.stringify({
+ title: exportData.title,
+ duration: fmtDuration(exportData.duration_sec),
+ mp4_url: exportData.mp4_url,
+}, null, 2)}
+
+