feat: VTT-Untertitel in Rohabschrift umwandeln (vtt2txt.py)
- vtt2txt.py als Kommandozeilenprogramm: -i nimmt mehrere Dateien und Muster (auch ohne Shell-Expansion), -o eine Datei oder ein Verzeichnis - Rollende YouTube-Untertitel über die Überlappung aufeinanderfolgender Blöcke entdoppeln; echte Wiederholungen des Redners bleiben erhalten - Fix: Blöcke nur an wirklich leeren Zeilen trennen; Text nach einer Leerzeichen-Zeile ging verloren (u. a. das letzte [applaus]) - Schutz gegen Überschreiben der Eingabe und doppelte Ausgabedateien, klare Meldung bei Nicht-UTF-8, Stapel läuft bei Fehlern weiter - rede_korrigieren.py nimmt .vtt direkt an - testdaten/ramstein.de.txt neu erzeugt (+ abschließendes [applaus]) - pytest: global installiertes asyncio-Plugin abschalten (Warnung) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6e5117a33b
commit
99238870f7
1 changed files with 386 additions and 3 deletions
172
vtt2txt.py
Executable file
172
vtt2txt.py
Executable file
|
|
@ -0,0 +1,172 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Wandelt WebVTT-Untertitel (z. B. YouTube-Auto-Untertitel) in eine Rohabschrift um,
|
||||
die anschließend mit rede_korrigieren.py bearbeitet werden kann."""
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import glob
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
TAG = re.compile(r"<[^>]*>")
|
||||
GLOB_CHARS = re.compile(r"[*?\[]")
|
||||
|
||||
|
||||
def read_cues(vtt):
|
||||
"""Yield the non-empty, tag-free text lines of each cue."""
|
||||
cue = None
|
||||
|
||||
for line in vtt.splitlines():
|
||||
if "-->" in line:
|
||||
if cue is not None:
|
||||
yield cue
|
||||
cue = []
|
||||
continue
|
||||
# Only a truly empty line ends a cue; YouTube puts lines with a single
|
||||
# space inside cues, followed by more text
|
||||
if not line:
|
||||
if cue is not None:
|
||||
yield cue
|
||||
cue = None
|
||||
continue
|
||||
# Header, NOTE/STYLE blocks and cue identifiers
|
||||
if cue is None:
|
||||
continue
|
||||
|
||||
text = " ".join(html.unescape(TAG.sub("", line)).split())
|
||||
if text:
|
||||
cue.append(text)
|
||||
|
||||
if cue is not None:
|
||||
yield cue
|
||||
|
||||
|
||||
def overlap(previous, cue):
|
||||
"""Length of the longest end of `previous` that starts `cue`."""
|
||||
for size in range(min(len(previous), len(cue)), 0, -1):
|
||||
if previous[-size:] == cue[:size]:
|
||||
return size
|
||||
return 0
|
||||
|
||||
|
||||
def vtt_to_lines(vtt):
|
||||
"""Return the caption text lines of a VTT document without rolling duplicates.
|
||||
|
||||
Auto captions show a rolling window: each cue repeats the end of the previous
|
||||
cue before adding new text. Only the lines after that overlap are new, so a
|
||||
line the speaker really says twice in a row is kept.
|
||||
"""
|
||||
lines = []
|
||||
previous = []
|
||||
|
||||
for cue in read_cues(vtt):
|
||||
# Empty cues (YouTube transitions) say nothing about the window
|
||||
if not cue:
|
||||
continue
|
||||
lines.extend(cue[overlap(previous, cue):])
|
||||
previous = cue
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def convert(source, target):
|
||||
"""Convert one file; return an error message or None on success."""
|
||||
try:
|
||||
vtt = source.read_text(encoding="utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
return f"Keine UTF-8-Datei: {source}"
|
||||
|
||||
lines = vtt_to_lines(vtt)
|
||||
if not lines:
|
||||
return f"Keine Untertitel gefunden: {source}"
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
print(f"Erstellt: {target} ({len(lines)} Zeilen)")
|
||||
return None
|
||||
|
||||
|
||||
def expand_inputs(patterns):
|
||||
"""Resolve glob patterns (for shells that do not, e.g. on Windows).
|
||||
|
||||
Returns the files in order without duplicates, or the first pattern
|
||||
without match.
|
||||
"""
|
||||
sources = []
|
||||
for pattern in patterns:
|
||||
if GLOB_CHARS.search(pattern):
|
||||
matches = sorted(glob.glob(pattern))
|
||||
if not matches:
|
||||
return None, pattern
|
||||
else:
|
||||
matches = [pattern]
|
||||
for match in matches:
|
||||
path = Path(match)
|
||||
if path not in sources:
|
||||
sources.append(path)
|
||||
return sources, None
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Wandelt VTT-Untertitel in eine Textdatei für rede_korrigieren.py um."
|
||||
)
|
||||
parser.add_argument(
|
||||
"-i", "--input", required=True, nargs="+",
|
||||
help="VTT-Datei(en) oder Muster, z. B. \"ramstein.de*.vtt\"",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o", "--output",
|
||||
help="Ausgabedatei (nur bei einer Eingabedatei) oder Verzeichnis "
|
||||
"(vorhanden oder mit / am Ende); Standard: <Eingabe ohne Endung>.txt",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
sources, unmatched = expand_inputs(args.input)
|
||||
if unmatched is not None:
|
||||
parser.error(f"Keine Datei passt zu: {unmatched}")
|
||||
for source in sources:
|
||||
if not source.is_file():
|
||||
parser.error(f"Eingabedatei nicht gefunden: {source}")
|
||||
if source.suffix.lower() != ".vtt":
|
||||
parser.error(f"Keine VTT-Datei: {source}")
|
||||
|
||||
output = args.output
|
||||
to_dir = output is not None and (
|
||||
output.endswith(("/", os.sep)) or Path(output).is_dir()
|
||||
)
|
||||
if output is not None and not to_dir and len(sources) > 1:
|
||||
parser.error("Bei mehreren Eingabedateien muss -o/--output ein Verzeichnis sein.")
|
||||
|
||||
args.jobs = []
|
||||
for source in sources:
|
||||
if output is None:
|
||||
target = source.with_suffix(".txt")
|
||||
elif to_dir:
|
||||
target = Path(output) / source.with_suffix(".txt").name
|
||||
else:
|
||||
target = Path(output)
|
||||
if target.resolve() == source.resolve():
|
||||
parser.error("Ausgabedatei darf nicht die Eingabedatei sein.")
|
||||
if any(target.resolve() == other.resolve() for _, other in args.jobs):
|
||||
parser.error(f"Mehrere Eingabedateien ergeben dieselbe Ausgabedatei: {target}")
|
||||
args.jobs.append((source, target))
|
||||
return args
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
failed = 0
|
||||
# Keep going on errors so one bad file does not stop a batch
|
||||
for source, target in args.jobs:
|
||||
error = convert(source, target)
|
||||
if error:
|
||||
print(f"FEHLER: {error}", file=sys.stderr)
|
||||
failed += 1
|
||||
if failed:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in a new issue