Files
lektor-ai/tmp2srt.py
T
claudeandClaude Opus 5 4a9b05de66 Add two standalone tools: prose to speech, TMPlayer to SRT
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
2026-08-31 21:14:00 +02:00

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()