From 99238870f73f42e66c35efd82922e5a0422fd2a7 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Sat, 10 Oct 2026 17:50:51 +0200 Subject: [PATCH] feat: VTT-Untertitel in Rohabschrift umwandeln (vtt2txt.py) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 39 +++++++- pytest.ini | 2 + rede_korrigieren.py | 10 +- tests/test_rede_korrigieren.py | 15 +++ tests/test_vtt2txt.py | 157 ++++++++++++++++++++++++++++++ vtt2txt.py | 172 +++++++++++++++++++++++++++++++++ 6 files changed, 389 insertions(+), 6 deletions(-) create mode 100644 tests/test_vtt2txt.py create mode 100755 vtt2txt.py diff --git a/README.md b/README.md index 415a659..de9f7bd 100644 --- a/README.md +++ b/README.md @@ -17,15 +17,42 @@ der Lauf wird etwa 12-mal langsamer. ## Aufruf +### Untertitel umwandeln: `vtt2txt.py` + +Macht aus VTT-Untertiteln (z. B. von `yt-dlp --write-auto-subs`) die Rohabschrift +für `rede_korrigieren.py`: Zeitstempel und Tags werden entfernt. YouTube-Auto-Untertitel +zeigen ein rollendes Fenster, jeder Block wiederholt das Ende des vorigen. Übernommen +wird nur, was nach dieser Überlappung neu ist; sagt der Redner eine Zeile wirklich +zweimal, bleibt sie zweimal erhalten. + ```bash -./rede_korrigieren.py -i rede.txt # → rede.korrigiert.txt -./rede_korrigieren.py -i rede.txt -o out/rede.txt # eigene Ausgabedatei -./rede_korrigieren.py -i rede.txt -g rede.glossar.txt # mit Glossar +./vtt2txt.py -i rede.de.vtt # → rede.de.txt +./vtt2txt.py -i ramstein.de*.vtt # mehrere Dateien, je → *.txt +./vtt2txt.py -i "ramstein.de*.vtt" # Muster löst das Programm auf (Windows) +./vtt2txt.py -i rede.de.vtt -o out/rede.txt # eigene Ausgabedatei +./vtt2txt.py -i *.vtt -o out/ # alle in ein Verzeichnis ``` | Option | Bedeutung | |---|---| -| `-i`, `--input` | Eingabedatei (Rohabschrift), Pflicht | +| `-i`, `--input` | eine oder mehrere VTT-Dateien oder Muster, Pflicht | +| `-o`, `--output` | Ausgabedatei (nur bei einer Eingabedatei) oder Verzeichnis (vorhanden oder mit `/` am Ende); Standard: `.txt` neben der Eingabe | + +Eine fehlerhafte Datei (keine Untertitel, nicht UTF-8) wird gemeldet, die übrigen +werden trotzdem umgewandelt; der Exit-Code ist dann 1. + +### Abschrift korrigieren: `rede_korrigieren.py` + +```bash +./rede_korrigieren.py -i rede.txt # → rede.korrigiert.txt +./rede_korrigieren.py -i rede.txt -o out/rede.txt # eigene Ausgabedatei +./rede_korrigieren.py -i rede.txt -g rede.glossar.txt # mit Glossar +./rede_korrigieren.py -i rede.de.vtt # VTT direkt → rede.de.korrigiert.txt +``` + +| Option | Bedeutung | +|---|---| +| `-i`, `--input` | Eingabedatei: Rohabschrift (`.txt`) oder Untertitel (`.vtt`, wird wie mit `vtt2txt.py` umgewandelt), Pflicht | | `-o`, `--output` | Ausgabedatei; Standard: `.korrigiert.txt` neben der Eingabe | | `-g`, `--glossar` | Glossar mit festen Ersetzungen und Schreibweisen | @@ -69,6 +96,7 @@ Ersetzungen ohne Treffer werden gemeldet (Tippfehler im Glossar). - `ramstein.de.txt`: daraus gewonnener Rohtext (Eingabe für das Programm) ```bash +./vtt2txt.py -i testdaten/ramstein.de.vtt # erzeugt testdaten/ramstein.de.txt neu ./rede_korrigieren.py -i testdaten/ramstein.de.txt -g ramstein.glossar.txt # → testdaten/ramstein.de.korrigiert.txt (nicht versioniert) ``` @@ -85,6 +113,9 @@ Die Tests brauchen kein Ollama: Modellaufrufe werden durch Fakes ersetzt. Geprü werden Abschnittsbildung, Glossar (inkl. „jede Ersetzung in `ramstein.glossar.txt` trifft die Testdaten“), Streaming-Auswertung, Absatzgliederung (Text bleibt unverändert, 3–10 Sätze), Zwischenspeicher/Fortsetzen und die Kommandozeile. +`tests/test_vtt2txt.py` prüft die VTT-Umwandlung: `testdaten/ramstein.de.vtt` +ergibt genau `testdaten/ramstein.de.txt`, echte Wiederholungen bleiben erhalten, +dazu Muster, Ausgabeverzeichnis und Fehlerfälle. ## Zwischenstände diff --git a/pytest.ini b/pytest.ini index 4584de7..a1ea7e7 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,5 @@ [pytest] testpaths = tests pythonpath = . +# Project has no async code; skip the globally installed asyncio plugin +addopts = -p no:asyncio diff --git a/rede_korrigieren.py b/rede_korrigieren.py index cbeb75f..47f06b4 100755 --- a/rede_korrigieren.py +++ b/rede_korrigieren.py @@ -9,6 +9,8 @@ import sys import time import urllib.request +from vtt2txt import vtt_to_lines + MODEL = "qwen3:32b" OLLAMA_URL = "http://127.0.0.1:11434/api/chat" @@ -356,7 +358,8 @@ def parse_args(): f"mit {MODEL} über Ollama." ) parser.add_argument( - "-i", "--input", required=True, type=Path, help="Eingabedatei (Rohabschrift)" + "-i", "--input", required=True, type=Path, + help="Eingabedatei: Rohabschrift (.txt) oder Untertitel (.vtt)", ) parser.add_argument( "-o", "--output", type=Path, @@ -388,8 +391,11 @@ def main(): terms, replacements = read_glossary(args.glossar) if args.glossar else ([], []) system = build_system(terms) + raw = source.read_text(encoding="utf-8-sig") + if source.suffix.lower() == ".vtt": + raw = "\n".join(vtt_to_lines(raw)) # Subtitle line breaks carry no meaning; send plain running text - text = " ".join(source.read_text(encoding="utf-8").split()) + text = " ".join(raw.split()) if args.glossar: print( diff --git a/tests/test_rede_korrigieren.py b/tests/test_rede_korrigieren.py index 171a819..f34c079 100644 --- a/tests/test_rede_korrigieren.py +++ b/tests/test_rede_korrigieren.py @@ -281,6 +281,21 @@ def test_main_output_option_creates_directories(sample_copy, monkeypatch): assert (target.parent / "rede.parts").is_dir() +def test_main_vtt_input_matches_txt_input(sample_copy, monkeypatch): + monkeypatch.setattr(rk, "correct_chunk", identity_correct) + monkeypatch.setattr(rk, "find_paragraph_starts", every_nth_start(5)) + vtt = sample_copy.parent / "vtt" / "ramstein.de.vtt" + vtt.parent.mkdir() + shutil.copy(SAMPLE.with_suffix(".vtt"), vtt) + + run_main(monkeypatch, "-i", sample_copy) + run_main(monkeypatch, "-i", vtt) + expected = sample_copy.with_name("ramstein.de.korrigiert.txt") + assert vtt.with_name("ramstein.de.korrigiert.txt").read_text( + encoding="utf-8" + ) == expected.read_text(encoding="utf-8") + + def test_main_warns_on_word_count_deviation(sample_copy, monkeypatch, capsys): def shortening(chunk, index, system): words = chunk.split() diff --git a/tests/test_vtt2txt.py b/tests/test_vtt2txt.py new file mode 100644 index 0000000..f4357e7 --- /dev/null +++ b/tests/test_vtt2txt.py @@ -0,0 +1,157 @@ +"""Tests for vtt2txt.py.""" +import shutil +import sys +from pathlib import Path + +import pytest + +import vtt2txt + +REPO = Path(__file__).resolve().parent.parent +SAMPLE_VTT = REPO / "testdaten" / "ramstein.de.vtt" +SAMPLE_TXT = REPO / "testdaten" / "ramstein.de.txt" + + +@pytest.fixture +def sample_copy(tmp_path): + target = tmp_path / SAMPLE_VTT.name + shutil.copy(SAMPLE_VTT, target) + return target + + +def cue(start, *lines): + return f"00:00:{start:02d}.000 --> 00:00:{start + 1:02d}.000\n" + "\n".join(lines) + "\n\n" + + +def run_main(monkeypatch, *args): + monkeypatch.setattr(sys, "argv", ["vtt2txt.py", *map(str, args)]) + vtt2txt.main() + + +def test_vtt_to_lines_strips_tags_and_rolling_duplicates(): + vtt = ( + "WEBVTT\nKind: captions\n\n" + "00:00:01.000 --> 00:00:02.000 align:start\n" + "Erste<00:00:01.500> Zeile\n\n" + "00:00:02.000 --> 00:00:03.000\nErste Zeile\n \n\n" + "cue-id\n00:00:03.000 --> 00:00:04.000\nErste Zeile\nA & B\n" + ) + assert vtt2txt.vtt_to_lines(vtt) == ["Erste Zeile", "A & B"] + + +def test_vtt_to_lines_keeps_text_after_space_only_line(): + # YouTube cues start with a line holding a single space; the text after it + # must survive even if no later cue repeats it (here: end of file) + vtt = "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\n \n[applaus]\n" + assert vtt2txt.vtt_to_lines(vtt) == ["[applaus]"] + + +def test_vtt_to_lines_skips_note_and_normalizes_whitespace(): + vtt = ( + "WEBVTT\n\nNOTE Kommentar\nnicht übernehmen\n\n" + "00:00:01.000 --> 00:00:02.000\nA  B\n\n" + "00:00:02.000 --> 00:00:03.000\nA B\nC\n" + ) + assert vtt2txt.vtt_to_lines(vtt) == ["A B", "C"] + + +def test_vtt_to_lines_keeps_real_repetition(): + # Rolling window: each cue repeats the previous line, then adds new text. + # The speaker really says "Nein." twice; both must survive. + vtt = "WEBVTT\n\n" + "".join([ + cue(1, " ", "Er sagt:"), + cue(2, "Er sagt:", " "), + cue(3, "Er sagt:", "Nein."), + cue(4, "Nein.", " "), + cue(5, "Nein.", "Nein."), + cue(6, " ", " "), + cue(7, "Nein.", "Ende."), + ]) + assert vtt2txt.vtt_to_lines(vtt) == ["Er sagt:", "Nein.", "Nein.", "Ende."] + + +def test_vtt_to_lines_without_rolling_window(): + vtt = "WEBVTT\n\n1\n" + cue(1, "A", "B") + "2\n" + cue(2, "C") + assert vtt2txt.vtt_to_lines(vtt) == ["A", "B", "C"] + + +def test_main_default_output_matches_sample(sample_copy, monkeypatch, capsys): + run_main(monkeypatch, "-i", sample_copy) + target = sample_copy.with_name("ramstein.de.txt") + assert target.read_text(encoding="utf-8") == SAMPLE_TXT.read_text(encoding="utf-8") + assert f"Erstellt: {target}" in capsys.readouterr().out + + +def test_main_multiple_inputs(sample_copy, monkeypatch): + second = sample_copy.with_name("rede.en.vtt") + shutil.copy(sample_copy, second) + run_main(monkeypatch, "-i", sample_copy, second) + assert sample_copy.with_suffix(".txt").is_file() + assert second.with_suffix(".txt").is_file() + + +def test_main_expands_glob_without_duplicates(sample_copy, monkeypatch, capsys): + shutil.copy(sample_copy, sample_copy.with_name("ramstein.en.vtt")) + shutil.copy(sample_copy, sample_copy.with_name("anders.de.vtt")) + run_main(monkeypatch, "-i", sample_copy.parent / "ramstein.*.vtt", sample_copy) + out = capsys.readouterr().out + assert out.count("Erstellt:") == 2 + assert not sample_copy.with_name("anders.de.txt").exists() + + +@pytest.mark.parametrize("make_dir, suffix", [(True, ""), (False, "/")]) +def test_main_output_directory(sample_copy, monkeypatch, make_dir, suffix): + second = sample_copy.with_name("rede.en.vtt") + shutil.copy(sample_copy, second) + out_dir = sample_copy.parent / "out" + if make_dir: + out_dir.mkdir() + run_main(monkeypatch, "-i", sample_copy, second, "-o", f"{out_dir}{suffix}") + assert (out_dir / "ramstein.de.txt").is_file() + assert (out_dir / "rede.en.txt").is_file() + + +def test_main_output_option_creates_directories(sample_copy, monkeypatch): + target = sample_copy.parent / "neu" / "unter" / "rede.txt" + run_main(monkeypatch, "-i", sample_copy, "-o", target) + assert target.is_file() + + +@pytest.mark.parametrize("content, message", [ + ("WEBVTT\n".encode("utf-8"), "Keine Untertitel"), + ("WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nGrüße\n".encode("latin-1"), + "Keine UTF-8-Datei"), +]) +def test_main_bad_file_continues_batch(sample_copy, monkeypatch, capsys, content, message): + bad = sample_copy.with_name("aaa.vtt") + bad.write_bytes(content) + with pytest.raises(SystemExit) as exc: + run_main(monkeypatch, "-i", bad, sample_copy) + assert exc.value.code == 1 + assert message in capsys.readouterr().err + assert not bad.with_suffix(".txt").exists() + assert sample_copy.with_suffix(".txt").is_file() + + +@pytest.mark.parametrize("args, message", [ + ([], "required"), + (["-i", "gibtsnicht.vtt"], "Eingabedatei nicht gefunden"), + (["-i", "{txt}"], "Keine VTT-Datei"), + (["-i", "{vtt}", "{vtt2}", "-o", "{dir}/x.txt"], "muss -o/--output ein Verzeichnis sein"), + (["-i", "{dir}/*.gibtsnicht"], "Keine Datei passt zu"), + (["-i", "{vtt}", "{dir}/sub/ramstein.de.vtt", "-o", "{dir}/"], "dieselbe Ausgabedatei"), + (["-i", "{vtt}", "-o", "{vtt}"], "nicht die Eingabedatei"), +]) +def test_cli_errors(sample_copy, monkeypatch, capsys, args, message): + txt = sample_copy.with_suffix(".txt") + txt.write_text("x\n", encoding="utf-8") + vtt2 = sample_copy.with_name("rede.en.vtt") + shutil.copy(sample_copy, vtt2) + (sample_copy.parent / "sub").mkdir() + shutil.copy(sample_copy, sample_copy.parent / "sub" / sample_copy.name) + args = [a.format(vtt=sample_copy, vtt2=vtt2, txt=txt, dir=sample_copy.parent) + for a in args] + with pytest.raises(SystemExit) as exc: + run_main(monkeypatch, *args) + assert exc.value.code == 2 + assert message in capsys.readouterr().err diff --git a/vtt2txt.py b/vtt2txt.py new file mode 100755 index 0000000..157ad74 --- /dev/null +++ b/vtt2txt.py @@ -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: .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()