from __future__ import annotations import json from pathlib import Path from .models import Timeline def _srt_time(seconds: float) -> str: ms = int(round(max(0.0, seconds) * 1000)) h, rem = divmod(ms, 3_600_000) m, rem = divmod(rem, 60_000) s, ms = divmod(rem, 1000) return f"{h:02}:{m:02}:{s:02},{ms:03}" def _ass_time(seconds: float) -> str: centiseconds = int(round(max(0.0, seconds) * 100)) h, rem = divmod(centiseconds, 360_000) m, rem = divmod(rem, 6_000) s, cs = divmod(rem, 100) return f"{h}:{m:02}:{s:02}.{cs:02}" def write_json(timeline: Timeline, output: Path) -> Path: output.write_text(timeline.model_dump_json(indent=2), encoding="utf-8") return output def write_words_json(timeline: Timeline, output: Path) -> Path: data = [word.model_dump() for word in timeline.words] output.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") return output def write_accessibility_srt(timeline: Timeline, output: Path) -> Path: lines: list[str] = [] for i, phrase in enumerate(timeline.phrases, 1): lines.extend([ str(i), f"{_srt_time(phrase.start)} --> {_srt_time(phrase.end)}", phrase.text, "", ]) output.write_text("\n".join(lines), encoding="utf-8-sig") return output def _ass_escape(text: str) -> str: return text.replace("\\", r"\\").replace("{", r"\{").replace("}", r"\}") def write_wordbeat_ass( timeline: Timeline, output: Path, *, width: int = 3840, height: int = 2160, font_name: str = "Arial", font_size: int = 92, ) -> Path: header = f"""[Script Info] Title: WordBeat Phrase-Building Captions ScriptType: v4.00+ PlayResX: {width} PlayResY: {height} WrapStyle: 2 ScaledBorderAndShadow: yes YCbCr Matrix: TV.709 [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: WordBeat,{font_name},{font_size},&H00FFFFFF,&H0000FFFF,&H00101010,&H90000000,-1,0,0,0,100,100,0,0,3,5,0,2,180,180,145,1 [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text """ lines = [header.rstrip()] for i, event in enumerate(timeline.events): start = event.reveal_frame / timeline.fps if i + 1 < len(timeline.events): end = timeline.events[i + 1].reveal_frame / timeline.fps else: end = event.end_frame / timeline.fps end = max(end, start + 1.0 / timeline.fps) # Highlight only the newly added word while retaining the phrase. phrase_words = event.visible_text.split() prior = " ".join(phrase_words[:-1]) current = phrase_words[-1] if phrase_words else event.word if prior: text = _ass_escape(prior) + " " else: text = "" pulse_ms = int(round(event.pulse_frames / timeline.fps * 1000)) text += ( r"{\c&H00FFFF&\fscx112\fscy112\t(0," + str(pulse_ms) + r",\c&HFFFFFF&\fscx100\fscy100)}" + _ass_escape(current) ) lines.append( f"Dialogue: 0,{_ass_time(start)},{_ass_time(end)},WordBeat,,0,0,0,,{text}" ) output.write_text("\n".join(lines) + "\n", encoding="utf-8-sig") return output