Last active
November 22, 2025 04:19
-
-
Save ouor/5825cb770efaf75a8ec884c22fc8e45e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import srt | |
| from datetime import timedelta | |
| from dataclasses import dataclass | |
| from typing import List, Tuple | |
| # ===== 설정값 ===== | |
| MAX_CHARS_PER_LINE = 20 # 한 줄에 들어갈 최대 글자 수 (대충 감으로 잡는 값) | |
| MAX_LINES_PER_SENTENCE = 3 | |
| LEAD_TIME = 0.5 # 자막을 미리 땡겨서 보여줄 시간(초) | |
| MIN_EFFECTIVE_DURATION = 0.3 # 너무 짧은 자막 방지용 최소 길이(초) | |
| FONT_NAME = "NanumGothicBold" | |
| FONT_SIZE = 100 | |
| STROKE_SIZE = 5 | |
| COLOR_TEXT = "&H60FFFFFF&" | |
| COLOR_OUTLINE = "&H30000000&" | |
| COLOR_BACK = "&H50000000&" | |
| COLOR_HIGHLIGHT = "&H0000FFFF&" | |
| @dataclass | |
| class EventState: | |
| time: float # 초 단위 | |
| top_text: str # ASS용 (\N 포함) | |
| bottom_text: str # ASS용 (\N 포함) | |
| def td_to_seconds(td: timedelta) -> float: | |
| return td.total_seconds() | |
| def seconds_to_ass_time(sec: float) -> str: | |
| """초(float)를 ASS 타임포맷 H:MM:SS.cc 로 변환""" | |
| if sec < 0: | |
| sec = 0.0 | |
| total_centis = int(round(sec * 100)) | |
| centis = total_centis % 100 | |
| total_seconds = total_centis // 100 | |
| s = total_seconds % 60 | |
| total_minutes = total_seconds // 60 | |
| m = total_minutes % 60 | |
| h = total_minutes // 60 | |
| return f"{h:d}:{m:02d}:{s:02d}.{centis:02d}" | |
| def layout_sentence(words: List[str], | |
| max_chars: int = MAX_CHARS_PER_LINE, | |
| max_lines: int = MAX_LINES_PER_SENTENCE) -> str: | |
| """ | |
| 단어 리스트를 받아서 줄바꿈까지 적용된 문자열로 만들어줌. | |
| 줄 수는 max_lines까지만 허용, 넘치는 단어는 버림. | |
| 결과는 ASS에서 사용할 \\N 포함 문자열. | |
| """ | |
| lines: List[str] = [] | |
| current = "" | |
| for w in words: | |
| candidate = w if not current else current + " " + w | |
| if len(candidate) <= max_chars: | |
| current = candidate | |
| else: | |
| if current: | |
| lines.append(current) | |
| current = w | |
| if len(lines) >= max_lines: | |
| break | |
| if current and len(lines) < max_lines: | |
| lines.append(current) | |
| return r"\N".join(lines) | |
| def layout_sentence_with_highlight(words: List[str], | |
| max_chars: int = MAX_CHARS_PER_LINE, | |
| max_lines: int = MAX_LINES_PER_SENTENCE, | |
| highlight_color: str = COLOR_HIGHLIGHT) -> str: | |
| """ | |
| 태그가 줄바꿈 계산을 방해하지 않도록. | |
| 1) 태그 없이 레이아웃 계산 | |
| 2) 그 후 하이라이트만 삽입 | |
| """ | |
| # 1) 태그 없는 상태로 레이아웃 계산 | |
| clean_text = layout_sentence(words, max_chars, max_lines) | |
| # 2) 마지막 단어 하이라이트 적용 | |
| if not words: | |
| return clean_text | |
| last_word = words[-1] | |
| # clean_text 안에서 마지막 단어만 찾아서 태그 씌우기 | |
| # 단, 여러 줄일 수 있으니까 그냥 마지막 등장 위치 찾아서 교체 | |
| highlight_tagged = r"{\c" + highlight_color + "}" + last_word + r"{\c&HFFFFFF&}" | |
| # 마지막 단어를 맨 마지막 등장 한 번만 치환 | |
| idx = clean_text.rfind(last_word) | |
| if idx != -1: | |
| clean_text = clean_text[:idx] + highlight_tagged + clean_text[idx+len(last_word):] | |
| return clean_text | |
| def generate_ass_from_srt(srt_text: str, | |
| playres_x: int = 1920, | |
| playres_y: int = 1080) -> str: | |
| subs = list(srt.parse(srt_text)) | |
| # 슬롯 상태 | |
| slot_text = { | |
| "Top": "", | |
| "Bottom": "", | |
| } | |
| events: List[EventState] = [] | |
| # 전체 종료 시간 계산용 | |
| global_end = 0.0 | |
| for idx, sub in enumerate(subs): | |
| start = td_to_seconds(sub.start) | |
| end = td_to_seconds(sub.end) | |
| if end > global_end: | |
| global_end = end | |
| raw_text = " ".join(sub.content.split()) | |
| if not raw_text: | |
| continue | |
| words = raw_text.split(" ") | |
| num_words = len(words) | |
| if num_words == 0: | |
| continue | |
| # 슬롯 결정: 0,2,4,... → Top / 1,3,5,... → Bottom | |
| slot_name = "Top" if (idx % 2 == 0) else "Bottom" | |
| # 타이밍 계산 | |
| effective_start = max(0.0, start - LEAD_TIME) | |
| effective_duration = max(MIN_EFFECTIVE_DURATION, (end - start - LEAD_TIME)) | |
| step = effective_duration / num_words | |
| # 단어가 하나씩 늘어날 때마다 이벤트 생성 | |
| # 단어 증가 이벤트들 | |
| for k in range(1, num_words + 1): | |
| t = effective_start + step * (k - 1) | |
| sentence_text = layout_sentence_with_highlight(words[:k]) | |
| slot_text[slot_name] = sentence_text | |
| events.append( | |
| EventState( | |
| time=t, | |
| top_text=slot_text["Top"], | |
| bottom_text=slot_text["Bottom"], | |
| ) | |
| ) | |
| # ★ 문장 끝난 뒤 "하이라이트 제거 버전" 추가 | |
| final_time = effective_start + step * num_words | |
| clean_text = layout_sentence(words) | |
| slot_text[slot_name] = clean_text | |
| events.append( | |
| EventState( | |
| time=final_time, | |
| top_text=slot_text["Top"], | |
| bottom_text=slot_text["Bottom"], | |
| ) | |
| ) | |
| if not events: | |
| raise ValueError("SRT에 유효한 자막이 없네.") | |
| # 시간 순으로 정렬 | |
| events.sort(key=lambda e: e.time) | |
| # 같은 시간대 이벤트는 마지막 것만 남기기 | |
| merged_events: List[EventState] = [] | |
| for ev in events: | |
| if not merged_events or abs(merged_events[-1].time - ev.time) > 1e-4: | |
| merged_events.append(ev) | |
| else: | |
| # 같은 시간이라면 최신 상태로 덮어쓰기 | |
| merged_events[-1] = ev | |
| events = merged_events | |
| # ASS 헤더 | |
| header = f"""[Script Info] | |
| ScriptType: v4.00+ | |
| PlayResX: {playres_x} | |
| PlayResY: {playres_y} | |
| WrapStyle: 2 | |
| ScaledBorderAndShadow: yes | |
| [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: Top, Pretendard,{FONT_SIZE},{COLOR_TEXT},&H00000000,{COLOR_OUTLINE},{COLOR_HIGHLIGHT}, -1,0,0,0,100,100,0,0,1,{STROKE_SIZE},0,7,60,60,80,1 | |
| Style: Bottom, Pretendard,{FONT_SIZE},{COLOR_TEXT},&H00000000,{COLOR_OUTLINE},{COLOR_HIGHLIGHT}, -1,0,0,0,100,100,0,0,1,{STROKE_SIZE},0,1,60,60,80,1 | |
| [Events] | |
| Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text | |
| """ | |
| # 이벤트를 ASS Dialogue로 변환 | |
| lines: List[str] = [header] | |
| # 마지막 이벤트 이후 끝나는 시간 | |
| timeline_end = max(global_end, events[-1].time + 0.5) | |
| for i, ev in enumerate(events): | |
| start_t = ev.time | |
| end_t = events[i + 1].time if i + 1 < len(events) else timeline_end | |
| if end_t - start_t <= 1e-3: | |
| continue # 0초짜리 같은 건 버림 | |
| start_str = seconds_to_ass_time(start_t) | |
| end_str = seconds_to_ass_time(end_t) | |
| if ev.top_text: | |
| lines.append( | |
| f"Dialogue: 0,{start_str},{end_str},Top,,0,0,0,,{ev.top_text}" | |
| ) | |
| if ev.bottom_text: | |
| lines.append( | |
| f"Dialogue: 0,{start_str},{end_str},Bottom,,0,0,0,,{ev.bottom_text}" | |
| ) | |
| return "\n".join(lines) | |
| def convert_srt_file_to_ass(srt_path: str, ass_path: str): | |
| with open(srt_path, "r", encoding="utf-8") as f: | |
| srt_text = f.read() | |
| ass_text = generate_ass_from_srt(srt_text) | |
| with open(ass_path, "w", encoding="utf-8") as f: | |
| f.write(ass_text) | |
| if __name__ == "__main__": | |
| # 대충 이렇게 쓰면 됨 | |
| import sys | |
| if len(sys.argv) != 3: | |
| print("usage: python srt_to_ass.py input.srt output.ass") | |
| sys.exit(1) | |
| convert_srt_file_to_ass(sys.argv[1], sys.argv[2]) | |
| print("완료. 알아서 ffmpeg로 덮어써라.") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment