| from __future__ import annotations |
|
|
| import html |
| from dataclasses import dataclass |
| from pathlib import Path |
|
|
| from renderer.templates import get_template |
|
|
|
|
| @dataclass |
| class SubtitleEvent: |
| start: float |
| end: float |
| text: str |
|
|
|
|
| class SubtitleGenerator: |
| def from_scenes(self, scenes: list, total_duration: float | None = None) -> list[SubtitleEvent]: |
| events: list[SubtitleEvent] = [] |
| for scene in scenes: |
| if scene.caption: |
| events.append(SubtitleEvent(scene.start, scene.start + scene.duration, scene.caption)) |
| if not events and total_duration: |
| events.append(SubtitleEvent(0, total_duration, "")) |
| return events |
|
|
| def write_srt(self, events: list[SubtitleEvent], output: Path) -> Path: |
| lines: list[str] = [] |
| for idx, event in enumerate(events, start=1): |
| lines.extend([str(idx), f"{_srt_time(event.start)} --> {_srt_time(event.end)}", event.text, ""]) |
| output.write_text("\n".join(lines), encoding="utf-8") |
| return output |
|
|
| def write_ass(self, events: list[SubtitleEvent], output: Path, template_key: str) -> Path: |
| template = get_template(template_key) |
| body = [ |
| "[Script Info]", |
| "ScriptType: v4.00+", |
| "PlayResX: 1080", |
| "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", |
| template.ass_style(), |
| "", |
| "[Events]", |
| "Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text", |
| ] |
| for event in events: |
| text = _ass_escape(event.text) |
| if template.effect == "karaoke": |
| text = _karaoke_text(text, event.end - event.start) |
| elif template.effect == "zoom": |
| text = r"{\t(0,180,\fscx115\fscy115)\t(180,360,\fscx100\fscy100)}" + text |
| elif template.effect == "bounce": |
| text = r"{\t(0,120,\frz-2)\t(120,240,\frz2)\t(240,360,\frz0)}" + text |
| body.append(f"Dialogue: 0,{_ass_time(event.start)},{_ass_time(event.end)},Default,,0,0,0,,{text}") |
| output.write_text("\n".join(body), encoding="utf-8") |
| return output |
|
|
|
|
| def _karaoke_text(text: str, duration: float) -> str: |
| words = text.split() |
| if not words: |
| return text |
| centiseconds = max(1, int(duration * 100 / len(words))) |
| return "".join(f"{{\\k{centiseconds}}}{word} " for word in words).strip() |
|
|
|
|
| def _srt_time(seconds: float) -> str: |
| ms = int(round(seconds * 1000)) |
| h, rem = divmod(ms, 3600000) |
| m, rem = divmod(rem, 60000) |
| s, ms = divmod(rem, 1000) |
| return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" |
|
|
|
|
| def _ass_time(seconds: float) -> str: |
| cs = int(round(seconds * 100)) |
| h, rem = divmod(cs, 360000) |
| m, rem = divmod(rem, 6000) |
| s, cs = divmod(rem, 100) |
| return f"{h}:{m:02d}:{s:02d}.{cs:02d}" |
|
|
|
|
| def _ass_escape(text: str) -> str: |
| return html.escape(text).replace("\n", r"\N").replace("{", r"\{").replace("}", r"\}") |
|
|