tts_txt.py reads a plain text file aloud with the lektor voice. The pipeline is built around subtitles — every clip is placed at a timestamp — so prose needs the one piece that is genuinely missing, a chunker that splits text into sentence-sized pieces (240 chars, on sentence boundaries, long sentences split at clause breaks). Everything else is lektor_ai_comfy's: the dictionary, the Polish number normalizer, the ComfyUI call with its truncation retries and crash recovery, the per-clip trim and speed-up. Clips land in output/clips/<stem>/ like a film's, so --resume-from and re-assembly work the same way, and --dry-run prints the chunks without generating anything. tmp2srt.py converts TMPlayer subtitles (HH:MM:SS:text, '|' for a line break) to SRT. TMPlayer stores only a start time, so an end is inferred: the next cue's start less a small gap, or sooner when 16 chars/second says the line can be read in less. The separator after the timestamp is optional in the wild — some files use ':', some a space, some nothing — so all three parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NkWDx9dVB7xBpqntvjvm6A
79 lines
2.4 KiB
Python
79 lines
2.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Convert TMPlayer subtitles (HH:MM:SS:text, '|' = line break) to SRT.
|
|
|
|
TMPlayer stores only a start time per line, so end times are inferred:
|
|
each cue ends when the next one starts (minus a small gap), or earlier if
|
|
its text is short enough to be read sooner.
|
|
|
|
python3 tmp2srt.py input/film.txt [output.srt]
|
|
"""
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# A separator after the timestamp is optional in the wild: some lines use
|
|
# ':', some a bare space, some nothing at all.
|
|
LINE_RE = re.compile(r"^\s*(\d{1,2}):(\d{2}):(\d{2}):?\s*(.*)$")
|
|
|
|
CPS = 16.0 # reading speed used to size a cue when there is room
|
|
MIN_DUR = 1.2 # s
|
|
MAX_DUR = 7.0 # s
|
|
GAP = 0.08 # s left between consecutive cues
|
|
|
|
|
|
def _fmt(t: float) -> str:
|
|
ms = int(round(t * 1000))
|
|
h, ms = divmod(ms, 3600000)
|
|
m, ms = divmod(ms, 60000)
|
|
s, ms = divmod(ms, 1000)
|
|
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
|
|
|
|
|
def parse(path: Path):
|
|
cues, skipped = [], []
|
|
for n, raw in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), 1):
|
|
if not raw.strip():
|
|
continue
|
|
m = LINE_RE.match(raw)
|
|
if not m:
|
|
skipped.append((n, raw))
|
|
continue
|
|
h, mi, s, text = m.groups()
|
|
text = text.replace("|", "\n").strip()
|
|
if not text:
|
|
continue
|
|
cues.append((int(h) * 3600 + int(mi) * 60 + int(s), text))
|
|
cues.sort(key=lambda c: c[0])
|
|
return cues, skipped
|
|
|
|
|
|
def to_srt(cues) -> str:
|
|
out = []
|
|
for i, (start, text) in enumerate(cues):
|
|
wanted = len(text.replace("\n", " ")) / CPS
|
|
end = start + max(MIN_DUR, min(MAX_DUR, wanted))
|
|
if i + 1 < len(cues):
|
|
end = min(end, cues[i + 1][0] - GAP)
|
|
if end <= start: # cues closer together than MIN_DUR
|
|
end = (cues[i + 1][0] if i + 1 < len(cues) else start + MIN_DUR) - 0.01
|
|
if end <= start:
|
|
end = start + 0.1
|
|
out.append(f"{i + 1}\n{_fmt(start)} --> {_fmt(end)}\n{text}\n")
|
|
return "\n".join(out)
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) < 2:
|
|
sys.exit(__doc__)
|
|
src = Path(sys.argv[1])
|
|
dst = Path(sys.argv[2]) if len(sys.argv) > 2 else src.with_suffix(".srt")
|
|
cues, skipped = parse(src)
|
|
dst.write_text(to_srt(cues), encoding="utf-8")
|
|
print(f"{src} -> {dst}: {len(cues)} cues")
|
|
for n, raw in skipped:
|
|
print(f" skipped line {n}: {raw[:70]!r}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|