feat(music): YouTube 탭 + 컴파일 기능 통합
- YouTube 탭 (영상 제작, 수익 추적, 시장 트렌드, 컴파일) 연결 - Create 탭 트랙 제목 직접 입력 - TrendsTab 히스토리 상세 + 메타데이터 수정 - 다중 트랙 FFmpeg concat 컴파일 서브탭 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -646,3 +646,10 @@ export const getTrendReports = () => apiGet('/api/music/market/report')
|
|||||||
export const getMarketSuggestions = () => apiGet('/api/music/market/suggest');
|
export const getMarketSuggestions = () => apiGet('/api/music/market/suggest');
|
||||||
export const triggerYoutubeResearch = () => apiPost('/api/agent-office/youtube/research', {});
|
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`);
|
||||||
|
|
||||||
|
|||||||
@@ -3085,3 +3085,98 @@
|
|||||||
.yt-table__row span:nth-child(4),
|
.yt-table__row span:nth-child(4),
|
||||||
.yt-table__row span:nth-child(5) { display: none; }
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
261
src/pages/music/components/CompileTab.jsx
Normal file
261
src/pages/music/components/CompileTab.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import VideoProjectsTab from './VideoProjectsTab';
|
import VideoProjectsTab from './VideoProjectsTab';
|
||||||
import RevenueTab from './RevenueTab';
|
import RevenueTab from './RevenueTab';
|
||||||
import TrendsTab from './TrendsTab';
|
import TrendsTab from './TrendsTab';
|
||||||
|
import CompileTab from './CompileTab';
|
||||||
|
|
||||||
export default function YoutubeTab({ library, initialTrackId, onClearInitialTrack }) {
|
export default function YoutubeTab({ library, initialTrackId, onClearInitialTrack }) {
|
||||||
const [subtab, setSubtab] = useState('video');
|
const [subtab, setSubtab] = useState('video');
|
||||||
@@ -35,6 +36,13 @@ export default function YoutubeTab({ library, initialTrackId, onClearInitialTrac
|
|||||||
>
|
>
|
||||||
📊 시장 트렌드
|
📊 시장 트렌드
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`yt-subtab ${subtab === 'compile' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setSubtab('compile')}
|
||||||
|
>
|
||||||
|
🎵 컴파일
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{subtab === 'video' && (
|
{subtab === 'video' && (
|
||||||
@@ -46,6 +54,7 @@ export default function YoutubeTab({ library, initialTrackId, onClearInitialTrac
|
|||||||
)}
|
)}
|
||||||
{subtab === 'revenue' && <RevenueTab />}
|
{subtab === 'revenue' && <RevenueTab />}
|
||||||
{subtab === 'trends' && <TrendsTab />}
|
{subtab === 'trends' && <TrendsTab />}
|
||||||
|
{subtab === 'compile' && <CompileTab library={library} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user