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:
kihong.kim
2026-01-06 21:21:58 +09:00
parent ad14c4ea8c
commit 5c57f33903
10 changed files with 3186 additions and 1040 deletions

View File

@@ -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>

View File

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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