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

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