Initial commit: YouTube Shorts maker application

Features:
- Video download from TikTok/Douyin using yt-dlp
- Audio transcription with OpenAI Whisper
- GPT-4 translation (direct/summarize/rewrite modes)
- Subtitle generation with ASS format
- Video trimming with frame-accurate preview
- BGM integration with volume control
- Intro text overlay support
- Thumbnail generation with text overlay

Tech stack:
- Backend: FastAPI, Python 3.11+
- Frontend: React, Vite, TailwindCSS
- Video processing: FFmpeg
- AI: OpenAI Whisper, GPT-4

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kihong.kim
2026-01-03 21:38:34 +09:00
commit c3795138da
64 changed files with 13059 additions and 0 deletions

74
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,74 @@
import React, { useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
import { Video, List, Music, Settings } from 'lucide-react';
import HomePage from './pages/HomePage';
import JobsPage from './pages/JobsPage';
import BGMPage from './pages/BGMPage';
function NavLink({ to, icon: Icon, children }) {
const location = useLocation();
const isActive = location.pathname === to;
return (
<Link
to={to}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
isActive
? 'bg-red-600 text-white'
: 'text-gray-400 hover:text-white hover:bg-gray-800'
}`}
>
<Icon size={20} />
<span>{children}</span>
</Link>
);
}
function Layout({ children }) {
return (
<div className="min-h-screen flex flex-col">
{/* Header */}
<header className="bg-gray-900 border-b border-gray-800 px-6 py-4">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<Link to="/" className="flex items-center gap-3">
<Video className="text-red-500" size={32} />
<h1 className="text-xl font-bold">Shorts Maker</h1>
</Link>
<nav className="flex items-center gap-2">
<NavLink to="/" icon={Video}> 작업</NavLink>
<NavLink to="/jobs" icon={List}>작업 목록</NavLink>
<NavLink to="/bgm" icon={Music}>BGM 관리</NavLink>
</nav>
</div>
</header>
{/* Main Content */}
<main className="flex-1 px-6 py-8">
<div className="max-w-7xl mx-auto">
{children}
</div>
</main>
{/* Footer */}
<footer className="bg-gray-900 border-t border-gray-800 px-6 py-4 text-center text-gray-500 text-sm">
Shorts Maker - 중국 쇼츠 영상 한글 자막 변환기
</footer>
</div>
);
}
function App() {
return (
<Router>
<Layout>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/jobs" element={<JobsPage />} />
<Route path="/bgm" element={<BGMPage />} />
</Routes>
</Layout>
</Router>
);
}
export default App;

159
frontend/src/api/client.js Normal file
View File

@@ -0,0 +1,159 @@
import axios from 'axios';
const API_BASE = '/api';
const client = axios.create({
baseURL: API_BASE,
headers: {
'Content-Type': 'application/json',
},
});
// Download API
export const downloadApi = {
start: (url) => client.post('/download/', { url }),
getPlatforms: () => client.get('/download/platforms'),
};
// Process API
export const processApi = {
// Legacy: Full auto processing (not used in manual workflow)
start: (jobId, options = {}) =>
client.post('/process/', {
job_id: jobId,
bgm_id: options.bgmId || null,
bgm_volume: options.bgmVolume || 0.3,
subtitle_style: options.subtitleStyle || null,
keep_original_audio: options.keepOriginalAudio || false,
translation_mode: options.translationMode || null,
use_vocal_separation: options.useVocalSeparation || false,
}),
// === Step-by-step Processing API ===
// Skip trimming and proceed to transcription
skipTrim: (jobId) => client.post(`/process/${jobId}/skip-trim`),
// Start transcription step (audio extraction + STT + translation)
startTranscription: (jobId, options = {}) =>
client.post(`/process/${jobId}/start-transcription`, {
translation_mode: options.translationMode || 'rewrite',
use_vocal_separation: options.useVocalSeparation || false,
}),
// Render final video with subtitles and BGM
render: (jobId, options = {}) =>
client.post(`/process/${jobId}/render`, {
bgm_id: options.bgmId || null,
bgm_volume: options.bgmVolume || 0.3,
subtitle_style: options.subtitleStyle || null,
keep_original_audio: options.keepOriginalAudio || false,
// Intro text overlay (YouTube Shorts thumbnail)
intro_text: options.introText || null,
intro_duration: options.introDuration || 0.7,
intro_font_size: options.introFontSize || 100,
}),
// === Other APIs ===
// Re-run GPT translation
retranslate: (jobId) => client.post(`/process/${jobId}/retranslate`),
transcribe: (jobId) => client.post(`/process/${jobId}/transcribe`),
updateTranscript: (jobId, segments) =>
client.put(`/process/${jobId}/transcript`, segments),
// Continue processing for jobs with no audio
continue: (jobId, options = {}) =>
client.post(`/process/${jobId}/continue`, {
job_id: jobId,
bgm_id: options.bgmId || null,
bgm_volume: options.bgmVolume || 0.3,
subtitle_style: options.subtitleStyle || null,
keep_original_audio: options.keepOriginalAudio || false,
}),
// Add manual subtitles
addManualSubtitle: (jobId, segments) =>
client.post(`/process/${jobId}/manual-subtitle`, segments),
// Get video info for trimming
getVideoInfo: (jobId) => client.get(`/process/${jobId}/video-info`),
// Get frame at specific timestamp (for precise trimming preview)
getFrameUrl: (jobId, timestamp) => `${API_BASE}/process/${jobId}/frame?timestamp=${timestamp}`,
// Trim video (reprocess default: false for manual workflow)
trim: (jobId, startTime, endTime, reprocess = false) =>
client.post(`/process/${jobId}/trim`, {
start_time: startTime,
end_time: endTime,
reprocess,
}),
};
// Jobs API
export const jobsApi = {
list: (limit = 50) => client.get('/jobs/', { params: { limit } }),
get: (jobId) => client.get(`/jobs/${jobId}`),
delete: (jobId) => client.delete(`/jobs/${jobId}`),
downloadOutput: (jobId) => `${API_BASE}/jobs/${jobId}/download`,
downloadOriginal: (jobId) => `${API_BASE}/jobs/${jobId}/original`,
downloadSubtitle: (jobId, format = 'ass') =>
`${API_BASE}/jobs/${jobId}/subtitle?format=${format}`,
downloadThumbnail: (jobId) => `${API_BASE}/jobs/${jobId}/thumbnail`,
// Re-edit completed job (reset to awaiting_review)
reEdit: (jobId) => client.post(`/jobs/${jobId}/re-edit`),
};
// Thumbnail API
export const thumbnailApi = {
// Get suggested timestamps for frame selection
getTimestamps: (jobId, count = 5) =>
client.get(`/process/${jobId}/thumbnail-timestamps`, { params: { count } }),
// Generate catchphrase using GPT
generateCatchphrase: (jobId, style = 'homeshopping') =>
client.post(`/process/${jobId}/generate-catchphrase`, null, { params: { style } }),
// Generate thumbnail with text overlay
generate: (jobId, options = {}) =>
client.post(`/process/${jobId}/thumbnail`, null, {
params: {
timestamp: options.timestamp || 2.0,
style: options.style || 'homeshopping',
custom_text: options.customText || null,
font_size: options.fontSize || 80,
position: options.position || 'center',
},
}),
};
// Fonts API
export const fontsApi = {
list: () => client.get('/fonts/'),
recommend: (contentType) => client.get(`/fonts/recommend/${contentType}`),
categories: () => client.get('/fonts/categories'),
};
// BGM API
export const bgmApi = {
list: () => client.get('/bgm/'),
get: (bgmId) => client.get(`/bgm/${bgmId}`),
upload: (file, name) => {
const formData = new FormData();
formData.append('file', file);
if (name) formData.append('name', name);
return client.post('/bgm/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
delete: (bgmId) => client.delete(`/bgm/${bgmId}`),
// Auto-download BGM from Freesound
autoDownload: (keywords, maxDuration = 60) =>
client.post('/bgm/auto-download', {
keywords,
max_duration: maxDuration,
commercial_only: true,
}),
// Download default BGM tracks (force re-download if needed)
initializeDefaults: (force = false) =>
client.post(`/bgm/defaults/initialize?force=${force}`),
// Get default BGM list with status
getDefaultStatus: () => client.get('/bgm/defaults/status'),
};
export default client;

View File

@@ -0,0 +1,219 @@
import React, { useState, useEffect } from 'react';
import { Plus, Trash2, Save, Clock, PenLine, Volume2, XCircle } from 'lucide-react';
import { processApi } from '../api/client';
export default function ManualSubtitleInput({ job, onProcessWithSubtitle, onProcessWithoutSubtitle, onCancel }) {
const [segments, setSegments] = useState([]);
const [isSaving, setIsSaving] = useState(false);
const [videoDuration, setVideoDuration] = useState(60); // Default 60 seconds
// Initialize with one empty segment
useEffect(() => {
if (segments.length === 0) {
addSegment();
}
}, []);
const addSegment = () => {
const lastEnd = segments.length > 0 ? segments[segments.length - 1].end : 0;
setSegments([
...segments,
{
id: Date.now(),
start: lastEnd,
end: Math.min(lastEnd + 3, videoDuration),
text: '',
translated: '',
},
]);
};
const removeSegment = (id) => {
setSegments(segments.filter((seg) => seg.id !== id));
};
const updateSegment = (id, field, value) => {
setSegments(
segments.map((seg) =>
seg.id === id ? { ...seg, [field]: value } : seg
)
);
};
const formatTimeInput = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = (seconds % 60).toFixed(1);
return `${mins}:${secs.padStart(4, '0')}`;
};
const parseTimeInput = (timeStr) => {
const parts = timeStr.split(':');
if (parts.length === 2) {
const mins = parseInt(parts[0]) || 0;
const secs = parseFloat(parts[1]) || 0;
return mins * 60 + secs;
}
return parseFloat(timeStr) || 0;
};
const handleSaveSubtitles = async () => {
// Validate segments
const validSegments = segments.filter(
(seg) => seg.text.trim() || seg.translated.trim()
);
if (validSegments.length === 0) {
alert('최소 한 개의 자막을 입력해주세요.');
return;
}
setIsSaving(true);
try {
// Format segments for API
const formattedSegments = validSegments.map((seg) => ({
start: seg.start,
end: seg.end,
text: seg.text.trim() || seg.translated.trim(),
translated: seg.translated.trim() || seg.text.trim(),
}));
await processApi.addManualSubtitle(job.job_id, formattedSegments);
onProcessWithSubtitle?.(formattedSegments);
} catch (err) {
console.error('Failed to save subtitles:', err);
alert('자막 저장 실패: ' + (err.response?.data?.detail || err.message));
} finally {
setIsSaving(false);
}
};
const handleProcessWithoutSubtitle = () => {
onProcessWithoutSubtitle?.();
};
return (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<PenLine size={20} className="text-amber-400" />
<h3 className="font-medium text-white">자막 직접 입력</h3>
</div>
<span className="text-xs text-gray-500">
{segments.length} 세그먼트
</span>
</div>
<p className="text-sm text-gray-400 mb-4">
영상에 표시할 자막을 직접 입력하세요. 시작/종료 시간과 텍스트를 설정합니다.
</p>
{/* Segments List */}
<div className="space-y-3 max-h-96 overflow-y-auto mb-4">
{segments.map((segment, index) => (
<div
key={segment.id}
className="p-4 bg-gray-800/50 rounded-lg border border-gray-700"
>
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-gray-500 font-mono">#{index + 1}</span>
<button
onClick={() => removeSegment(segment.id)}
className="text-gray-500 hover:text-red-400 transition-colors"
disabled={segments.length === 1}
>
<Trash2 size={16} />
</button>
</div>
{/* Time Inputs */}
<div className="flex gap-4 mb-3">
<div className="flex-1">
<label className="text-xs text-gray-500 mb-1 block">
<Clock size={12} className="inline mr-1" />
시작 ()
</label>
<input
type="number"
step="0.1"
min="0"
value={segment.start}
onChange={(e) =>
updateSegment(segment.id, 'start', parseFloat(e.target.value) || 0)
}
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-amber-500"
/>
</div>
<div className="flex-1">
<label className="text-xs text-gray-500 mb-1 block">
<Clock size={12} className="inline mr-1" />
종료 ()
</label>
<input
type="number"
step="0.1"
min="0"
value={segment.end}
onChange={(e) =>
updateSegment(segment.id, 'end', parseFloat(e.target.value) || 0)
}
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-amber-500"
/>
</div>
</div>
{/* Text Input */}
<div>
<label className="text-xs text-gray-500 mb-1 block">자막 텍스트</label>
<textarea
value={segment.translated || segment.text}
onChange={(e) => updateSegment(segment.id, 'translated', e.target.value)}
placeholder="표시할 자막을 입력하세요..."
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-amber-500 resize-none"
rows={2}
/>
</div>
</div>
))}
</div>
{/* Add Segment Button */}
<button
onClick={addSegment}
className="w-full py-2 border border-dashed border-gray-700 rounded-lg text-gray-400 hover:text-white hover:border-gray-600 transition-colors flex items-center justify-center gap-2 mb-4"
>
<Plus size={16} />
자막 추가
</button>
{/* Action Buttons */}
<div className="space-y-3">
<div className="flex gap-3">
<button
onClick={handleSaveSubtitles}
disabled={isSaving || segments.every((s) => !s.text && !s.translated)}
className="flex-1 btn-primary py-3 flex items-center justify-center gap-2"
>
<PenLine size={18} />
{isSaving ? '저장 중...' : '자막으로 처리'}
</button>
<button
onClick={handleProcessWithoutSubtitle}
className="flex-1 btn-secondary py-3 flex items-center justify-center gap-2"
>
<Volume2 size={18} />
BGM만 추가
</button>
</div>
{onCancel && (
<button
onClick={onCancel}
className="w-full py-2 border border-gray-700 rounded-lg text-gray-400 hover:text-red-400 hover:border-red-800 transition-colors flex items-center justify-center gap-2"
>
<XCircle size={16} />
작업 취소
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,474 @@
import { useState } from 'react';
import {
Download,
Languages,
Film,
CheckCircle,
XCircle,
Loader,
ChevronDown,
ChevronUp,
Volume2,
VolumeX,
PenLine,
Music2,
Mic,
FileAudio,
Wand2,
RefreshCw,
} from 'lucide-react';
// 6-step pipeline
const PIPELINE_STEPS = [
{
key: 'downloading',
label: '영상 다운로드',
icon: Download,
description: 'yt-dlp로 원본 영상 다운로드',
statusKey: 'downloading',
},
{
key: 'extracting_audio',
label: '오디오 추출',
icon: FileAudio,
description: 'FFmpeg로 오디오 스트림 추출',
statusKey: 'extracting_audio',
},
{
key: 'noise_reduction',
label: '노이즈 제거',
icon: Wand2,
description: '배경 잡음 제거 및 음성 강화',
statusKey: 'noise_reduction',
},
{
key: 'transcribing',
label: 'Whisper STT',
icon: Mic,
description: 'OpenAI Whisper로 음성 인식',
statusKey: 'transcribing',
},
{
key: 'translating',
label: '요약 + 한국어 변환',
icon: Languages,
description: 'GPT로 요약 및 자연스러운 한국어 작성 (중국어만)',
statusKey: 'translating',
},
{
key: 'processing',
label: '영상 합성 + BGM',
icon: Film,
description: 'FFmpeg로 자막 합성 및 BGM 추가',
statusKey: 'processing',
},
];
// Status progression order
const STATUS_ORDER = [
'pending',
'downloading',
'ready_for_trim', // Download complete, ready for trimming
'trimming', // Video trimming step
'extracting_audio',
'noise_reduction',
'transcribing',
'awaiting_subtitle',
'translating',
'awaiting_review', // Script ready, waiting for user review
'processing',
'completed',
];
function getStepStatus(step, job) {
const currentIndex = STATUS_ORDER.indexOf(job.status);
const stepIndex = STATUS_ORDER.indexOf(step.statusKey);
if (job.status === 'failed') {
// Check if this step was the one that failed
if (stepIndex <= currentIndex) {
return stepIndex === currentIndex ? 'failed' : 'completed';
}
return 'pending';
}
if (job.status === 'completed') {
// Check if translation was skipped (non-Chinese)
if (step.key === 'translating' && job.detected_language && !['zh', 'zh-cn', 'zh-tw', 'chinese', 'mandarin'].includes(job.detected_language.toLowerCase())) {
return 'skipped';
}
return 'completed';
}
if (job.status === 'awaiting_subtitle') {
// Special handling for no-audio case
if (step.key === 'transcribing') {
return 'warning'; // Show as warning (no audio)
}
if (stepIndex < STATUS_ORDER.indexOf('transcribing')) {
return 'completed';
}
return 'pending';
}
if (stepIndex < currentIndex) {
return 'completed';
}
if (stepIndex === currentIndex) {
return 'active';
}
return 'pending';
}
function StepIcon({ step, status }) {
const Icon = step.icon;
const baseClasses = 'w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300';
switch (status) {
case 'completed':
return (
<div className={`${baseClasses} bg-green-500 shadow-lg shadow-green-500/30`}>
<CheckCircle size={20} className="text-white" />
</div>
);
case 'active':
return (
<div className={`${baseClasses} bg-red-500 shadow-lg shadow-red-500/30 animate-pulse`}>
<Loader size={20} className="text-white animate-spin" />
</div>
);
case 'failed':
return (
<div className={`${baseClasses} bg-red-600 shadow-lg shadow-red-600/30`}>
<XCircle size={20} className="text-white" />
</div>
);
case 'warning':
return (
<div className={`${baseClasses} bg-amber-500 shadow-lg shadow-amber-500/30`}>
<VolumeX size={20} className="text-white" />
</div>
);
case 'skipped':
return (
<div className={`${baseClasses} bg-gray-600 shadow-lg shadow-gray-600/20`}>
<CheckCircle size={20} className="text-gray-300" />
</div>
);
default:
return (
<div className={`${baseClasses} bg-gray-700`}>
<Icon size={20} className="text-gray-400" />
</div>
);
}
}
function StepCard({ step, status, index, isLast, job, onRetranslate }) {
const [isExpanded, setIsExpanded] = useState(false);
const [isRetranslating, setIsRetranslating] = useState(false);
const getStatusBadge = () => {
switch (status) {
case 'completed':
return <span className="text-xs px-2 py-0.5 bg-green-500/20 text-green-400 rounded-full">완료</span>;
case 'active':
return <span className="text-xs px-2 py-0.5 bg-red-500/20 text-red-400 rounded-full animate-pulse">진행 </span>;
case 'failed':
return <span className="text-xs px-2 py-0.5 bg-red-600/20 text-red-400 rounded-full">실패</span>;
case 'warning':
return (
<span className="text-xs px-2 py-0.5 bg-amber-500/20 text-amber-400 rounded-full">
{job.audio_status === 'singing_only' ? '노래만 감지' : '오디오 없음'}
</span>
);
case 'skipped':
return <span className="text-xs px-2 py-0.5 bg-gray-600/20 text-gray-400 rounded-full">스킵</span>;
default:
return <span className="text-xs px-2 py-0.5 bg-gray-700/50 text-gray-500 rounded-full">대기</span>;
}
};
const getTextColor = () => {
switch (status) {
case 'completed':
return 'text-green-400';
case 'active':
return 'text-white';
case 'failed':
return 'text-red-400';
case 'warning':
return 'text-amber-400';
case 'skipped':
return 'text-gray-400';
default:
return 'text-gray-500';
}
};
return (
<div className="flex items-start gap-4">
{/* Step Icon & Connector */}
<div className="flex flex-col items-center">
<StepIcon step={step} status={status} />
{!isLast && (
<div className={`w-0.5 h-16 mt-2 transition-colors duration-500 ${
status === 'completed' ? 'bg-green-500' : 'bg-gray-700'
}`} />
)}
</div>
{/* Step Content */}
<div className="flex-1 pb-6">
<div className="flex items-center gap-3 mb-1">
<span className="text-xs font-mono text-gray-500">0{index + 1}</span>
<h4 className={`font-semibold ${getTextColor()}`}>{step.label}</h4>
{getStatusBadge()}
</div>
<p className={`text-sm ${status === 'pending' ? 'text-gray-600' : 'text-gray-400'}`}>
{status === 'warning'
? job.audio_status === 'singing_only'
? '노래/배경음악만 감지됨 - 음성 없음'
: '오디오가 없거나 무음입니다'
: status === 'skipped'
? `중국어가 아닌 콘텐츠 (${job.detected_language || '알 수 없음'}) - GPT 스킵`
: step.description}
</p>
{/* Expandable Details */}
{status === 'completed' && step.key === 'transcribing' && job.transcript?.length > 0 && (
<div className="mt-3">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 text-xs text-gray-400 hover:text-white transition-colors"
>
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
인식 결과 ({job.transcript.length} 세그먼트)
</button>
{isExpanded && (
<div className="mt-2 p-3 bg-gray-800/50 rounded-lg border border-gray-700 max-h-64 overflow-y-auto">
{job.transcript.map((seg, i) => (
<div key={i} className="text-xs text-gray-300 mb-1">
<span className="text-gray-500 font-mono mr-2">
{formatTime(seg.start)}
</span>
{seg.text}
</div>
))}
</div>
)}
</div>
)}
{status === 'completed' && step.key === 'translating' && job.transcript?.some((s) => s.translated) && (
<div className="mt-3">
<div className="flex items-center gap-3">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 text-xs text-gray-400 hover:text-white transition-colors"
>
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
번역 결과 보기
</button>
{onRetranslate && (
<button
onClick={async () => {
setIsRetranslating(true);
await onRetranslate();
setIsRetranslating(false);
}}
disabled={isRetranslating}
className="flex items-center gap-1.5 text-xs px-2 py-1 bg-blue-600/20 text-blue-400 hover:bg-blue-600/30 rounded-md transition-colors disabled:opacity-50"
>
<RefreshCw size={12} className={isRetranslating ? 'animate-spin' : ''} />
{isRetranslating ? '재번역 중...' : 'GPT 재번역'}
</button>
)}
</div>
{isExpanded && (
<div className="mt-2 p-3 bg-gray-800/50 rounded-lg border border-gray-700 max-h-64 overflow-y-auto space-y-2">
{job.transcript
.filter((s) => s.translated)
.map((seg, i) => (
<div key={i} className="text-xs">
<div className="text-gray-500 line-through">{seg.text}</div>
<div className="text-white mt-0.5">{seg.translated}</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
export default function PipelineView({ job, onRetranslate }) {
const getOverallProgress = () => {
if (job.status === 'completed') return 100;
if (job.status === 'failed') return job.progress || 0;
const currentIndex = STATUS_ORDER.indexOf(job.status);
const totalSteps = PIPELINE_STEPS.length;
const stepProgress = (currentIndex / (totalSteps + 2)) * 100;
return Math.min(Math.round(stepProgress), 99);
};
const getStatusText = () => {
switch (job.status) {
case 'completed':
return '처리 완료';
case 'failed':
return '처리 실패';
case 'awaiting_subtitle':
return '자막 입력 대기';
case 'awaiting_review':
return '스크립트 확인 대기';
case 'ready_for_trim':
return '다운로드 완료';
case 'pending':
return '시작 대기';
case 'trimming':
return '영상 자르기 중...';
default:
return '처리 중...';
}
};
const getStatusColor = () => {
switch (job.status) {
case 'completed':
return 'text-green-400';
case 'failed':
return 'text-red-400';
case 'awaiting_subtitle':
return 'text-amber-400';
case 'awaiting_review':
return 'text-blue-400';
case 'ready_for_trim':
return 'text-blue-400';
case 'trimming':
return 'text-amber-400';
default:
return 'text-white';
}
};
return (
<div className="space-y-6">
{/* Progress Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`text-3xl font-bold ${getStatusColor()}`}>
{job.progress || getOverallProgress()}%
</div>
<div>
<div className={`font-medium ${getStatusColor()}`}>{getStatusText()}</div>
<div className="text-sm text-gray-500">
{job.original_url?.substring(0, 40)}...
</div>
</div>
</div>
</div>
{/* Progress Bar */}
<div className="relative h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className={`absolute inset-y-0 left-0 transition-all duration-700 ease-out ${
job.status === 'failed'
? 'bg-red-500'
: job.status === 'completed'
? 'bg-green-500'
: job.status === 'awaiting_subtitle'
? 'bg-amber-500'
: job.status === 'awaiting_review' || job.status === 'ready_for_trim'
? 'bg-blue-500'
: 'bg-gradient-to-r from-red-500 to-red-400'
}`}
style={{ width: `${job.progress || getOverallProgress()}%` }}
/>
{!['completed', 'failed', 'awaiting_subtitle', 'awaiting_review', 'ready_for_trim', 'pending'].includes(job.status) && (
<div
className="absolute inset-y-0 bg-white/20 animate-pulse"
style={{
left: `${(job.progress || getOverallProgress()) - 5}%`,
width: '5%',
}}
/>
)}
</div>
{/* Pipeline Steps */}
<div className="mt-8">
{PIPELINE_STEPS.map((step, index) => (
<StepCard
key={step.key}
step={step}
status={getStepStatus(step, job)}
index={index}
isLast={index === PIPELINE_STEPS.length - 1}
job={job}
onRetranslate={step.key === 'translating' ? onRetranslate : undefined}
/>
))}
</div>
{/* No Audio Alert */}
{job.status === 'awaiting_subtitle' && (
<div className="p-4 bg-amber-900/20 border border-amber-700/50 rounded-xl">
<div className="flex items-center gap-2 text-amber-400 font-medium mb-3">
{job.audio_status === 'singing_only' ? <Music2 size={18} /> : <VolumeX size={18} />}
{job.audio_status === 'no_audio_stream'
? '오디오 스트림 없음'
: job.audio_status === 'singing_only'
? '노래/배경음악만 감지됨'
: '무음 오디오'}
</div>
<p className="text-amber-200/80 text-sm mb-4">
{job.audio_status === 'no_audio_stream'
? '이 영상에는 오디오 트랙이 없습니다.'
: job.audio_status === 'singing_only'
? '음성 없이 배경음악/노래만 감지되었습니다.'
: '오디오가 거의 무음입니다.'}
</p>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-gray-800/50 rounded-lg border border-gray-700/50">
<div className="flex items-center gap-2 text-white font-medium text-sm mb-1">
<PenLine size={14} />
자막 직접 입력
</div>
<p className="text-xs text-gray-400">원하는 자막을 수동으로 입력</p>
</div>
<div className="p-3 bg-gray-800/50 rounded-lg border border-gray-700/50">
<div className="flex items-center gap-2 text-white font-medium text-sm mb-1">
<Volume2 size={14} />
BGM만 추가
</div>
<p className="text-xs text-gray-400">자막 없이 음악만 추가</p>
</div>
</div>
</div>
)}
{/* Error Display */}
{job.error && (
<div className="p-4 bg-red-900/20 border border-red-800/50 rounded-xl">
<div className="flex items-center gap-2 text-red-400 font-medium mb-2">
<XCircle size={18} />
오류 발생
</div>
<p className="text-red-300/80 text-sm font-mono">{job.error}</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { CheckCircle, XCircle, Loader } from 'lucide-react';
const STEPS = [
{ key: 'downloading', label: '영상 다운로드' },
{ key: 'transcribing', label: '음성 인식 (Whisper)' },
{ key: 'translating', label: '한글 번역 (GPT)' },
{ key: 'processing', label: '영상 처리 (FFmpeg)' },
];
const STATUS_ORDER = ['pending', 'downloading', 'transcribing', 'translating', 'processing', 'completed'];
export default function ProcessingStatus({ status, progress, error }) {
const currentIndex = STATUS_ORDER.indexOf(status);
return (
<div className="space-y-4">
{/* Progress bar */}
<div className="relative">
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-500 ${
status === 'failed' ? 'bg-red-500' : 'bg-red-500'
}`}
style={{ width: `${progress}%` }}
/>
</div>
<div className="mt-2 flex justify-between text-sm text-gray-500">
<span>{progress}%</span>
<span>
{status === 'completed' && '완료!'}
{status === 'failed' && '실패'}
{!['completed', 'failed'].includes(status) && '처리 중...'}
</span>
</div>
</div>
{/* Steps */}
<div className="space-y-3">
{STEPS.map((step, index) => {
const stepIndex = STATUS_ORDER.indexOf(step.key);
const isComplete = currentIndex > stepIndex || status === 'completed';
const isCurrent = status === step.key;
const isFailed = status === 'failed' && isCurrent;
return (
<div
key={step.key}
className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${
isCurrent ? 'bg-gray-800' : ''
}`}
>
<div className="flex-shrink-0">
{isComplete ? (
<CheckCircle className="text-green-500" size={20} />
) : isFailed ? (
<XCircle className="text-red-500" size={20} />
) : isCurrent ? (
<Loader className="text-red-500 animate-spin" size={20} />
) : (
<div className="w-5 h-5 rounded-full border-2 border-gray-600" />
)}
</div>
<span
className={`${
isComplete
? 'text-green-500'
: isFailed
? 'text-red-500'
: isCurrent
? 'text-white'
: 'text-gray-500'
}`}
>
{step.label}
</span>
{isCurrent && !isFailed && (
<span className="text-sm text-gray-500 ml-auto">진행 ...</span>
)}
</div>
);
})}
</div>
{/* Error message */}
{error && (
<div className="p-4 bg-red-900/30 border border-red-800 rounded-lg text-red-400">
<strong>오류:</strong> {error}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,130 @@
import React, { useState } from 'react';
import { Edit2, Save, X } from 'lucide-react';
import { processApi } from '../api/client';
export default function SubtitleEditor({ segments, jobId, onUpdate, compact = false }) {
const [editingIndex, setEditingIndex] = useState(null);
const [editText, setEditText] = useState('');
const [isSaving, setIsSaving] = useState(false);
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 100);
return `${mins}:${String(secs).padStart(2, '0')}.${String(ms).padStart(2, '0')}`;
};
const handleEdit = (index) => {
setEditingIndex(index);
setEditText(segments[index].translated || segments[index].text);
};
const handleSave = async () => {
if (editingIndex === null) return;
const updatedSegments = [...segments];
updatedSegments[editingIndex] = {
...updatedSegments[editingIndex],
translated: editText,
};
setIsSaving(true);
try {
await processApi.updateTranscript(jobId, updatedSegments);
onUpdate(updatedSegments);
setEditingIndex(null);
} catch (err) {
console.error('Failed to save:', err);
alert('저장 실패');
} finally {
setIsSaving(false);
}
};
const handleCancel = () => {
setEditingIndex(null);
setEditText('');
};
// Show all segments (scrollable)
const displayedSegments = segments;
return (
<div>
{!compact && (
<>
<h3 className="font-medium mb-4">자막 편집</h3>
<p className="text-sm text-gray-500 mb-4">
번역된 자막을 수정할 있습니다. 클릭하여 편집하세요.
</p>
</>
)}
<div className={`space-y-2 overflow-y-auto ${compact ? 'max-h-64' : 'max-h-[500px]'}`}>
{displayedSegments.map((segment, index) => (
<div
key={index}
className={`p-3 rounded-lg border transition-colors ${
editingIndex === index
? 'border-red-500 bg-gray-800'
: 'border-gray-800 hover:border-gray-700'
}`}
>
<div className="flex items-start gap-3">
<span className="text-xs text-gray-500 font-mono whitespace-nowrap pt-1">
{formatTime(segment.start)}
</span>
{editingIndex === index ? (
<div className="flex-1 space-y-2">
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-red-500"
rows={2}
autoFocus
/>
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={isSaving}
className="btn-primary py-1 px-3 text-sm flex items-center gap-1"
>
<Save size={14} />
{isSaving ? '저장 중...' : '저장'}
</button>
<button
onClick={handleCancel}
className="btn-secondary py-1 px-3 text-sm flex items-center gap-1"
>
<X size={14} />
취소
</button>
</div>
</div>
) : (
<div
className="flex-1 cursor-pointer group"
onClick={() => handleEdit(index)}
>
<div className="text-sm">
{segment.translated || segment.text}
</div>
{segment.translated && segment.text !== segment.translated && (
<div className="text-xs text-gray-500 mt-1">
원문: {segment.text}
</div>
)}
<Edit2
size={14}
className="text-gray-600 group-hover:text-gray-400 mt-1"
/>
</div>
)}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,308 @@
import React, { useState, useEffect, useRef } from 'react';
import { Image, Download, Wand2, RefreshCw } from 'lucide-react';
import { thumbnailApi, jobsApi } from '../api/client';
const THUMBNAIL_STYLES = [
{ id: 'homeshopping', name: '홈쇼핑', desc: '강렬한 어필' },
{ id: 'viral', name: '바이럴', desc: '호기심 유발' },
{ id: 'informative', name: '정보성', desc: '명확한 전달' },
];
export default function ThumbnailGenerator({ jobId, onClose }) {
const videoRef = useRef(null);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(2.0);
const [thumbnailStyle, setThumbnailStyle] = useState('homeshopping');
const [thumbnailText, setThumbnailText] = useState('');
const [fontSize, setFontSize] = useState(80);
const [position, setPosition] = useState('center');
const [isGenerating, setIsGenerating] = useState(false);
const [isGeneratingText, setIsGeneratingText] = useState(false);
const [generatedUrl, setGeneratedUrl] = useState(null);
const [error, setError] = useState(null);
// Load video metadata
useEffect(() => {
if (videoRef.current) {
videoRef.current.addEventListener('loadedmetadata', () => {
setDuration(videoRef.current.duration);
// Start at 10% of video
const startTime = videoRef.current.duration * 0.1;
setCurrentTime(startTime);
videoRef.current.currentTime = startTime;
});
}
}, []);
// Seek video when currentTime changes
useEffect(() => {
if (videoRef.current && videoRef.current.readyState >= 2) {
videoRef.current.currentTime = currentTime;
}
}, [currentTime]);
const handleSliderChange = (e) => {
const time = parseFloat(e.target.value);
setCurrentTime(time);
};
const handleGenerateText = async () => {
setIsGeneratingText(true);
setError(null);
try {
const res = await thumbnailApi.generateCatchphrase(jobId, thumbnailStyle);
setThumbnailText(res.data.catchphrase || '');
} catch (err) {
setError(err.response?.data?.detail || '문구 생성 실패');
} finally {
setIsGeneratingText(false);
}
};
const handleGenerate = async () => {
setIsGenerating(true);
setError(null);
try {
const res = await thumbnailApi.generate(jobId, {
timestamp: currentTime,
style: thumbnailStyle,
customText: thumbnailText || null,
fontSize: fontSize,
position: position,
});
setGeneratedUrl(`${jobsApi.downloadThumbnail(jobId)}?t=${Date.now()}`);
if (res.data.text && !thumbnailText) {
setThumbnailText(res.data.text);
}
} catch (err) {
setError(err.response?.data?.detail || '썸네일 생성 실패');
} finally {
setIsGenerating(false);
}
};
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// Calculate text position style
const getTextPositionStyle = () => {
const base = {
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
textAlign: 'center',
fontWeight: 'bold',
color: 'white',
textShadow: `
-2px -2px 0 #000,
2px -2px 0 #000,
-2px 2px 0 #000,
2px 2px 0 #000,
-3px 0 0 #000,
3px 0 0 #000,
0 -3px 0 #000,
0 3px 0 #000
`,
fontSize: `${fontSize * 0.5}px`, // Scale down for preview
maxWidth: '90%',
wordBreak: 'keep-all',
};
switch (position) {
case 'top':
return { ...base, top: '15%' };
case 'bottom':
return { ...base, bottom: '20%' };
default: // center
return { ...base, top: '50%', transform: 'translate(-50%, -50%)' };
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold text-pink-400 flex items-center gap-2">
<Image size={20} />
썸네일 생성
</h3>
{onClose && (
<button
onClick={onClose}
className="text-gray-400 hover:text-white text-sm"
>
닫기
</button>
)}
</div>
{/* Video Preview with Text Overlay */}
<div className="relative bg-black rounded-lg overflow-hidden">
<video
ref={videoRef}
src={jobsApi.downloadOriginal(jobId)}
className="w-full h-auto"
muted
playsInline
preload="metadata"
/>
{/* Text Overlay Preview */}
{thumbnailText && (
<div style={getTextPositionStyle()}>
{thumbnailText}
</div>
)}
{/* Time indicator */}
<div className="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded">
{formatTime(currentTime)}
</div>
</div>
{/* Timeline Slider */}
<div className="space-y-1">
<label className="text-sm text-gray-400">프레임 선택</label>
<input
type="range"
min={0}
max={duration || 30}
step={0.1}
value={currentTime}
onChange={handleSliderChange}
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-pink-500"
/>
<div className="flex justify-between text-xs text-gray-500">
<span>0:00</span>
<span className="text-pink-400 font-medium">{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
{/* Style Selection */}
<div>
<label className="block text-sm text-gray-400 mb-2">문구 스타일</label>
<div className="grid grid-cols-3 gap-2">
{THUMBNAIL_STYLES.map((style) => (
<button
key={style.id}
onClick={() => setThumbnailStyle(style.id)}
className={`p-2 rounded-lg border text-sm transition-colors ${
thumbnailStyle === style.id
? 'border-pink-500 bg-pink-500/10'
: 'border-gray-700 hover:border-gray-600'
}`}
>
<div>{style.name}</div>
<div className="text-xs text-gray-500">{style.desc}</div>
</button>
))}
</div>
</div>
{/* Text Input */}
<div>
<label className="block text-sm text-gray-400 mb-2">썸네일 문구</label>
<div className="flex gap-2">
<input
type="text"
value={thumbnailText}
onChange={(e) => setThumbnailText(e.target.value)}
placeholder="문구를 입력하거나 자동 생성..."
maxLength={20}
className="flex-1 p-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm focus:outline-none focus:border-pink-500"
/>
<button
onClick={handleGenerateText}
disabled={isGeneratingText}
className="px-3 py-2 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 rounded-lg transition-colors disabled:opacity-50"
title="GPT로 문구 생성"
>
{isGeneratingText ? (
<RefreshCw size={18} className="animate-spin" />
) : (
<Wand2 size={18} />
)}
</button>
</div>
<div className="text-xs text-gray-500 mt-1">
{thumbnailText.length}/20 (최대 15 권장)
</div>
</div>
{/* Font Size & Position */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-2">
글자 크기: {fontSize}px
</label>
<input
type="range"
min="40"
max="120"
value={fontSize}
onChange={(e) => setFontSize(parseInt(e.target.value))}
className="w-full accent-pink-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">위치</label>
<div className="flex gap-1">
{['top', 'center', 'bottom'].map((pos) => (
<button
key={pos}
onClick={() => setPosition(pos)}
className={`flex-1 p-2 rounded border text-xs transition-colors ${
position === pos
? 'border-pink-500 bg-pink-500/10'
: 'border-gray-700 hover:border-gray-600'
}`}
>
{pos === 'top' ? '상단' : pos === 'center' ? '중앙' : '하단'}
</button>
))}
</div>
</div>
</div>
{/* Error Message */}
{error && (
<div className="p-2 bg-red-900/30 border border-red-800 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
{/* Generate Button */}
<button
onClick={handleGenerate}
disabled={isGenerating}
className="w-full py-3 bg-gradient-to-r from-pink-600 to-purple-600 hover:from-pink-500 hover:to-purple-500 rounded-lg font-medium flex items-center justify-center gap-2 disabled:opacity-50 transition-all"
>
<Image size={18} className={isGenerating ? 'animate-pulse' : ''} />
{isGenerating ? '생성 중...' : '썸네일 생성'}
</button>
{/* Generated Thumbnail Preview & Download */}
{generatedUrl && (
<div className="space-y-3 pt-4 border-t border-gray-700">
<h4 className="text-sm text-gray-400">생성된 썸네일</h4>
<div className="border border-gray-700 rounded-lg overflow-hidden">
<img
src={generatedUrl}
alt="Generated Thumbnail"
className="w-full h-auto"
/>
</div>
<a
href={generatedUrl}
download={`thumbnail_${jobId}.jpg`}
className="w-full py-2 border border-pink-700/50 bg-pink-900/20 hover:bg-pink-900/30 rounded-lg transition-colors flex items-center justify-center gap-2 text-pink-300"
>
<Download size={18} />
썸네일 다운로드
</a>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { Download, FileText, Film } from 'lucide-react';
import { jobsApi } from '../api/client';
export default function VideoPreview({ videoUrl, jobId }) {
return (
<div className="space-y-4">
<h3 className="font-medium flex items-center gap-2">
<Film size={20} className="text-green-500" />
처리 완료!
</h3>
{/* Video Player */}
<div className="relative bg-black rounded-lg overflow-hidden aspect-[9/16] max-w-sm mx-auto">
<video
src={videoUrl}
controls
className="w-full h-full object-contain"
poster=""
>
브라우저가 비디오를 지원하지 않습니다.
</video>
</div>
{/* Download Buttons */}
<div className="flex flex-wrap gap-3 justify-center">
<a
href={jobsApi.downloadOutput(jobId)}
download
className="btn-primary flex items-center gap-2"
>
<Download size={18} />
영상 다운로드
</a>
<a
href={jobsApi.downloadOriginal(jobId)}
download
className="btn-secondary flex items-center gap-2"
>
<Film size={18} />
원본 영상
</a>
<a
href={jobsApi.downloadSubtitle(jobId, 'srt')}
download
className="btn-secondary flex items-center gap-2"
>
<FileText size={18} />
자막 (SRT)
</a>
<a
href={jobsApi.downloadSubtitle(jobId, 'ass')}
download
className="btn-secondary flex items-center gap-2"
>
<FileText size={18} />
자막 (ASS)
</a>
</div>
<p className="text-sm text-gray-500 text-center">
영상을 YouTube Shorts에 업로드하세요!
</p>
</div>
);
}

View File

@@ -0,0 +1,498 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Scissors, Play, Pause, RotateCcw, Check, AlertCircle, Image, ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight } from 'lucide-react';
import { processApi, jobsApi } from '../api/client';
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 10);
return `${mins}:${secs.toString().padStart(2, '0')}.${ms}`;
}
function formatTimeDetailed(seconds) {
return seconds.toFixed(2);
}
export default function VideoTrimmer({ jobId, onTrimComplete, onCancel }) {
const videoRef = useRef(null);
const [videoInfo, setVideoInfo] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [trimming, setTrimming] = useState(false);
// Trim range state
const [startTime, setStartTime] = useState(0);
const [endTime, setEndTime] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [cacheBuster, setCacheBuster] = useState(Date.now());
// Frame preview state
const [startFrameUrl, setStartFrameUrl] = useState(null);
const [endFrameUrl, setEndFrameUrl] = useState(null);
const [loadingStartFrame, setLoadingStartFrame] = useState(false);
const [loadingEndFrame, setLoadingEndFrame] = useState(false);
// Load video info
useEffect(() => {
const loadVideoInfo = async () => {
try {
setLoading(true);
const response = await processApi.getVideoInfo(jobId);
setVideoInfo(response.data);
setEndTime(response.data.duration);
} catch (err) {
setError(err.response?.data?.detail || 'Failed to load video info');
} finally {
setLoading(false);
}
};
loadVideoInfo();
}, [jobId]);
// Update current time while playing
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleTimeUpdate = () => {
setCurrentTime(video.currentTime);
// Stop at end time
if (video.currentTime >= endTime) {
video.pause();
video.currentTime = startTime;
setIsPlaying(false);
}
};
video.addEventListener('timeupdate', handleTimeUpdate);
return () => video.removeEventListener('timeupdate', handleTimeUpdate);
}, [startTime, endTime]);
// Load frame previews with debouncing
const loadStartFrame = useCallback(async (time) => {
setLoadingStartFrame(true);
try {
const url = processApi.getFrameUrl(jobId, time);
setStartFrameUrl(`${url}&t=${Date.now()}`);
} finally {
setLoadingStartFrame(false);
}
}, [jobId]);
const loadEndFrame = useCallback(async (time) => {
setLoadingEndFrame(true);
try {
const url = processApi.getFrameUrl(jobId, time);
setEndFrameUrl(`${url}&t=${Date.now()}`);
} finally {
setLoadingEndFrame(false);
}
}, [jobId]);
// Load initial frames when video info is loaded
useEffect(() => {
if (videoInfo) {
loadStartFrame(0);
loadEndFrame(videoInfo.duration - 0.1);
}
}, [videoInfo, loadStartFrame, loadEndFrame]);
const handlePlayPause = () => {
const video = videoRef.current;
if (!video) return;
if (isPlaying) {
video.pause();
} else {
if (video.currentTime < startTime || video.currentTime >= endTime) {
video.currentTime = startTime;
}
video.play();
}
setIsPlaying(!isPlaying);
};
const handleSeek = (time) => {
const video = videoRef.current;
if (!video) return;
video.currentTime = time;
setCurrentTime(time);
};
const handleStartChange = (value) => {
const newStart = Math.max(0, Math.min(parseFloat(value) || 0, endTime - 0.1));
setStartTime(newStart);
handleSeek(newStart);
loadStartFrame(newStart);
};
const handleEndChange = (value) => {
const maxDuration = videoInfo?.duration || 0;
const newEnd = Math.min(maxDuration, Math.max(parseFloat(value) || 0, startTime + 0.1));
setEndTime(newEnd);
handleSeek(Math.max(0, newEnd - 0.5));
loadEndFrame(newEnd - 0.1);
};
const adjustStart = (delta) => {
const newStart = Math.max(0, Math.min(startTime + delta, endTime - 0.1));
setStartTime(newStart);
handleSeek(newStart);
loadStartFrame(newStart);
};
const adjustEnd = (delta) => {
const maxDuration = videoInfo?.duration || 0;
const newEnd = Math.min(maxDuration, Math.max(endTime + delta, startTime + 0.1));
setEndTime(newEnd);
handleSeek(Math.max(0, newEnd - 0.5));
loadEndFrame(newEnd - 0.1);
};
const handleTrim = async () => {
if (trimming) return;
try {
setTrimming(true);
// reprocess=false for manual workflow - user will manually proceed to next step
const response = await processApi.trim(jobId, startTime, endTime, false);
if (response.data.success) {
// Update cache buster to reload video with new trimmed version
setCacheBuster(Date.now());
// Update video info with new duration
if (response.data.new_duration) {
setVideoInfo(prev => ({ ...prev, duration: response.data.new_duration }));
setEndTime(response.data.new_duration);
setStartTime(0);
// Reload frame previews
loadStartFrame(0);
loadEndFrame(response.data.new_duration - 0.1);
}
onTrimComplete?.(response.data);
} else {
setError(response.data.message);
}
} catch (err) {
setError(err.response?.data?.detail || 'Trim failed');
} finally {
setTrimming(false);
}
};
const handleReset = () => {
if (videoInfo) {
setStartTime(0);
setEndTime(videoInfo.duration);
handleSeek(0);
loadStartFrame(0);
loadEndFrame(videoInfo.duration - 0.1);
}
};
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-500"></div>
<span className="ml-3 text-gray-400">Loading video...</span>
</div>
);
}
if (error) {
return (
<div className="p-4 bg-red-900/20 border border-red-800/50 rounded-xl">
<div className="flex items-center gap-2 text-red-400">
<AlertCircle size={18} />
<span>{error}</span>
</div>
<button
onClick={onCancel}
className="mt-3 px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600"
>
Close
</button>
</div>
);
}
const trimmedDuration = endTime - startTime;
// Add cache buster to ensure video is reloaded after trimming
const videoUrl = `${jobsApi.downloadOriginal(jobId)}?t=${cacheBuster}`;
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Scissors className="text-red-400" size={20} />
<h3 className="font-semibold text-white">Video Trimmer</h3>
</div>
<div className="text-sm text-gray-400">
Original: {formatTime(videoInfo?.duration || 0)}
</div>
</div>
{/* Video Preview */}
<div className="relative aspect-[9/16] max-h-[400px] bg-black rounded-lg overflow-hidden mx-auto">
<video
key={cacheBuster}
ref={videoRef}
src={videoUrl}
className="w-full h-full object-contain"
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
/>
{/* Play button overlay */}
<button
onClick={handlePlayPause}
className="absolute inset-0 flex items-center justify-center bg-black/30 opacity-0 hover:opacity-100 transition-opacity"
>
{isPlaying ? (
<Pause className="text-white" size={48} />
) : (
<Play className="text-white" size={48} />
)}
</button>
</div>
{/* Frame Preview Section */}
<div className="flex justify-between gap-4">
{/* Start Frame Preview */}
<div className="space-y-2">
<div className="text-sm text-gray-400 flex items-center gap-1">
<Image size={14} />
시작 프레임
</div>
<div className="relative w-[68px] h-[120px] bg-gray-800 rounded-lg overflow-hidden border-2 border-green-500/50">
{loadingStartFrame ? (
<div className="absolute inset-0 flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-green-500"></div>
</div>
) : startFrameUrl ? (
<img src={startFrameUrl} alt="Start frame" className="w-full h-full object-cover" />
) : null}
</div>
</div>
{/* End Frame Preview */}
<div className="space-y-2">
<div className="text-sm text-gray-400 flex items-center gap-1 justify-end">
<Image size={14} />
종료 프레임
</div>
<div className="relative w-[68px] h-[120px] bg-gray-800 rounded-lg overflow-hidden border-2 border-red-500/50">
{loadingEndFrame ? (
<div className="absolute inset-0 flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-red-500"></div>
</div>
) : endFrameUrl ? (
<img src={endFrameUrl} alt="End frame" className="w-full h-full object-cover" />
) : null}
</div>
</div>
</div>
{/* Timeline */}
<div className="space-y-3">
{/* Progress bar */}
<div className="relative h-8 bg-gray-800 rounded-lg overflow-hidden">
{/* Selected range */}
<div
className="absolute h-full bg-red-500/30"
style={{
left: `${(startTime / (videoInfo?.duration || 1)) * 100}%`,
width: `${((endTime - startTime) / (videoInfo?.duration || 1)) * 100}%`,
}}
/>
{/* Current position indicator */}
<div
className="absolute top-0 bottom-0 w-1 bg-white"
style={{
left: `${(currentTime / (videoInfo?.duration || 1)) * 100}%`,
}}
/>
{/* Click to seek */}
<div
className="absolute inset-0 cursor-pointer"
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const percent = x / rect.width;
const time = percent * (videoInfo?.duration || 0);
handleSeek(Math.max(startTime, Math.min(endTime, time)));
}}
/>
</div>
{/* Start/End controls with precise inputs */}
<div className="grid grid-cols-2 gap-6">
{/* Start Time Control */}
<div className="space-y-2">
<label className="block text-sm text-gray-400">
시작: <span className="text-green-400 font-mono">{formatTimeDetailed(startTime)}</span>
</label>
<div className="flex items-center gap-1">
<button
onClick={() => adjustStart(-0.5)}
className="p-1.5 bg-gray-700 hover:bg-gray-600 rounded text-gray-300"
title="-0.5초"
>
<ChevronsLeft size={16} />
</button>
<button
onClick={() => adjustStart(-0.1)}
className="p-1.5 bg-gray-700 hover:bg-gray-600 rounded text-gray-300"
title="-0.1초"
>
<ChevronLeft size={16} />
</button>
<input
type="number"
min="0"
max={endTime - 0.1}
step="0.1"
value={startTime.toFixed(1)}
onChange={(e) => handleStartChange(e.target.value)}
className="w-16 px-2 py-1 bg-gray-800 border border-gray-600 rounded text-white text-center font-mono text-sm"
/>
<button
onClick={() => adjustStart(0.1)}
className="p-1.5 bg-gray-700 hover:bg-gray-600 rounded text-gray-300"
title="+0.1초"
>
<ChevronRight size={16} />
</button>
<button
onClick={() => adjustStart(0.5)}
className="p-1.5 bg-gray-700 hover:bg-gray-600 rounded text-gray-300"
title="+0.5초"
>
<ChevronsRight size={16} />
</button>
</div>
<input
type="range"
min="0"
max={videoInfo?.duration || 0}
step="0.1"
value={startTime}
onChange={(e) => handleStartChange(e.target.value)}
className="w-full accent-green-500"
/>
</div>
{/* End Time Control */}
<div className="space-y-2">
<label className="block text-sm text-gray-400">
종료: <span className="text-red-400 font-mono">{formatTimeDetailed(endTime)}</span>
</label>
<div className="flex items-center gap-1">
<button
onClick={() => adjustEnd(-0.5)}
className="p-1.5 bg-gray-700 hover:bg-gray-600 rounded text-gray-300"
title="-0.5초"
>
<ChevronsLeft size={16} />
</button>
<button
onClick={() => adjustEnd(-0.1)}
className="p-1.5 bg-gray-700 hover:bg-gray-600 rounded text-gray-300"
title="-0.1초"
>
<ChevronLeft size={16} />
</button>
<input
type="number"
min={startTime + 0.1}
max={videoInfo?.duration || 0}
step="0.1"
value={endTime.toFixed(1)}
onChange={(e) => handleEndChange(e.target.value)}
className="w-16 px-2 py-1 bg-gray-800 border border-gray-600 rounded text-white text-center font-mono text-sm"
/>
<button
onClick={() => adjustEnd(0.1)}
className="p-1.5 bg-gray-700 hover:bg-gray-600 rounded text-gray-300"
title="+0.1초"
>
<ChevronRight size={16} />
</button>
<button
onClick={() => adjustEnd(0.5)}
className="p-1.5 bg-gray-700 hover:bg-gray-600 rounded text-gray-300"
title="+0.5초"
>
<ChevronsRight size={16} />
</button>
</div>
<input
type="range"
min="0"
max={videoInfo?.duration || 0}
step="0.1"
value={endTime}
onChange={(e) => handleEndChange(e.target.value)}
className="w-full accent-red-500"
/>
</div>
</div>
{/* Duration info */}
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">
자를 영상 길이: <span className="text-white font-medium">{formatTime(trimmedDuration)}</span>
</span>
<span className="text-gray-400">
제거할 길이: <span className="text-red-400">{formatTime((videoInfo?.duration || 0) - trimmedDuration)}</span>
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-between pt-2">
<button
onClick={handleReset}
className="flex items-center gap-2 px-4 py-2 text-gray-400 hover:text-white transition-colors"
>
<RotateCcw size={16} />
초기화
</button>
<div className="flex items-center gap-3">
<button
onClick={onCancel}
className="px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors"
>
취소
</button>
<button
onClick={handleTrim}
disabled={trimming || trimmedDuration < 1}
className="flex items-center gap-2 px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{trimming ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
자르는 ...
</>
) : (
<>
<Check size={16} />
영상 자르기
</>
)}
</button>
</div>
</div>
{/* Help text */}
<p className="text-xs text-gray-500 text-center">
« » 0.5 이동 · 0.1 이동 · 프레임 미리보기로 정확한 지점 확인
</p>
</div>
);
}

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,172 @@
import React, { useState, useEffect, useRef } from 'react';
import { Upload, Trash2, Play, Pause, Music } from 'lucide-react';
import { bgmApi } from '../api/client';
export default function BGMPage() {
const [bgmList, setBgmList] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [playingId, setPlayingId] = useState(null);
const audioRef = useRef(null);
const fileInputRef = useRef(null);
const fetchBgmList = async () => {
setIsLoading(true);
try {
const res = await bgmApi.list();
setBgmList(res.data);
} catch (err) {
console.error('Failed to fetch BGM list:', err);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchBgmList();
}, []);
const handleUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
await bgmApi.upload(file);
await fetchBgmList();
} catch (err) {
console.error('Failed to upload BGM:', err);
alert('업로드 실패: ' + (err.response?.data?.detail || err.message));
} finally {
setUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleDelete = async (bgmId) => {
if (!confirm('이 BGM을 삭제하시겠습니까?')) return;
try {
await bgmApi.delete(bgmId);
setBgmList(bgmList.filter((b) => b.id !== bgmId));
if (playingId === bgmId) {
setPlayingId(null);
if (audioRef.current) {
audioRef.current.pause();
}
}
} catch (err) {
console.error('Failed to delete BGM:', err);
}
};
const handlePlay = (bgm) => {
if (playingId === bgm.id) {
// Stop playing
if (audioRef.current) {
audioRef.current.pause();
}
setPlayingId(null);
} else {
// Start playing
if (audioRef.current) {
audioRef.current.src = bgm.path;
audioRef.current.play();
}
setPlayingId(bgm.id);
}
};
const formatDuration = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${String(secs).padStart(2, '0')}`;
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">BGM 관리</h2>
<label className="btn-primary flex items-center gap-2 cursor-pointer">
<Upload size={18} />
{uploading ? '업로드 중...' : 'BGM 업로드'}
<input
ref={fileInputRef}
type="file"
accept=".mp3,.wav,.m4a,.ogg"
onChange={handleUpload}
className="hidden"
disabled={uploading}
/>
</label>
</div>
<audio
ref={audioRef}
onEnded={() => setPlayingId(null)}
className="hidden"
/>
{isLoading ? (
<div className="card text-center py-12 text-gray-500">
로딩 ...
</div>
) : bgmList.length === 0 ? (
<div className="card text-center py-12 text-gray-500">
<Music size={48} className="mx-auto mb-4 opacity-50" />
<p>등록된 BGM이 없습니다.</p>
<p className="text-sm mt-2">MP3, WAV 파일을 업로드하세요.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{bgmList.map((bgm) => (
<div
key={bgm.id}
className="card flex items-center gap-4 hover:border-gray-700 transition-colors"
>
<button
onClick={() => handlePlay(bgm)}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
playingId === bgm.id
? 'bg-red-600 text-white'
: 'bg-gray-800 text-gray-400 hover:text-white'
}`}
>
{playingId === bgm.id ? (
<Pause size={20} />
) : (
<Play size={20} className="ml-1" />
)}
</button>
<div className="flex-1 min-w-0">
<h3 className="font-medium truncate">{bgm.name}</h3>
<p className="text-sm text-gray-500">
{formatDuration(bgm.duration)}
</p>
</div>
<button
onClick={() => handleDelete(bgm.id)}
className="p-2 text-gray-500 hover:text-red-500 transition-colors"
title="삭제"
>
<Trash2 size={18} />
</button>
</div>
))}
</div>
)}
<div className="card bg-gray-800/50">
<h3 className="font-medium mb-2">지원 형식</h3>
<p className="text-sm text-gray-400">
MP3, WAV, M4A, OGG 형식의 오디오 파일을 업로드할 있습니다.
저작권에 유의하여 사용하세요.
</p>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,163 @@
import React, { useState, useEffect } from 'react';
import { Trash2, Download, ExternalLink, RefreshCw } from 'lucide-react';
import { jobsApi } from '../api/client';
const STATUS_COLORS = {
pending: 'bg-yellow-500',
downloading: 'bg-blue-500',
transcribing: 'bg-purple-500',
translating: 'bg-indigo-500',
processing: 'bg-orange-500',
completed: 'bg-green-500',
failed: 'bg-red-500',
};
const STATUS_LABELS = {
pending: '대기',
downloading: '다운로드 중',
transcribing: '음성 인식',
translating: '번역',
processing: '처리 중',
completed: '완료',
failed: '실패',
};
export default function JobsPage() {
const [jobs, setJobs] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const fetchJobs = async () => {
setIsLoading(true);
try {
const res = await jobsApi.list();
setJobs(res.data);
} catch (err) {
console.error('Failed to fetch jobs:', err);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchJobs();
// Auto refresh every 5 seconds
const interval = setInterval(fetchJobs, 5000);
return () => clearInterval(interval);
}, []);
const handleDelete = async (jobId) => {
if (!confirm('이 작업을 삭제하시겠습니까?')) return;
try {
await jobsApi.delete(jobId);
setJobs(jobs.filter((j) => j.job_id !== jobId));
} catch (err) {
console.error('Failed to delete job:', err);
}
};
const formatDate = (dateStr) => {
const date = new Date(dateStr);
return date.toLocaleString('ko-KR', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">작업 목록</h2>
<button
onClick={fetchJobs}
className="btn-secondary flex items-center gap-2"
disabled={isLoading}
>
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
새로고침
</button>
</div>
{jobs.length === 0 ? (
<div className="card text-center py-12 text-gray-500">
<p>아직 작업이 없습니다.</p>
<p className="text-sm mt-2"> 영상 URL을 입력하여 시작하세요.</p>
</div>
) : (
<div className="space-y-4">
{jobs.map((job) => (
<div key={job.job_id} className="card">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<span className="font-mono text-sm bg-gray-800 px-2 py-1 rounded">
{job.job_id}
</span>
<span
className={`px-2 py-1 rounded text-xs font-medium ${STATUS_COLORS[job.status]} bg-opacity-20`}
>
<span
className={`inline-block w-2 h-2 rounded-full mr-1 ${STATUS_COLORS[job.status]}`}
/>
{STATUS_LABELS[job.status]}
</span>
<span className="text-sm text-gray-500">
{formatDate(job.created_at)}
</span>
</div>
{job.original_url && (
<p className="text-sm text-gray-400 truncate mb-2">
{job.original_url}
</p>
)}
{job.error && (
<p className="text-sm text-red-400 mt-2">
오류: {job.error}
</p>
)}
{/* Progress bar */}
{!['completed', 'failed'].includes(job.status) && (
<div className="mt-3">
<div className="h-1 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full bg-red-500 transition-all duration-500"
style={{ width: `${job.progress}%` }}
/>
</div>
</div>
)}
</div>
<div className="flex items-center gap-2">
{job.status === 'completed' && job.output_path && (
<a
href={jobsApi.downloadOutput(job.job_id)}
className="btn-primary flex items-center gap-2"
download
>
<Download size={18} />
다운로드
</a>
)}
<button
onClick={() => handleDelete(job.job_id)}
className="p-2 text-gray-500 hover:text-red-500 transition-colors"
title="삭제"
>
<Trash2 size={18} />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,91 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: 'Noto Sans KR', sans-serif;
background-color: #0f0f0f;
color: #ffffff;
}
@layer components {
.btn-primary {
@apply bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200;
}
.btn-secondary {
@apply bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200;
}
.input-field {
@apply w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-red-500 transition-colors;
}
.card {
@apply bg-gray-900 rounded-xl p-6 border border-gray-800;
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #1f1f1f;
}
::-webkit-scrollbar-thumb {
background: #444;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Progress bar animation */
@keyframes progress {
0% {
width: 0%;
}
100% {
width: var(--progress);
}
}
.progress-bar {
animation: progress 0.5s ease-out forwards;
}
/* Fade in animation */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out forwards;
}
/* Pipeline step animation */
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-slideIn {
animation: slideIn 0.4s ease-out forwards;
}