Files
bini-shorts-maker/frontend/src/pages/HomePage.jsx
kihong.kim 5c57f33903 feat: 타임라인 에디터 및 비디오 스튜디오 컴포넌트 추가
- 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>
2026-01-06 21:21:58 +09:00

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