feat(music): YouTube 탭 + 컴파일 기능 통합

- YouTube 탭 (영상 제작, 수익 추적, 시장 트렌드, 컴파일) 연결
- Create 탭 트랙 제목 직접 입력
- TrendsTab 히스토리 상세 + 메타데이터 수정
- 다중 트랙 FFmpeg concat 컴파일 서브탭 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 17:01:12 +09:00
4 changed files with 372 additions and 0 deletions

View File

@@ -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`);

View File

@@ -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;
}

View File

@@ -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 (
<div className="yt-content">
{/* 트랙 선택 패널 */}
<div className="yt-card yt-card--create">
<h3 className="yt-card__title">🎵 트랙 선택 (2 이상)</h3>
{library.length === 0 ? (
<p className="yt-empty">라이브러리에 트랙이 없습니다</p>
) : (
<div className="yt-compile-tracklist">
{library.map(t => {
const isSelected = !!selected.find(s => s.id === t.id);
return (
<div
key={t.id}
className={`yt-compile-track ${isSelected ? 'is-selected' : ''}`}
onClick={() => toggleTrack(t)}
>
<span className="yt-compile-track__check">{isSelected ? '✓' : ''}</span>
<span className="yt-compile-track__title">{t.title}</span>
{t.duration_sec && (
<span className="yt-compile-track__dur">{fmtDuration(t.duration_sec)}</span>
)}
</div>
);
})}
</div>
)}
</div>
{/* 순서 조정 + 설정 */}
{selected.length > 0 && (
<div className="yt-card">
<h3 className="yt-card__title">
📋 선택된 트랙 순서 ({selected.length}
{totalMin > 0 ? ` · 약 ${totalMin}` : ''})
</h3>
<div className="yt-compile-order">
{selected.map((t, i) => (
<div key={t.id} className="yt-compile-order__row">
<span className="yt-compile-order__num">{i + 1}</span>
<span className="yt-compile-order__title">{t.title}</span>
<div className="yt-compile-order__btns">
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => moveUp(i)} disabled={i === 0}></button>
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => moveDown(i)} disabled={i === selected.length - 1}></button>
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => remove(i)}></button>
</div>
</div>
))}
</div>
{/* 설정 */}
<div className="yt-compile-settings">
<div className="yt-field">
<label className="yt-field__label">크로스페이드 {crossfade}</label>
<input type="range" min="1" max="10" step="0.5"
value={crossfade}
onChange={e => setCrossfade(Number(e.target.value))}
className="yt-compile-slider"
/>
</div>
<div className="yt-field">
<label className="yt-field__label">컴파일 제목 (선택)</label>
<input type="text" className="yt-input"
placeholder={`컴파일 ${new Date().toLocaleDateString('ko-KR')}`}
value={title}
onChange={e => setTitle(e.target.value)}
maxLength={80}
/>
</div>
</div>
<button
type="button"
className="ms-btn ms-btn--primary yt-create-btn"
onClick={handleCreate}
disabled={selected.length < 2 || creating}
>
{creating ? '생성 중...' : `🎬 컴파일 생성 (${selected.length}곡)`}
</button>
</div>
)}
{/* 컴파일 작업 목록 */}
{jobs.length > 0 && (
<div className="yt-card">
<h3 className="yt-card__title">컴파일 작업</h3>
<div className="yt-project-list">
{jobs.map(j => (
<div key={j.id} className="yt-project-card">
<div className="yt-project-card__icon">🎵</div>
<div className="yt-project-card__info">
<div className="yt-project-card__title">{j.title || `컴파일 #${j.id}`}</div>
<div className="yt-project-card__meta">
{j.track_ids?.length ?? 0}
{j.duration_sec ? ` · ${fmtDuration(j.duration_sec)}` : ''}
{' · '}크로스페이드 {j.crossfade_sec}
</div>
</div>
{j.status === 'rendering' && (
<>
<span className="yt-status yt-status--rendering">처리중</span>
<div className="yt-progress-bar" style={{position:'absolute',bottom:0,left:0,right:0}}>
<div className="yt-progress-bar__fill" />
</div>
</>
)}
{j.status === 'done' && (
<>
<span className="yt-status yt-status--done"> 완료</span>
<button type="button"
className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => handleExport(j.id)}
disabled={exportingId === j.id}
>
{exportingId === j.id ? '...' : '↓ 내보내기'}
</button>
</>
)}
{j.status === 'failed' && (
<span className="yt-status yt-status--failed">실패</span>
)}
{j.status === 'pending' && (
<span className="yt-status yt-status--pending">대기</span>
)}
<button type="button"
className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => handleDelete(j.id)}
style={{marginLeft: 4}}
>🗑</button>
</div>
))}
</div>
</div>
)}
{/* 내보내기 패키지 */}
{exportData && (
<div className="yt-card yt-card--export">
<h3 className="yt-card__title"> 내보내기</h3>
<div className="yt-export-links">
<a href={exportData.mp4_url} download
className="ms-btn ms-btn--primary ms-btn--sm">
MP4 다운로드
</a>
</div>
<div className="yt-meta-preview">
<div className="yt-meta-preview__label">파일 정보</div>
<pre className="yt-meta-preview__content">
{JSON.stringify({
title: exportData.title,
duration: fmtDuration(exportData.duration_sec),
mp4_url: exportData.mp4_url,
}, null, 2)}
</pre>
</div>
</div>
)}
</div>
);
}

View File

@@ -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
>
📊 시장 트렌드
</button>
<button
type="button"
className={`yt-subtab ${subtab === 'compile' ? 'is-active' : ''}`}
onClick={() => setSubtab('compile')}
>
🎵 컴파일
</button>
</nav>
{subtab === 'video' && (
@@ -46,6 +54,7 @@ export default function YoutubeTab({ library, initialTrackId, onClearInitialTrac
)}
{subtab === 'revenue' && <RevenueTab />}
{subtab === 'trends' && <TrendsTab />}
{subtab === 'compile' && <CompileTab library={library} />}
</div>
);
}