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:
74
frontend/src/App.jsx
Normal file
74
frontend/src/App.jsx
Normal 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
159
frontend/src/api/client.js
Normal 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;
|
||||
219
frontend/src/components/ManualSubtitleInput.jsx
Normal file
219
frontend/src/components/ManualSubtitleInput.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
474
frontend/src/components/PipelineView.jsx
Normal file
474
frontend/src/components/PipelineView.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
frontend/src/components/ProcessingStatus.jsx
Normal file
93
frontend/src/components/ProcessingStatus.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
frontend/src/components/SubtitleEditor.jsx
Normal file
130
frontend/src/components/SubtitleEditor.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
308
frontend/src/components/ThumbnailGenerator.jsx
Normal file
308
frontend/src/components/ThumbnailGenerator.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
frontend/src/components/VideoPreview.jsx
Normal file
69
frontend/src/components/VideoPreview.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
498
frontend/src/components/VideoTrimmer.jsx
Normal file
498
frontend/src/components/VideoTrimmer.jsx
Normal 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
10
frontend/src/main.jsx
Normal 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>,
|
||||
)
|
||||
172
frontend/src/pages/BGMPage.jsx
Normal file
172
frontend/src/pages/BGMPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1161
frontend/src/pages/HomePage.jsx
Normal file
1161
frontend/src/pages/HomePage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
163
frontend/src/pages/JobsPage.jsx
Normal file
163
frontend/src/pages/JobsPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
frontend/src/styles/index.css
Normal file
91
frontend/src/styles/index.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user