#!/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*-->") SENTENCE_END = re.compile(r"[.!?…][\"'»«“”)\]]*(?=\s|$)") LINE_ENDS_SENTENCE = re.compile(r"[.!?…][\"'»«“”)\]]*$") MAX_SNAP_SECONDS = 30 # so weit darf ein Zeitraum bis zur nächsten Satzgrenze wachsen 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", ) parser.add_argument( "--exakt", action="store_true", help="genau an -s/-e schneiden, statt angeschnittene Sätze vollständig " f"aufzunehmen (bis {MAX_SNAP_SECONDS} s über den Zeitraum hinaus)", ) 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 timed_lines(vtt): """Return (cue start in seconds, line) for the new caption lines of a VTT document. 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 time, cue in read_cues(vtt): # Empty cues (YouTube transitions) say nothing about the window if not cue: continue lines.extend((time, line) for line in cue[overlap(previous, cue):]) previous = cue return lines def sentence_start(timed, first, start): """Lines before index `first` back to the start of the sentence cut by `start`.""" if start is None or first == 0 or LINE_ENDS_SENTENCE.search(timed[first - 1][1]): return [] extra = [] for time, line in reversed(timed[:first]): if time is None or time < start - MAX_SNAP_SECONDS: return [] ends = list(SENTENCE_END.finditer(line)) if ends: rest = line[ends[-1].end():].strip() return ([rest] if rest else []) + extra extra.insert(0, line) # Reached the first caption: the speech starts there return extra def sentence_end(timed, last, end): """Lines after index `last` up to the end of the sentence cut by `end`.""" if end is None or LINE_ENDS_SENTENCE.search(timed[last][1]): return [] extra = [] for time, line in timed[last + 1:]: if time is None or time >= end + MAX_SNAP_SECONDS: return [] match = SENTENCE_END.search(line) if match: return extra + [line[:match.end()]] extra.append(line) # Reached the last caption: the speech ends there return extra def snap_to_sentences(timed, start, end): """Lines in the range, extended so that no sentence is cut at either side. Without a sentence end within MAX_SNAP_SECONDS that side is cut exactly. """ inside = [i for i, (time, _) in enumerate(timed) if in_range(time, start, end)] if not inside: return [] first, last = inside[0], inside[-1] lines = [line for _, line in timed[first:last + 1]] return sentence_start(timed, first, start) + lines + sentence_end(timed, last, end) def vtt_to_lines(vtt, start=None, end=None, exact=False): """Return the caption text lines of a VTT document without rolling duplicates. With `start`/`end` (seconds) only the new lines of cues beginning in that range are kept, completed to whole sentences unless `exact`. The window is followed through all cues, so a line repeated from before `start` does not slip in. """ timed = timed_lines(vtt) if start is None and end is None: return [line for _, line in timed] if exact: return [line for time, line in timed if in_range(time, start, end)] return snap_to_sentences(timed, start, end) def convert(source, target, start=None, end=None, exact=False): """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, exact) 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: .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, args.exakt) if error: print(f"FEHLER: {error}", file=sys.stderr) failed += 1 if failed: sys.exit(1) if __name__ == "__main__": main()