#!/usr/bin/env python3
"""Karaoke / word-by-word captions from a Scribe transcript.
Generates an ASS subtitle file where the transcript 'plays' at the bottom — words
revealed/highlighted in sync with speech (the Submagic / Opus look). Times are
remapped onto the OUTPUT timeline using the EDL ranges, so it lines up after cuts.
Usage:
word_captions.py <transcript.json> --ranges 641.52-667.43[,783-824] -o out.ass
Options: --per-line, --size, --marginv, --hi (BGR &H..), --base, --mode {karaoke,pop}
"""
import argparse
import json
def ts(t):
cs = int(round(max(0, t) * 100))
return f"{cs//360000}:{(cs//6000)%60:02d}:{(cs//100)%60:02d}.{cs%100:02d}"
def esc(w):
return w.replace("{", "(").replace("}", ")").replace("\n", " ")
def load_words(path):
d = json.load(open(path))
return [w for w in d.get("words", []) if w.get("type", "word") == "word"]
def build(words, ranges, out, per_line=4, play=(1080, 1920), font="Helvetica Neue",
size=58, marginv=300, hi="&H005AFFD5", base="&H00FFFFFF", mode="karaoke", align=2):
events, offset = [], 0.0
for rs, re_ in ranges:
seg = [w for w in words if w["start"] >= rs - 0.05 and w["end"] <= re_ + 0.05]
for i in range(0, len(seg), per_line):
grp = seg[i:i + per_line]
if not grp:
continue
if mode == "pop":
# each word appears alone, centered-bottom, as spoken
for j, w in enumerate(grp):
s = w["start"] - rs + offset
e = (grp[j + 1]["start"] if j + 1 < len(grp) else w["end"] + 0.15) - rs + offset
events.append((s, e, f"{{\\fad(60,40)}}{esc(w['text'].upper())}"))
else: # karaoke: whole line, highlight sweeps word by word
ls = grp[0]["start"] - rs + offset
le = grp[-1]["end"] - rs + offset + 0.15
txt = "".join(f"{{\\kf{max(1,int(round((w['end']-w['start'])*100)))}}}{esc(w['text'].upper())} "
for w in grp).strip()
events.append((ls, le, txt))
offset += re_ - rs
head = (
"[Script Info]\nScriptType: v4.00+\n"
f"PlayResX: {play[0]}\nPlayResY: {play[1]}\nWrapStyle: 2\nScaledBorderAndShadow: yes\n\n"
"[V4+ Styles]\n"
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, "
"BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, "
"BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
f"Style: Cap,{font},{size},{hi},{base},&H00000000,&H64000000,1,0,0,0,100,100,0,0,1,6,1,{align},80,80,{marginv},1\n\n"
"[Events]\n"
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
)
body = "".join(f"Dialogue: 0,{ts(s)},{ts(e)},Cap,,0,0,0,,{t}\n" for s, e, t in events)
open(out, "w").write(head + body)
return len(events)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("transcript")
ap.add_argument("--ranges", required=True, help="comma list of start-end seconds")
ap.add_argument("-o", "--out", required=True)
ap.add_argument("--per-line", type=int, default=4)
ap.add_argument("--size", type=int, default=58)
ap.add_argument("--marginv", type=int, default=300)
ap.add_argument("--hi", default="&H005AFFD5")
ap.add_argument("--base", default="&H00FFFFFF")
ap.add_argument("--mode", default="karaoke", choices=["karaoke", "pop"])
ap.add_argument("--align", type=int, default=2, help="ASS alignment: 2=bottom, 5=middle, 8=top")
a = ap.parse_args()
ranges = [tuple(float(x) for x in r.split("-")) for r in a.ranges.split(",")]
n = build(load_words(a.transcript), ranges, a.out, a.per_line, (1080, 1920),
"Helvetica Neue", a.size, a.marginv, a.hi, a.base, a.mode, a.align)
print(f"wrote {a.out} ({n} caption events)")
if __name__ == "__main__":
main()