- TimelineEditor, VideoStudio 컴포넌트 신규 추가 - 백엔드 transcriber, video_processor 서비스 개선 - 프론트엔드 HomePage 리팩토링 및 스타일 업데이트 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
459 lines
16 KiB
JavaScript
459 lines
16 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { Link2, Sparkles, Loader2, RefreshCw, CheckCircle, XCircle, Clock, Film, FolderOpen, Trash2 } from 'lucide-react';
|
|
import { downloadApi, processApi, jobsApi, bgmApi, fontsApi } from '../api/client';
|
|
import VideoStudio from '../components/VideoStudio';
|
|
|
|
// Pipeline step definitions
|
|
const PIPELINE_STEPS = [
|
|
{ id: 'download', label: '다운로드', statuses: ['downloading'] },
|
|
{ id: 'trim', label: '트리밍', statuses: ['ready_for_trim'] },
|
|
{ id: 'transcribe', label: '음성인식', statuses: ['extracting_audio', 'noise_reduction', 'transcribing'] },
|
|
{ id: 'translate', label: '번역', statuses: ['translating'] },
|
|
{ id: 'review', label: '편집', statuses: ['awaiting_review', 'awaiting_subtitle'] },
|
|
{ id: 'render', label: '렌더링', statuses: ['processing'] },
|
|
];
|
|
|
|
export default function HomePage() {
|
|
// Core state
|
|
const [url, setUrl] = useState('');
|
|
const [currentJob, setCurrentJob] = useState(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState(null);
|
|
|
|
// Previous jobs
|
|
const [previousJobs, setPreviousJobs] = useState([]);
|
|
const [showPreviousJobs, setShowPreviousJobs] = useState(false);
|
|
|
|
// Resources
|
|
const [bgmList, setBgmList] = useState([]);
|
|
const [fontList, setFontList] = useState([]);
|
|
|
|
// Load BGM list
|
|
useEffect(() => {
|
|
const loadBgm = async () => {
|
|
try {
|
|
const res = await bgmApi.list();
|
|
setBgmList(res.data);
|
|
|
|
// Auto-download if no BGM
|
|
if (res.data.length === 0) {
|
|
try {
|
|
const downloadRes = await bgmApi.autoDownload(['upbeat', 'energetic'], 60);
|
|
if (downloadRes.data.success) {
|
|
const bgmRes = await bgmApi.list();
|
|
setBgmList(bgmRes.data);
|
|
}
|
|
} catch (e) {
|
|
console.log('BGM auto-download failed:', e.message);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load BGM:', err);
|
|
}
|
|
};
|
|
loadBgm();
|
|
}, []);
|
|
|
|
// Load fonts
|
|
useEffect(() => {
|
|
const loadFonts = async () => {
|
|
try {
|
|
const res = await fontsApi.list();
|
|
setFontList(res.data.fonts || []);
|
|
} catch (err) {
|
|
console.error('Failed to load fonts:', err);
|
|
}
|
|
};
|
|
loadFonts();
|
|
}, []);
|
|
|
|
// Load previous jobs
|
|
useEffect(() => {
|
|
const loadPreviousJobs = async () => {
|
|
try {
|
|
const res = await jobsApi.list(20);
|
|
setPreviousJobs(res.data || []);
|
|
} catch (err) {
|
|
console.error('Failed to load previous jobs:', err);
|
|
}
|
|
};
|
|
loadPreviousJobs();
|
|
}, []);
|
|
|
|
// Reload previous jobs when current job changes
|
|
const reloadPreviousJobs = async () => {
|
|
try {
|
|
const res = await jobsApi.list(20);
|
|
setPreviousJobs(res.data || []);
|
|
} catch (err) {
|
|
console.error('Failed to reload previous jobs:', err);
|
|
}
|
|
};
|
|
|
|
// Poll job status
|
|
useEffect(() => {
|
|
const stopPollingStatuses = ['completed', 'failed', 'awaiting_subtitle', 'awaiting_review', 'ready_for_trim'];
|
|
if (!currentJob || stopPollingStatuses.includes(currentJob.status)) {
|
|
return;
|
|
}
|
|
|
|
const interval = setInterval(async () => {
|
|
try {
|
|
const res = await jobsApi.get(currentJob.job_id);
|
|
setCurrentJob(res.data);
|
|
} catch (err) {
|
|
console.error('Poll failed:', err);
|
|
}
|
|
}, 1000);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [currentJob]);
|
|
|
|
// Submit URL
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
if (!url.trim()) return;
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const res = await downloadApi.start(url);
|
|
setCurrentJob({
|
|
job_id: res.data.job_id,
|
|
status: 'downloading',
|
|
progress: 0,
|
|
original_url: url,
|
|
});
|
|
setUrl('');
|
|
} catch (err) {
|
|
setError(err.response?.data?.detail || '다운로드 시작 실패');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// Reset job
|
|
const handleReset = () => {
|
|
setCurrentJob(null);
|
|
setError(null);
|
|
reloadPreviousJobs();
|
|
};
|
|
|
|
// Select previous job
|
|
const handleSelectJob = async (job) => {
|
|
try {
|
|
const res = await jobsApi.get(job.job_id);
|
|
setCurrentJob(res.data);
|
|
setShowPreviousJobs(false);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError('작업을 불러오는데 실패했습니다');
|
|
}
|
|
};
|
|
|
|
// Delete job
|
|
const handleDeleteJob = async (e, jobId) => {
|
|
e.stopPropagation();
|
|
if (!confirm('이 작업을 삭제하시겠습니까?')) return;
|
|
|
|
try {
|
|
await jobsApi.delete(jobId);
|
|
reloadPreviousJobs();
|
|
if (currentJob?.job_id === jobId) {
|
|
setCurrentJob(null);
|
|
}
|
|
} catch (err) {
|
|
setError('작업 삭제 실패');
|
|
}
|
|
};
|
|
|
|
// Start transcription
|
|
const handleStartTranscription = async (translationMode) => {
|
|
if (!currentJob) return;
|
|
setError(null);
|
|
|
|
try {
|
|
await processApi.startTranscription(currentJob.job_id, {
|
|
translationMode,
|
|
useVocalSeparation: false,
|
|
});
|
|
const res = await jobsApi.get(currentJob.job_id);
|
|
setCurrentJob(res.data);
|
|
} catch (err) {
|
|
setError(err.response?.data?.detail || '음성 인식 시작 실패');
|
|
}
|
|
};
|
|
|
|
// Trim video (with optional exclude regions for cutting middle sections)
|
|
const handleTrim = async (startTime, endTime, excludeRegions = []) => {
|
|
if (!currentJob) return;
|
|
setError(null);
|
|
|
|
try {
|
|
await processApi.trim(currentJob.job_id, startTime, endTime, false, excludeRegions);
|
|
const res = await jobsApi.get(currentJob.job_id);
|
|
setCurrentJob(res.data);
|
|
} catch (err) {
|
|
setError(err.response?.data?.detail || '트리밍 실패');
|
|
}
|
|
};
|
|
|
|
// Render final video
|
|
const handleRender = async (options) => {
|
|
if (!currentJob) return;
|
|
setError(null);
|
|
|
|
// Auto-select first BGM if none selected
|
|
let bgmId = options.bgmId;
|
|
if (!bgmId && bgmList.length > 0) {
|
|
bgmId = bgmList[0].id;
|
|
}
|
|
|
|
try {
|
|
await processApi.render(currentJob.job_id, {
|
|
bgmId,
|
|
bgmVolume: options.bgmVolume,
|
|
subtitleStyle: options.subtitleStyle,
|
|
keepOriginalAudio: false,
|
|
introText: options.introText,
|
|
introDuration: options.introDuration,
|
|
introFontSize: options.introFontSize,
|
|
introPosition: options.introPosition,
|
|
});
|
|
const res = await jobsApi.get(currentJob.job_id);
|
|
setCurrentJob(res.data);
|
|
} catch (err) {
|
|
setError(err.response?.data?.detail || '렌더링 시작 실패');
|
|
}
|
|
};
|
|
|
|
// Get current step for pipeline indicator
|
|
const getCurrentStep = () => {
|
|
if (!currentJob) return -1;
|
|
if (currentJob.status === 'completed') return PIPELINE_STEPS.length;
|
|
if (currentJob.status === 'failed') return -1;
|
|
|
|
for (let i = 0; i < PIPELINE_STEPS.length; i++) {
|
|
if (PIPELINE_STEPS[i].statuses.includes(currentJob.status)) {
|
|
return i;
|
|
}
|
|
}
|
|
return -1;
|
|
};
|
|
|
|
const currentStep = getCurrentStep();
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header with URL Input */}
|
|
<div className="card">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-xl font-semibold flex items-center gap-2">
|
|
<Sparkles className="text-red-500" size={24} />
|
|
영상 URL 입력
|
|
</h2>
|
|
{currentJob && (
|
|
<button
|
|
onClick={handleReset}
|
|
className="text-gray-400 hover:text-white text-sm px-3 py-1 border border-gray-700 rounded-lg hover:border-gray-600 transition-colors flex items-center gap-2"
|
|
>
|
|
<RefreshCw size={14} />
|
|
새 작업
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="flex gap-4">
|
|
<div className="flex-1 relative">
|
|
<Link2 className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500" size={20} />
|
|
<input
|
|
type="text"
|
|
value={url}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
placeholder="Douyin, Kuaishou, TikTok, YouTube URL 입력..."
|
|
className="input-field pl-12"
|
|
disabled={isLoading || currentJob}
|
|
/>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={isLoading || !url.trim() || currentJob}
|
|
className="btn-primary px-8 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isLoading ? <Loader2 size={20} className="animate-spin" /> : '시작'}
|
|
</button>
|
|
</form>
|
|
|
|
{/* Previous Jobs Toggle */}
|
|
{!currentJob && previousJobs.length > 0 && (
|
|
<div className="mt-4">
|
|
<button
|
|
onClick={() => setShowPreviousJobs(!showPreviousJobs)}
|
|
className="text-gray-400 hover:text-white text-sm flex items-center gap-2"
|
|
>
|
|
<FolderOpen size={16} />
|
|
기존 작업 불러오기 ({previousJobs.length}개)
|
|
<span className={`transition-transform ${showPreviousJobs ? 'rotate-180' : ''}`}>▼</span>
|
|
</button>
|
|
|
|
{showPreviousJobs && (
|
|
<div className="mt-3 max-h-64 overflow-y-auto space-y-2">
|
|
{previousJobs.map((job) => (
|
|
<div
|
|
key={job.job_id}
|
|
onClick={() => handleSelectJob(job)}
|
|
className="flex items-center justify-between p-3 bg-gray-800 hover:bg-gray-700 rounded-lg cursor-pointer transition-colors"
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`w-2 h-2 rounded-full ${
|
|
job.status === 'completed' ? 'bg-green-500' :
|
|
job.status === 'failed' ? 'bg-red-500' :
|
|
'bg-yellow-500'
|
|
}`} />
|
|
<span className="text-sm text-white truncate">
|
|
{job.original_url || job.job_id}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-gray-500 mt-1">
|
|
{job.status === 'completed' ? '완료' :
|
|
job.status === 'failed' ? '실패' :
|
|
job.status === 'awaiting_review' ? '편집 대기' :
|
|
job.status === 'ready_for_trim' ? '트리밍 대기' :
|
|
job.status}
|
|
{job.created_at && ` · ${new Date(job.created_at).toLocaleDateString()}`}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={(e) => handleDeleteJob(e, job.job_id)}
|
|
className="p-2 text-gray-500 hover:text-red-400 transition-colors"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Pipeline Progress */}
|
|
{currentJob && (
|
|
<div className="mt-4 pt-4 border-t border-gray-700">
|
|
<div className="flex items-center justify-between">
|
|
{PIPELINE_STEPS.map((step, index) => {
|
|
const isActive = index === currentStep;
|
|
const isCompleted = index < currentStep || currentJob.status === 'completed';
|
|
const isFailed = currentJob.status === 'failed' && index === currentStep;
|
|
|
|
return (
|
|
<React.Fragment key={step.id}>
|
|
<div className="flex flex-col items-center">
|
|
<div
|
|
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
|
|
isCompleted
|
|
? 'bg-green-600 text-white'
|
|
: isActive
|
|
? 'bg-blue-600 text-white'
|
|
: isFailed
|
|
? 'bg-red-600 text-white'
|
|
: 'bg-gray-700 text-gray-400'
|
|
}`}
|
|
>
|
|
{isCompleted ? (
|
|
<CheckCircle size={16} />
|
|
) : isActive ? (
|
|
<Loader2 size={16} className="animate-spin" />
|
|
) : isFailed ? (
|
|
<XCircle size={16} />
|
|
) : (
|
|
index + 1
|
|
)}
|
|
</div>
|
|
<span className={`text-xs mt-1 ${isActive ? 'text-blue-400' : 'text-gray-500'}`}>
|
|
{step.label}
|
|
</span>
|
|
</div>
|
|
{index < PIPELINE_STEPS.length - 1 && (
|
|
<div
|
|
className={`flex-1 h-0.5 mx-2 ${
|
|
index < currentStep ? 'bg-green-600' : 'bg-gray-700'
|
|
}`}
|
|
/>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
{/* Completed indicator */}
|
|
<div className="flex flex-col items-center">
|
|
<div
|
|
className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
|
currentJob.status === 'completed'
|
|
? 'bg-green-600 text-white'
|
|
: 'bg-gray-700 text-gray-400'
|
|
}`}
|
|
>
|
|
<Film size={16} />
|
|
</div>
|
|
<span className={`text-xs mt-1 ${currentJob.status === 'completed' ? 'text-green-400' : 'text-gray-500'}`}>
|
|
완료
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress text */}
|
|
{currentJob.progress > 0 && currentJob.progress < 100 && (
|
|
<div className="mt-3 flex items-center justify-center gap-2 text-sm text-gray-400">
|
|
<Clock size={14} />
|
|
진행률: {currentJob.progress}%
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="mt-4 p-4 bg-red-900/30 border border-red-800 rounded-lg text-red-400">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Main Editor */}
|
|
<VideoStudio
|
|
job={currentJob}
|
|
bgmList={bgmList}
|
|
fontList={fontList}
|
|
onJobUpdate={setCurrentJob}
|
|
onStartTranscription={handleStartTranscription}
|
|
onTrim={handleTrim}
|
|
onRender={handleRender}
|
|
/>
|
|
|
|
{/* Quick Guide when no job */}
|
|
{!currentJob && (
|
|
<div className="card bg-gray-800/50">
|
|
<h3 className="font-medium mb-3 flex items-center gap-2">
|
|
<Film size={18} className="text-purple-400" />
|
|
사용 방법
|
|
</h3>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 text-center text-sm">
|
|
{PIPELINE_STEPS.map((step, index) => (
|
|
<div key={step.id} className="p-3 bg-gray-900 rounded-lg">
|
|
<div className="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center mx-auto mb-2">
|
|
{index + 1}
|
|
</div>
|
|
<span className="text-gray-400">{step.label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="text-xs text-gray-500 mt-4 text-center">
|
|
지원: 抖音(Douyin), 快手(Kuaishou), TikTok, YouTube, Bilibili
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|