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

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