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:
@@ -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", "<<LINEBREAK>>").replace("/n", "<<LINEBREAK>>")
|
||||
text = text.replace("\\N", "<<LINEBREAK>>").replace("\\n", "<<LINEBREAK>>")
|
||||
|
||||
# 3. Escape special characters (백슬래시, 중괄호)
|
||||
text = text.replace("\\", "\\\\").replace("{", "\\{").replace("}", "\\}")
|
||||
|
||||
# Add animation effects
|
||||
# 4. 플레이스홀더를 ASS 줄바꿈으로 복원
|
||||
text = text.replace("<<LINEBREAK>>", "\\N")
|
||||
|
||||
# 5. Add animation effects
|
||||
if animation == "fade":
|
||||
# Fade in/out effect (250ms)
|
||||
text = f"{{\\fad(250,250)}}{text}"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user