Skip to content

Instantly share code, notes, and snippets.

@ouor
Last active November 22, 2025 04:19
Show Gist options
  • Select an option

  • Save ouor/5825cb770efaf75a8ec884c22fc8e45e to your computer and use it in GitHub Desktop.

Select an option

Save ouor/5825cb770efaf75a8ec884c22fc8e45e to your computer and use it in GitHub Desktop.
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