#!/usr/bin/env python3 """Offline, current-word-only ASS subtitles. Python 3.9+, standard library only.""" import argparse import json from pathlib import Path FONT_FAMILY = "DejaVu Sans" _WHITE = r"{\1c&HFFFFFF&}" _YELLOW = r"{\1c&H00FFFF&}" # ASS uses BGR, not RGB. def _required(obj, key, path=""): name = path + key if key not in obj: raise ValueError("missing required field '" + name + "'") return obj[key] def _integer(value, name): if type(value) is not int: # JSON booleans must not pass as integers. raise ValueError(name + " must be an integer (not a boolean)") return value def _time(value, name): value = _integer(value, name) if value % 10: raise ValueError(name + " must be a multiple of 10 milliseconds") return value def _validate_spec(spec): """Validate and copy the fields used by the tool; never normalize input.""" if not isinstance(spec, dict): raise ValueError("spec must be an object") duration = _time(_required(spec, "duration_ms"), "duration_ms") if duration <= 0: raise ValueError("duration_ms must be positive") dimensions = [] for key in ("width", "height"): value = _integer(_required(spec, key), key) if value <= 0: raise ValueError(key + " must be positive") dimensions.append(value) words = _required(spec, "words") if not isinstance(words, list) or not words: raise ValueError("words must be a non-empty list") copied = [] previous_end = 0 for index, word in enumerate(words): path = "words[{}]".format(index) if not isinstance(word, dict): raise ValueError(path + " must be an object") text = _required(word, "text", path + ".") if not isinstance(text, str) or not text.strip(): raise ValueError(path + ".text must be a non-empty string") if any(char in text for char in "\\{}\r\n"): raise ValueError(path + ".text must not contain backslash, braces, CR or LF") if "\x00" in text: raise ValueError(path + ".text must not contain NUL") try: text.encode("utf-8") except UnicodeEncodeError as error: raise ValueError(path + ".text must contain valid Unicode scalar values") from error start = _time(_required(word, "start_ms", path + "."), path + ".start_ms") end = _time(_required(word, "end_ms", path + "."), path + ".end_ms") if not 0 <= start < end <= duration: raise ValueError(path + " must satisfy 0 <= start_ms < end_ms <= duration_ms") if start < previous_end: raise ValueError(path + " intervals must be sorted and non-overlapping") copied.append((text, start, end)) previous_end = end return duration, dimensions[0], dimensions[1], copied def _events(duration, words): sentence = " ".join(word[0] for word in words) result = [] cursor = 0 for index, (_, start, end) in enumerate(words): if cursor < start: result.append(dict(start_ms=cursor, end_ms=start, active_index=None, text=sentence)) result.append(dict(start_ms=start, end_ms=end, active_index=index, text=sentence)) cursor = end if cursor < duration: result.append(dict(start_ms=cursor, end_ms=duration, active_index=None, text=sentence)) return result def build_events(spec): """Return gap-free, non-overlapping [start_ms, end_ms) event dictionaries. Every event carries the complete sentence. active_index is None in pauses. Invalid specifications raise ValueError. The input is never changed. """ duration, _, _, words = _validate_spec(spec) return _events(duration, words) def _ass_time(milliseconds): seconds, centiseconds = divmod(milliseconds // 10, 100) minutes, seconds = divmod(seconds, 60) hours, minutes = divmod(minutes, 60) return "{}:{:02d}:{:02d}.{:02d}".format(hours, minutes, seconds, centiseconds) def generate_ass(spec): """Return a full ASS v4+ script with only the currently spoken word yellow.""" duration, width, height, words = _validate_spec(spec) font_size = max(1, min(511, width // 20, height // 12)) margin_x, margin_y = width // 20, height // 20 lines = [ "[Script Info]", "; Generated by subtitle_workflow.py", "Title: Current spoken word", "ScriptType: v4.00+", "ScaledBorderAndShadow: yes", "YCbCr Matrix: None", "PlayResX: {}".format(width), "PlayResY: {}".format(height), "LayoutResX: {}".format(width), "LayoutResY: {}".format(height), "WrapStyle: 0", "", "[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,{},{},&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000," "0,0,0,0,100,100,0,0,1,2,0,2,{},{},{},1".format( FONT_FAMILY, font_size, margin_x, margin_x, margin_y ), "", "[Events]", "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text", ] for event in _events(duration, words): active = event["active_index"] # Each event starts white; reset immediately after the active token so # even the spaces remain white. No karaoke state carries across words. payload = _WHITE + " ".join( _YELLOW + word[0] + _WHITE if index == active else word[0] for index, word in enumerate(words) ) lines.append("Dialogue: 0,{},{},Default,,0,0,0,,{}".format( _ass_time(event["start_ms"]), _ass_time(event["end_ms"]), payload )) return "\n".join(lines) + "\n" def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("input_json", type=Path) parser.add_argument("output_ass", type=Path) args = parser.parse_args(argv) try: if args.input_json.resolve() == args.output_ass.resolve(): raise ValueError("input and output paths must differ") spec = json.loads(args.input_json.read_text(encoding="utf-8-sig")) subtitle = generate_ass(spec) # Finish validation before opening output. with args.output_ass.open("w", encoding="utf-8", newline="\n") as output: output.write(subtitle) except (ValueError, OSError) as error: parser.error(str(error)) return 0 if __name__ == "__main__": raise SystemExit(main())