youtube_abschrift_korrigieren/vtt2txt.py
dschlueter 1668a8dcad feat: Zeitraum-Auswahl (-s/-e) in vtt2txt.py
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:06:36 +02:00

250 lines
7.9 KiB
Python
Executable file

#!/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"[*?\[]")
TIME = re.compile(r"(?:(\d+):)??(?:(\d+):)?(\d+(?:[.,]\d+)?)")
CUE_START = re.compile(r"^\s*([\d:.,]+)\s*-->")
def parse_time(text):
"""Seconds from "00:03:17", "3:17", "197" or "00:03:17.5"."""
match = TIME.fullmatch(text.strip())
if not match:
raise ValueError(f"Ungültige Zeit: {text}")
hours, minutes, seconds = match.groups()
return int(hours or 0) * 3600 + int(minutes or 0) * 60 + float(seconds.replace(",", "."))
def time_option(text):
"""argparse type for -s/--start and -e/--ende."""
try:
return parse_time(text)
except ValueError:
raise argparse.ArgumentTypeError(
f"ungültige Zeit „{text}“ (z. B. 00:03:17, 3:17 oder 197)"
) from None
def format_time(seconds, separator=":"):
seconds = int(seconds)
return separator.join(
f"{value:02d}" for value in (seconds // 3600, seconds // 60 % 60, seconds % 60)
)
def time_suffix(start, end):
"""Part of a default file name for a time range: "_00-03-17_bis_00-05-25"."""
if start is None and end is None:
return ""
if end is None:
return f"_ab_{format_time(start, '-')}"
if start is None:
return f"_bis_{format_time(end, '-')}"
return f"_{format_time(start, '-')}_bis_{format_time(end, '-')}"
def add_time_options(parser):
parser.add_argument(
"-s", "--start", type=time_option, metavar="ZEIT",
help="nur Untertitel ab dieser Zeit, z. B. 00:03:17",
)
parser.add_argument(
"-e", "--ende", type=time_option, metavar="ZEIT",
help="nur Untertitel vor dieser Zeit, z. B. 00:05:25",
)
def check_time_range(parser, args):
if args.start is not None and args.ende is not None and args.start >= args.ende:
parser.error("-s/--start muss vor -e/--ende liegen.")
def read_cues(vtt):
"""Yield (start in seconds, non-empty tag-free text lines) of each cue."""
cue = None
start = None
for line in vtt.splitlines():
if "-->" in line:
if cue is not None:
yield start, cue
match = CUE_START.match(line)
try:
start = parse_time(match.group(1)) if match else None
except ValueError:
start = None
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 start, 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 start, 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 in_range(time, start, end):
if time is None:
return start is None and end is None
return (start is None or time >= start) and (end is None or time < end)
def vtt_to_lines(vtt, start=None, end=None):
"""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.
With `start`/`end` (seconds) only the new lines of cues beginning in that
range are kept. The window is followed through all cues, so a line repeated
from before `start` does not slip in.
"""
lines = []
previous = []
for time, cue in read_cues(vtt):
# Empty cues (YouTube transitions) say nothing about the window
if not cue:
continue
if in_range(time, start, end):
lines.extend(cue[overlap(previous, cue):])
previous = cue
return lines
def convert(source, target, start=None, end=None):
"""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, start, end)
if not lines:
where = "" if start is None and end is None else " im gewählten Zeitraum"
return f"Keine Untertitel{where} 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, "
"mit -s/-e zusätzlich der Zeitraum im Namen",
)
add_time_options(parser)
args = parser.parse_args()
check_time_range(parser, args)
suffix = time_suffix(args.start, args.ende)
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:
name = f"{source.stem}{suffix}.txt"
if output is None:
target = source.with_name(name)
elif to_dir:
target = Path(output) / 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, args.start, args.ende)
if error:
print(f"FEHLER: {error}", file=sys.stderr)
failed += 1
if failed:
sys.exit(1)
if __name__ == "__main__":
main()