feat: 타임라인 에디터 및 비디오 스튜디오 컴포넌트 추가
- TimelineEditor, VideoStudio 컴포넌트 신규 추가 - 백엔드 transcriber, video_processor 서비스 개선 - 프론트엔드 HomePage 리팩토링 및 스타일 업데이트 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,14 @@
|
||||
<title>Shorts Maker - 쇼츠 한글 자막 변환기</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<!-- 기본 UI 폰트 -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- 자막 프리뷰용 한글 폰트들 (Google Fonts) -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nanum+Gothic:wght@400;700;800&family=Nanum+Gothic+Coding&family=Black+Han+Sans&family=Do+Hyeon&family=Jua&family=Sunflower:wght@300;500;700&family=Gaegu:wght@300;400;700&family=Gamja+Flower&family=Stylish&family=Gothic+A1:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<!-- Pretendard (CDN) -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css">
|
||||
<!-- Spoqa Han Sans (CDN) -->
|
||||
<link href="https://spoqa.github.io/spoqa-han-sans/css/SpoqaHanSansNeo.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -52,6 +52,7 @@ export const processApi = {
|
||||
intro_text: options.introText || null,
|
||||
intro_duration: options.introDuration || 0.7,
|
||||
intro_font_size: options.introFontSize || 100,
|
||||
intro_position: options.introPosition || 'center',
|
||||
}),
|
||||
|
||||
// === Other APIs ===
|
||||
@@ -79,11 +80,13 @@ export const processApi = {
|
||||
// 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) =>
|
||||
// excludeRegions: array of {start, end} objects to cut from middle of video
|
||||
trim: (jobId, startTime, endTime, reprocess = false, excludeRegions = []) =>
|
||||
client.post(`/process/${jobId}/trim`, {
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
reprocess,
|
||||
exclude_regions: excludeRegions,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
317
frontend/src/components/TimelineEditor.jsx
Normal file
317
frontend/src/components/TimelineEditor.jsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Play, Pause, Edit2, Check, X, Clock, Type, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { processApi, jobsApi } from '../api/client';
|
||||
|
||||
export default function TimelineEditor({ job, onTranscriptUpdate, onSave }) {
|
||||
const [segments, setSegments] = useState(job.transcript || []);
|
||||
const [editingIndex, setEditingIndex] = useState(null);
|
||||
const [editText, setEditText] = useState('');
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [activeSegmentIndex, setActiveSegmentIndex] = useState(0);
|
||||
const videoRef = useRef(null);
|
||||
const timelineRef = useRef(null);
|
||||
|
||||
// Sync video time with active segment
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
const handleTimeUpdate = () => {
|
||||
const time = videoRef.current.currentTime;
|
||||
setCurrentTime(time);
|
||||
|
||||
// Find which segment is currently playing
|
||||
const index = segments.findIndex(
|
||||
(seg, i) => time >= seg.start && time < seg.end
|
||||
);
|
||||
if (index !== -1 && index !== activeSegmentIndex) {
|
||||
setActiveSegmentIndex(index);
|
||||
scrollToSegment(index);
|
||||
}
|
||||
};
|
||||
|
||||
videoRef.current.addEventListener('timeupdate', handleTimeUpdate);
|
||||
return () => videoRef.current?.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
}
|
||||
}, [segments, activeSegmentIndex]);
|
||||
|
||||
const scrollToSegment = (index) => {
|
||||
const segmentEl = document.getElementById(`segment-${index}`);
|
||||
if (segmentEl && timelineRef.current) {
|
||||
segmentEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (seconds) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
const ms = Math.floor((seconds % 1) * 100);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleSegmentClick = (index) => {
|
||||
setActiveSegmentIndex(index);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = segments[index].start;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (videoRef.current) {
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
videoRef.current.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditStart = (index) => {
|
||||
setEditingIndex(index);
|
||||
setEditText(segments[index].translated || segments[index].text);
|
||||
};
|
||||
|
||||
const handleEditSave = () => {
|
||||
if (editingIndex === null) return;
|
||||
|
||||
const newSegments = [...segments];
|
||||
newSegments[editingIndex] = {
|
||||
...newSegments[editingIndex],
|
||||
translated: editText,
|
||||
};
|
||||
setSegments(newSegments);
|
||||
setEditingIndex(null);
|
||||
setEditText('');
|
||||
|
||||
if (onTranscriptUpdate) {
|
||||
onTranscriptUpdate(newSegments);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditCancel = () => {
|
||||
setEditingIndex(null);
|
||||
setEditText('');
|
||||
};
|
||||
|
||||
const handlePrevSegment = () => {
|
||||
if (activeSegmentIndex > 0) {
|
||||
const newIndex = activeSegmentIndex - 1;
|
||||
setActiveSegmentIndex(newIndex);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = segments[newIndex].start;
|
||||
}
|
||||
scrollToSegment(newIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextSegment = () => {
|
||||
if (activeSegmentIndex < segments.length - 1) {
|
||||
const newIndex = activeSegmentIndex + 1;
|
||||
setActiveSegmentIndex(newIndex);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = segments[newIndex].start;
|
||||
}
|
||||
scrollToSegment(newIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAll = async () => {
|
||||
try {
|
||||
await processApi.updateTranscript(job.job_id, segments);
|
||||
if (onSave) {
|
||||
onSave(segments);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save transcript:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Get video URL
|
||||
const videoUrl = jobsApi.downloadOriginal(job.job_id);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-lg flex items-center gap-2">
|
||||
<Type size={20} className="text-purple-400" />
|
||||
타임라인 편집기
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleSaveAll}
|
||||
className="px-4 py-2 bg-purple-600 hover:bg-purple-500 rounded-lg text-sm font-medium flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<Check size={16} />
|
||||
스크립트 저장
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Editor Layout - Vrew Style */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Left: Video Preview */}
|
||||
<div className="space-y-3">
|
||||
{/* Video Player */}
|
||||
<div className="relative bg-black rounded-lg overflow-hidden aspect-[9/16] max-h-[400px] mx-auto">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
className="w-full h-full object-contain"
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
/>
|
||||
|
||||
{/* Current subtitle overlay */}
|
||||
{segments[activeSegmentIndex] && (
|
||||
<div className="absolute bottom-16 left-0 right-0 text-center px-4">
|
||||
<span className="bg-black/80 text-white px-3 py-1 rounded text-sm">
|
||||
{segments[activeSegmentIndex].translated || segments[activeSegmentIndex].text}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Playback Controls */}
|
||||
<div className="flex items-center justify-center gap-4 bg-gray-800 rounded-lg p-3">
|
||||
<button
|
||||
onClick={handlePrevSegment}
|
||||
disabled={activeSegmentIndex === 0}
|
||||
className="p-2 hover:bg-gray-700 rounded-lg disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handlePlayPause}
|
||||
className="p-3 bg-purple-600 hover:bg-purple-500 rounded-full transition-colors"
|
||||
>
|
||||
{isPlaying ? <Pause size={24} /> : <Play size={24} className="ml-0.5" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleNextSegment}
|
||||
disabled={activeSegmentIndex === segments.length - 1}
|
||||
className="p-2 hover:bg-gray-700 rounded-lg disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Time indicator */}
|
||||
<div className="text-center text-sm text-gray-400">
|
||||
<Clock size={14} className="inline mr-1" />
|
||||
{formatTime(currentTime)} / 세그먼트 {activeSegmentIndex + 1} / {segments.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Timeline Segments List */}
|
||||
<div
|
||||
ref={timelineRef}
|
||||
className="bg-gray-800/50 rounded-lg overflow-hidden"
|
||||
>
|
||||
<div className="p-3 border-b border-gray-700 sticky top-0 bg-gray-800 z-10">
|
||||
<div className="flex items-center justify-between text-sm text-gray-400">
|
||||
<span>타임라인 ({segments.length}개 세그먼트)</span>
|
||||
<span className="text-xs">클릭하여 이동, 더블클릭하여 편집</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[500px] overflow-y-auto">
|
||||
{segments.map((segment, index) => (
|
||||
<div
|
||||
key={index}
|
||||
id={`segment-${index}`}
|
||||
onClick={() => handleSegmentClick(index)}
|
||||
onDoubleClick={() => handleEditStart(index)}
|
||||
className={`flex gap-3 p-3 border-b border-gray-700/50 cursor-pointer transition-colors ${
|
||||
activeSegmentIndex === index
|
||||
? 'bg-purple-900/30 border-l-4 border-l-purple-500'
|
||||
: 'hover:bg-gray-700/30'
|
||||
}`}
|
||||
>
|
||||
{/* Segment Number */}
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center text-sm font-medium">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div className="flex-shrink-0 w-16 h-12 bg-gray-900 rounded overflow-hidden">
|
||||
<img
|
||||
src={processApi.getFrameUrl(job.job_id, segment.start)}
|
||||
alt={`Frame at ${segment.start}s`}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Time */}
|
||||
<div className="text-xs text-gray-500 mb-1 flex items-center gap-2">
|
||||
<Clock size={10} />
|
||||
{formatTime(segment.start)} - {formatTime(segment.end)}
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
{editingIndex === index ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
className="w-full p-2 bg-gray-900 border border-purple-500 rounded text-sm resize-none focus:outline-none"
|
||||
rows={2}
|
||||
autoFocus
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleEditSave(); }}
|
||||
className="px-2 py-1 bg-green-600 hover:bg-green-500 rounded text-xs flex items-center gap-1"
|
||||
>
|
||||
<Check size={12} /> 저장
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleEditCancel(); }}
|
||||
className="px-2 py-1 bg-gray-600 hover:bg-gray-500 rounded text-xs flex items-center gap-1"
|
||||
>
|
||||
<X size={12} /> 취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{/* Original text (Chinese) */}
|
||||
{segment.text !== segment.translated && (
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{segment.text}
|
||||
</p>
|
||||
)}
|
||||
{/* Translated text (Korean) */}
|
||||
<p className="text-sm text-white line-clamp-2">
|
||||
{segment.translated || segment.text}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Button */}
|
||||
{editingIndex !== index && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleEditStart(index); }}
|
||||
className="flex-shrink-0 p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tip */}
|
||||
<div className="text-xs text-gray-500 text-center">
|
||||
팁: 세그먼트를 클릭하면 해당 시간으로 이동합니다. 더블클릭하거나 편집 버튼을 눌러 텍스트를 수정하세요.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2224
frontend/src/components/VideoStudio.jsx
Normal file
2224
frontend/src/components/VideoStudio.jsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -89,3 +89,64 @@ body {
|
||||
.animate-slideIn {
|
||||
animation: slideIn 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Dual-handle range slider */
|
||||
.dual-range-slider input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dual-range-slider input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
cursor: grab;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.dual-range-slider input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: grab;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Hide default track */
|
||||
.dual-range-slider input[type="range"]::-webkit-slider-runnable-track {
|
||||
background: transparent;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.dual-range-slider input[type="range"]::-moz-range-track {
|
||||
background: transparent;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
/* Range slider accent color */
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
cursor: grab;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
cursor: grab;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user