From f3b0b2c1094ac62d68fb3e9103a4a98b1b51f17f Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 1 May 2026 16:58:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(music):=20YouTube=20=ED=83=AD=20=EC=BB=B4?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=84=9C=EB=B8=8C=ED=83=AD=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(=EB=8B=A4=EC=A4=91=20=ED=8A=B8=EB=9E=99=20FFmpeg?= =?UTF-8?q?=20concat)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/api.js | 7 + src/pages/music/MusicStudio.css | 95 ++++++++ src/pages/music/components/CompileTab.jsx | 261 ++++++++++++++++++++++ src/pages/music/components/YoutubeTab.jsx | 9 + 4 files changed, 372 insertions(+) create mode 100644 src/pages/music/components/CompileTab.jsx 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 ( +
+ {/* 트랙 선택 패널 */} +
+

🎵 트랙 선택 (2개 이상)

+ {library.length === 0 ? ( +

라이브러리에 트랙이 없습니다

+ ) : ( +
+ {library.map(t => { + const isSelected = !!selected.find(s => s.id === t.id); + return ( +
toggleTrack(t)} + > + {isSelected ? '✓' : ''} + {t.title} + {t.duration_sec && ( + {fmtDuration(t.duration_sec)} + )} +
+ ); + })} +
+ )} +
+ + {/* 순서 조정 + 설정 */} + {selected.length > 0 && ( +
+

+ 📋 선택된 트랙 순서 ({selected.length}개 + {totalMin > 0 ? ` · 약 ${totalMin}분` : ''}) +

+
+ {selected.map((t, i) => ( +
+ {i + 1} + {t.title} +
+ + + +
+
+ ))} +
+ + {/* 설정 */} +
+
+ + setCrossfade(Number(e.target.value))} + className="yt-compile-slider" + /> +
+
+ + setTitle(e.target.value)} + maxLength={80} + /> +
+
+ + +
+ )} + + {/* 컴파일 작업 목록 */} + {jobs.length > 0 && ( +
+

컴파일 작업

+
+ {jobs.map(j => ( +
+
🎵
+
+
{j.title || `컴파일 #${j.id}`}
+
+ {j.track_ids?.length ?? 0}곡 + {j.duration_sec ? ` · ${fmtDuration(j.duration_sec)}` : ''} + {' · '}크로스페이드 {j.crossfade_sec}초 +
+
+ {j.status === 'rendering' && ( + <> + 처리중 +
+
+
+ + )} + {j.status === 'done' && ( + <> + ✓ 완료 + + + )} + {j.status === 'failed' && ( + 실패 + )} + {j.status === 'pending' && ( + 대기 + )} + +
+ ))} +
+
+ )} + + {/* 내보내기 패키지 */} + {exportData && ( +
+

↓ 내보내기

+ +
+
파일 정보
+
+{JSON.stringify({
+    title:    exportData.title,
+    duration: fmtDuration(exportData.duration_sec),
+    mp4_url:  exportData.mp4_url,
+}, null, 2)}
+                        
+
+
+ )} +
+ ); +} diff --git a/src/pages/music/components/YoutubeTab.jsx b/src/pages/music/components/YoutubeTab.jsx index 9def1b8..f822d93 100644 --- a/src/pages/music/components/YoutubeTab.jsx +++ b/src/pages/music/components/YoutubeTab.jsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import VideoProjectsTab from './VideoProjectsTab'; import RevenueTab from './RevenueTab'; import TrendsTab from './TrendsTab'; +import CompileTab from './CompileTab'; export default function YoutubeTab({ library, initialTrackId, onClearInitialTrack }) { const [subtab, setSubtab] = useState('video'); @@ -35,6 +36,13 @@ export default function YoutubeTab({ library, initialTrackId, onClearInitialTrac > 📊 시장 트렌드 + {subtab === 'video' && ( @@ -46,6 +54,7 @@ export default function YoutubeTab({ library, initialTrackId, onClearInitialTrac )} {subtab === 'revenue' && } {subtab === 'trends' && } + {subtab === 'compile' && }
); }