From 5c57f33903dc378bd92fdd0c85cd523e04fcb7cd Mon Sep 17 00:00:00 2001 From: "kihong.kim" Date: Tue, 6 Jan 2026 21:21:58 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EC=97=90=EB=94=94=ED=84=B0=20=EB=B0=8F=20=EB=B9=84=EB=94=94?= =?UTF-8?q?=EC=98=A4=20=EC=8A=A4=ED=8A=9C=EB=94=94=EC=98=A4=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TimelineEditor, VideoStudio 컴포넌트 신규 추가 - 백엔드 transcriber, video_processor 서비스 개선 - 프론트엔드 HomePage 리팩토링 및 스타일 업데이트 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/app/models/schemas.py | 28 +- backend/app/routers/process.py | 18 + backend/app/services/transcriber.py | 99 +- backend/app/services/video_processor.py | 156 +- frontend/index.html | 7 + frontend/src/api/client.js | 5 +- frontend/src/components/TimelineEditor.jsx | 317 +++ frontend/src/components/VideoStudio.jsx | 2224 ++++++++++++++++++++ frontend/src/pages/HomePage.jsx | 1311 +++--------- frontend/src/styles/index.css | 61 + 10 files changed, 3186 insertions(+), 1040 deletions(-) create mode 100644 frontend/src/components/TimelineEditor.jsx create mode 100644 frontend/src/components/VideoStudio.jsx diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index 46597b7..e998a93 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -32,18 +32,20 @@ class DownloadResponse(BaseModel): class SubtitleStyle(BaseModel): - font_size: int = 28 - font_color: str = "white" - outline_color: str = "black" - outline_width: int = 2 - position: str = "bottom" # top, center, bottom + font_size: int = 70 + font_color: str = "FFFFFF" # hex color code (white) + outline_color: str = "000000" # hex color code (black) + outline_width: int = 4 # 아웃라인 두께 (가독성을 위해 4로 증가) + position: str = "center" # top, center, bottom + margin_v: int = 50 # 수직 위치 (0=가장자리, 100=화면 중심쪽) - 화면 높이의 % font_name: str = "Pretendard" # Enhanced styling options bold: bool = True # 굵은 글씨 (가독성 향상) - shadow: int = 1 # 그림자 깊이 (0=없음, 1-4) - background_box: bool = True # 불투명 배경 박스로 원본 자막 덮기 - background_opacity: str = "E0" # 배경 불투명도 (00=투명, FF=완전불투명, E0=권장) - animation: str = "none" # none, fade, pop (자막 애니메이션) + shadow: int = 2 # 그림자 깊이 (0=없음, 1-4) + background_box: bool = False # False=아웃라인 스타일 (깔끔함), True=배경 박스 + background_opacity: str = "80" # 배경 불투명도 (00=투명, FF=완전불투명, 80=반투명) + animation: str = "fade" # none, fade, pop (자막 애니메이션) + max_chars_per_line: int = 0 # 줄당 최대 글자 수 (0=비활성화, 15~20 권장) class TranslationModeEnum(str, Enum): @@ -68,11 +70,18 @@ class ProcessResponse(BaseModel): message: str +class ExcludeRegion(BaseModel): + """A region to exclude (cut out) from the video.""" + start: float # Start time in seconds + end: float # End time in seconds + + class TrimRequest(BaseModel): """Request to trim a video to a specific time range.""" start_time: float # Start time in seconds end_time: float # End time in seconds reprocess: bool = False # Whether to automatically reprocess after trimming (default: False for manual workflow) + exclude_regions: List[ExcludeRegion] = [] # Regions to cut out from the middle of the video class TranscribeRequest(BaseModel): @@ -91,6 +100,7 @@ class RenderRequest(BaseModel): intro_text: Optional[str] = None # Max 20 characters recommended intro_duration: float = 0.7 # Duration of frozen frame with intro text (seconds) intro_font_size: int = 100 # Font size + intro_position: str = "center" # top, center, bottom class TrimResponse(BaseModel): diff --git a/backend/app/routers/process.py b/backend/app/routers/process.py index 0a60c15..1efddf2 100644 --- a/backend/app/routers/process.py +++ b/backend/app/routers/process.py @@ -116,12 +116,14 @@ async def process_task( outline_color=style.outline_color.lstrip("#"), font_name=style.font_name, position=style.position, # top, center, bottom + margin_v=style.margin_v, outline_width=style.outline_width, bold=style.bold, shadow=style.shadow, background_box=style.background_box, background_opacity=style.background_opacity, animation=style.animation, + max_chars_per_line=style.max_chars_per_line, ) # Save subtitle file @@ -284,12 +286,14 @@ async def continue_processing_task( outline_color=style.outline_color.lstrip("#"), font_name=style.font_name, position=style.position, + margin_v=style.margin_v, outline_width=style.outline_width, bold=style.bold, shadow=style.shadow, background_box=style.background_box, background_opacity=style.background_opacity, animation=style.animation, + max_chars_per_line=style.max_chars_per_line, ) job_dir = os.path.dirname(job.video_path) @@ -511,12 +515,21 @@ async def trim_job_video( video_ext = os.path.splitext(job.video_path)[1] trimmed_path = os.path.join(video_dir, f"trimmed{video_ext}") + # Convert exclude_regions to list of dicts for the function + exclude_regions = None + if request.exclude_regions: + exclude_regions = [ + {'start': region.start, 'end': region.end} + for region in request.exclude_regions + ] + # Perform trim success, message = await trim_video( job.video_path, trimmed_path, request.start_time, request.end_time, + exclude_regions, ) if not success: @@ -690,6 +703,7 @@ async def render_step_task( intro_text: str | None = None, intro_duration: float = 0.7, intro_font_size: int = 100, + intro_position: str = "center", ): """Background task for final video rendering (subtitle composition + BGM + intro text).""" job = job_store.get_job(job_id) @@ -715,6 +729,7 @@ async def render_step_task( outline_color=style.outline_color.lstrip("#"), font_name=style.font_name, position=style.position, + margin_v=style.margin_v, outline_width=style.outline_width, bold=style.bold, shadow=style.shadow, @@ -722,6 +737,7 @@ async def render_step_task( background_opacity=style.background_opacity, animation=style.animation, time_offset=subtitle_offset, + max_chars_per_line=style.max_chars_per_line, ) job_dir = os.path.dirname(job.video_path) @@ -756,6 +772,7 @@ async def render_step_task( intro_text=intro_text, intro_duration=intro_duration, intro_font_size=intro_font_size, + intro_position=intro_position, ) if success: @@ -846,6 +863,7 @@ async def render_video( request.intro_text, request.intro_duration, request.intro_font_size, + request.intro_position, ) return ProcessResponse( diff --git a/backend/app/services/transcriber.py b/backend/app/services/transcriber.py index 6e141a4..3b93bc5 100644 --- a/backend/app/services/transcriber.py +++ b/backend/app/services/transcriber.py @@ -314,21 +314,68 @@ def format_srt_time(seconds: float) -> str: return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}" +def auto_wrap_text(text: str, max_chars: int) -> str: + """ + 자동으로 긴 텍스트를 2줄로 나눔. + + Args: + text: 원본 텍스트 + max_chars: 줄당 최대 글자 수 (0이면 비활성화) + + Returns: + 줄바꿈이 적용된 텍스트 (\\N 사용) + """ + if max_chars <= 0 or len(text) <= max_chars: + return text + + # 이미 수동 줄바꿈이 있으면 그대로 반환 (\N, \n, /N, /n 모두 체크) + if "\\N" in text or "\\n" in text or "/N" in text or "/n" in text: + return text + + # 중간 지점 근처에서 좋은 끊김점 찾기 + mid = len(text) // 2 + best_break = mid + + # 공백, 쉼표, 마침표 등에서 끊기 우선 + break_chars = [' ', ',', '.', '!', '?', '。', ',', '!', '?', '、'] + + # 중간점에서 가장 가까운 끊김점 찾기 (앞뒤 10자 범위) + for offset in range(min(10, mid)): + # 중간 뒤쪽 확인 + if mid + offset < len(text) and text[mid + offset] in break_chars: + best_break = mid + offset + 1 + break + # 중간 앞쪽 확인 + if mid - offset >= 0 and text[mid - offset] in break_chars: + best_break = mid - offset + 1 + break + + # 끊김점이 없으면 그냥 중간에서 자르기 + line1 = text[:best_break].strip() + line2 = text[best_break:].strip() + + if line2: + return f"{line1}\\N{line2}" + return line1 + + def segments_to_ass( segments: List[TranscriptSegment], use_translated: bool = True, - font_size: int = 28, + font_size: int = 70, font_color: str = "FFFFFF", outline_color: str = "000000", - font_name: str = "NanumGothic", - position: str = "bottom", # top, center, bottom - outline_width: int = 3, + font_name: str = "Pretendard", + position: str = "center", # top, center, bottom + margin_v: int = 50, # 수직 위치 (0=가장자리, 100=화면 중심쪽) + outline_width: int = 4, # 아웃라인 두께 (가독성) bold: bool = True, - shadow: int = 1, - background_box: bool = True, - background_opacity: str = "E0", # 00=transparent, FF=opaque - animation: str = "none", # none, fade, pop + shadow: int = 2, # 그림자 깊이 + background_box: bool = False, # False=아웃라인 스타일 (깔끔함) + background_opacity: str = "80", # 00=transparent, FF=opaque + animation: str = "fade", # none, fade, pop time_offset: float = 0.0, # Delay all subtitles by this amount (for intro text) + max_chars_per_line: int = 0, # 줄당 최대 글자 수 (0=비활성화, 15~20 권장) ) -> str: """ Convert segments to ASS format with styling. @@ -341,6 +388,7 @@ def segments_to_ass( outline_color: Outline color in hex (without #) font_name: Font family name position: Subtitle position - "top", "center", or "bottom" + margin_v: Vertical margin (0=edge, 100=toward center) - percentage of screen height outline_width: Outline thickness bold: Use bold text shadow: Shadow depth (0-4) @@ -355,16 +403,14 @@ def segments_to_ass( # 1=Bottom-Left, 2=Bottom-Center, 3=Bottom-Right # 4=Middle-Left, 5=Middle-Center, 6=Middle-Right # 7=Top-Left, 8=Top-Center, 9=Top-Right - alignment_map = { - "top": 8, # Top-Center - "center": 5, # Middle-Center (영상 가운데) - "bottom": 2, # Bottom-Center (기본값) - } - alignment = alignment_map.get(position, 2) + # + # position='top'으로 고정하고 margin_v를 화면 높이의 퍼센트로 직접 사용 + # margin_v=5 → 상단 5%, margin_v=95 → 하단 95% + alignment = 8 # Top-Center (상단 기준으로 margin_v 적용) - # Adjust margin based on position (낮은 값 = 화면 가장자리에 더 가까움) - # 원본 자막을 덮기 위해 하단 마진을 작게 설정 - margin_v = 30 if position == "bottom" else (100 if position == "top" else 10) + # margin_v를 화면 높이의 퍼센트로 직접 변환 (1920 높이 기준) + # margin_v=5 → 96px, margin_v=50 → 960px, margin_v=95 → 1824px + ass_margin_v = int((margin_v / 100) * 1920) # Bold: -1 = bold, 0 = normal bold_value = -1 if bold else 0 @@ -385,7 +431,7 @@ PlayResY: 1920 [V4+ Styles] Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding -Style: Default,{font_name},{font_size},&H00{font_color},&H00FFFFFF,&H00{outline_color},&H{back_alpha}000000,{bold_value},0,0,0,100,100,0,0,{border_style},{outline_width},{shadow},{alignment},30,30,{margin_v},1 +Style: Default,{font_name},{font_size},&H00{font_color},&H00FFFFFF,&H00{outline_color},&H{back_alpha}000000,{bold_value},0,0,0,100,100,0,0,{border_style},{outline_width},{shadow},{alignment},30,30,{ass_margin_v},1 [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text @@ -396,10 +442,23 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text start_time = format_ass_time(seg.start + time_offset) end_time = format_ass_time(seg.end + time_offset) text = seg.translated if use_translated and seg.translated else seg.text - # Escape special characters + + # 1. 자동 줄바꿈 적용 (max_chars_per_line이 설정된 경우) + if max_chars_per_line > 0: + text = auto_wrap_text(text, max_chars_per_line) + + # 2. 수동 줄바꿈 처리: \N, \n, /N, /n을 모두 지원 + # 사용자가 /N (슬래시)를 입력해도 동작하도록 함 + text = text.replace("/N", "<>").replace("/n", "<>") + text = text.replace("\\N", "<>").replace("\\n", "<>") + + # 3. Escape special characters (백슬래시, 중괄호) text = text.replace("\\", "\\\\").replace("{", "\\{").replace("}", "\\}") - # Add animation effects + # 4. 플레이스홀더를 ASS 줄바꿈으로 복원 + text = text.replace("<>", "\\N") + + # 5. Add animation effects if animation == "fade": # Fade in/out effect (250ms) text = f"{{\\fad(250,250)}}{text}" diff --git a/backend/app/services/video_processor.py b/backend/app/services/video_processor.py index 9da6090..e473c5a 100644 --- a/backend/app/services/video_processor.py +++ b/backend/app/services/video_processor.py @@ -15,6 +15,7 @@ async def process_video( intro_text: Optional[str] = None, intro_duration: float = 0.7, intro_font_size: int = 100, + intro_position: str = "center", # top, center, bottom ) -> Tuple[bool, str]: """ Process video: remove audio, add subtitles, add BGM, add intro text. @@ -157,13 +158,21 @@ async def process_video( escaped_text = display_text.replace("'", "\\'").replace(":", "\\:") - # Draw text centered on screen during freeze frame + # Calculate vertical position based on intro_position + if intro_position == "top": + y_expr = "h*0.15" # 15% from top + elif intro_position == "bottom": + y_expr = "h*0.80-text_h" # 80% from top (above subtitle area) + else: # center + y_expr = "(h-text_h)/2" # Center vertically + + # Draw text on screen during freeze frame drawtext_parts = [ f"text='{escaped_text}'", f"fontsize={adjusted_font_size}", "fontcolor=white", "x=(w-text_w)/2", # Center horizontally - "y=(h-text_h)/2", # Center vertically + f"y={y_expr}", # Vertical position based on intro_position f"enable='lt(t,{intro_duration})'", "borderw=4", "bordercolor=black", @@ -311,15 +320,17 @@ async def trim_video( output_path: str, start_time: float, end_time: float, + exclude_regions: list = None, ) -> Tuple[bool, str]: """ - Trim video to specified time range. + Trim video to specified time range, optionally excluding middle sections. Args: input_path: Path to input video output_path: Path for output video start_time: Start time in seconds end_time: End time in seconds + exclude_regions: List of dicts with 'start' and 'end' keys for sections to remove Returns: Tuple of (success, message) @@ -341,6 +352,13 @@ async def trim_video( os.makedirs(os.path.dirname(output_path), exist_ok=True) + # If there are exclude regions, use the complex concat approach + if exclude_regions and len(exclude_regions) > 0: + return await _trim_with_exclude_regions( + input_path, output_path, start_time, end_time, exclude_regions + ) + + # Simple trim without exclude regions trim_duration = end_time - start_time # Log trim parameters for debugging @@ -400,6 +418,138 @@ async def trim_video( return False, f"Trim error: {str(e)}" +async def _trim_with_exclude_regions( + input_path: str, + output_path: str, + start_time: float, + end_time: float, + exclude_regions: list, +) -> Tuple[bool, str]: + """ + Trim video with exclude regions - cuts out specified sections and concatenates remaining parts. + + Uses FFmpeg's filter_complex with trim and concat filters. + """ + import tempfile + + print(f"[Trim] Trimming with {len(exclude_regions)} exclude regions") + print(f"[Trim] Main range: {start_time:.3f}s - {end_time:.3f}s") + for i, region in enumerate(exclude_regions): + print(f"[Trim] Exclude region {i}: {region['start']:.3f}s - {region['end']:.3f}s") + + # Sort and merge overlapping exclude regions + sorted_regions = sorted(exclude_regions, key=lambda r: r['start']) + merged_regions = [] + for region in sorted_regions: + # Clip region to main trim range + region_start = max(region['start'], start_time) + region_end = min(region['end'], end_time) + if region_start >= region_end: + continue # Skip invalid regions + + if merged_regions and region_start <= merged_regions[-1]['end']: + merged_regions[-1]['end'] = max(merged_regions[-1]['end'], region_end) + else: + merged_regions.append({'start': region_start, 'end': region_end}) + + if not merged_regions: + # No valid exclude regions, use simple trim + print("[Trim] No valid exclude regions after merging, using simple trim") + return await trim_video(input_path, output_path, start_time, end_time, None) + + # Calculate keep segments (inverse of exclude regions) + keep_segments = [] + current_pos = start_time + + for region in merged_regions: + if current_pos < region['start']: + keep_segments.append({'start': current_pos, 'end': region['start']}) + current_pos = region['end'] + + # Add final segment if there's remaining time + if current_pos < end_time: + keep_segments.append({'start': current_pos, 'end': end_time}) + + if not keep_segments: + return False, "No video segments remaining after excluding regions" + + print(f"[Trim] Keep segments: {keep_segments}") + + # Calculate expected output duration + expected_duration = sum(seg['end'] - seg['start'] for seg in keep_segments) + print(f"[Trim] Expected output duration: {expected_duration:.3f}s") + + # Build FFmpeg filter_complex for concatenation + # Each segment needs: trim, setpts for video; atrim, asetpts for audio + video_filters = [] + audio_filters = [] + segment_labels = [] + + for i, seg in enumerate(keep_segments): + seg_duration = seg['end'] - seg['start'] + # Video filter: trim and reset timestamps + video_filters.append( + f"[0:v]trim=start={seg['start']:.6f}:end={seg['end']:.6f},setpts=PTS-STARTPTS[v{i}]" + ) + # Audio filter: atrim and reset timestamps + audio_filters.append( + f"[0:a]atrim=start={seg['start']:.6f}:end={seg['end']:.6f},asetpts=PTS-STARTPTS[a{i}]" + ) + segment_labels.append(f"[v{i}][a{i}]") + + # Concat filter + concat_input = "".join(segment_labels) + filter_complex = ";".join(video_filters + audio_filters) + filter_complex += f";{concat_input}concat=n={len(keep_segments)}:v=1:a=1[outv][outa]" + + cmd = [ + "ffmpeg", "-y", + "-i", input_path, + "-filter_complex", filter_complex, + "-map", "[outv]", + "-map", "[outa]", + "-c:v", "libx264", + "-preset", "fast", + "-crf", "18", + "-c:a", "aac", + "-b:a", "128k", + "-avoid_negative_ts", "make_zero", + output_path + ] + + print(f"[Trim] Command: ffmpeg -y -i {input_path} -filter_complex [complex] -map [outv] -map [outa] ...") + print(f"[Trim] Filter complex: {filter_complex[:200]}..." if len(filter_complex) > 200 else f"[Trim] Filter complex: {filter_complex}") + + try: + result = await asyncio.to_thread( + subprocess.run, + cmd, + capture_output=True, + text=True, + timeout=300, # Longer timeout for complex operations + ) + + if result.returncode != 0: + error_msg = result.stderr[-500:] if result.stderr else "Unknown error" + print(f"[Trim] FFmpeg error: {error_msg}") + return False, f"Trim with exclude regions failed: {error_msg}" + + if os.path.exists(output_path): + new_duration = await get_video_duration(output_path) + print(f"[Trim] Success! New duration: {new_duration:.3f}s (expected: {expected_duration:.3f}s)") + return True, f"Video trimmed successfully ({new_duration:.1f}s, excluded {len(merged_regions)} regions)" + else: + print("[Trim] Error: Output file not created") + return False, "Output file not created" + + except subprocess.TimeoutExpired: + print("[Trim] Error: Timeout") + return False, "Trim operation timed out" + except Exception as e: + print(f"[Trim] Error: {str(e)}") + return False, f"Trim error: {str(e)}" + + async def extract_frame( video_path: str, output_path: str, diff --git a/frontend/index.html b/frontend/index.html index 974cb61..1de3725 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,7 +6,14 @@ Shorts Maker - 쇼츠 한글 자막 변환기 + + + + + + +
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 4b58cf6..ab6689b 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -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, }), }; diff --git a/frontend/src/components/TimelineEditor.jsx b/frontend/src/components/TimelineEditor.jsx new file mode 100644 index 0000000..34b69d8 --- /dev/null +++ b/frontend/src/components/TimelineEditor.jsx @@ -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 ( +
+ {/* Header */} +
+

+ + 타임라인 편집기 +

+ +
+ + {/* Main Editor Layout - Vrew Style */} +
+ {/* Left: Video Preview */} +
+ {/* Video Player */} +
+
+ + {/* Playback Controls */} +
+ + + + + +
+ + {/* Time indicator */} +
+ + {formatTime(currentTime)} / 세그먼트 {activeSegmentIndex + 1} / {segments.length} +
+
+ + {/* Right: Timeline Segments List */} +
+
+
+ 타임라인 ({segments.length}개 세그먼트) + 클릭하여 이동, 더블클릭하여 편집 +
+
+ +
+ {segments.map((segment, index) => ( +
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 */} +
+ {index + 1} +
+ + {/* Thumbnail */} +
+ {`Frame +
+ + {/* Content */} +
+ {/* Time */} +
+ + {formatTime(segment.start)} - {formatTime(segment.end)} +
+ + {/* Text */} + {editingIndex === index ? ( +
+