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
6 changed files with 386 additions and 3 deletions
39
README.md
39
README.md
|
|
@ -17,15 +17,42 @@ der Lauf wird etwa 12-mal langsamer.
|
||||||
|
|
||||||
## Aufruf
|
## 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
|
```bash
|
||||||
./rede_korrigieren.py -i rede.txt # → rede.korrigiert.txt
|
./vtt2txt.py -i rede.de.vtt # → rede.de.txt
|
||||||
./rede_korrigieren.py -i rede.txt -o out/rede.txt # eigene Ausgabedatei
|
./vtt2txt.py -i ramstein.de*.vtt # mehrere Dateien, je → *.txt
|
||||||
./rede_korrigieren.py -i rede.txt -g rede.glossar.txt # mit Glossar
|
./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 |
|
| 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: `<Eingabe ohne Endung>.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: `<Eingabe ohne Endung>.korrigiert.txt` neben der Eingabe |
|
| `-o`, `--output` | Ausgabedatei; Standard: `<Eingabe ohne Endung>.korrigiert.txt` neben der Eingabe |
|
||||||
| `-g`, `--glossar` | Glossar mit festen Ersetzungen und Schreibweisen |
|
| `-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)
|
- `ramstein.de.txt`: daraus gewonnener Rohtext (Eingabe für das Programm)
|
||||||
|
|
||||||
```bash
|
```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
|
./rede_korrigieren.py -i testdaten/ramstein.de.txt -g ramstein.glossar.txt
|
||||||
# → testdaten/ramstein.de.korrigiert.txt (nicht versioniert)
|
# → 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`
|
werden Abschnittsbildung, Glossar (inkl. „jede Ersetzung in `ramstein.glossar.txt`
|
||||||
trifft die Testdaten“), Streaming-Auswertung, Absatzgliederung (Text bleibt
|
trifft die Testdaten“), Streaming-Auswertung, Absatzgliederung (Text bleibt
|
||||||
unverändert, 3–10 Sätze), Zwischenspeicher/Fortsetzen und die Kommandozeile.
|
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
|
## Zwischenstände
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
[pytest]
|
[pytest]
|
||||||
testpaths = tests
|
testpaths = tests
|
||||||
pythonpath = .
|
pythonpath = .
|
||||||
|
# Project has no async code; skip the globally installed asyncio plugin
|
||||||
|
addopts = -p no:asyncio
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import sys
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
|
from vtt2txt import vtt_to_lines
|
||||||
|
|
||||||
MODEL = "qwen3:32b"
|
MODEL = "qwen3:32b"
|
||||||
OLLAMA_URL = "http://127.0.0.1:11434/api/chat"
|
OLLAMA_URL = "http://127.0.0.1:11434/api/chat"
|
||||||
|
|
||||||
|
|
@ -356,7 +358,8 @@ def parse_args():
|
||||||
f"mit {MODEL} über Ollama."
|
f"mit {MODEL} über Ollama."
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
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(
|
parser.add_argument(
|
||||||
"-o", "--output", type=Path,
|
"-o", "--output", type=Path,
|
||||||
|
|
@ -388,8 +391,11 @@ def main():
|
||||||
terms, replacements = read_glossary(args.glossar) if args.glossar else ([], [])
|
terms, replacements = read_glossary(args.glossar) if args.glossar else ([], [])
|
||||||
system = build_system(terms)
|
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
|
# 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:
|
if args.glossar:
|
||||||
print(
|
print(
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,21 @@ def test_main_output_option_creates_directories(sample_copy, monkeypatch):
|
||||||
assert (target.parent / "rede.parts").is_dir()
|
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 test_main_warns_on_word_count_deviation(sample_copy, monkeypatch, capsys):
|
||||||
def shortening(chunk, index, system):
|
def shortening(chunk, index, system):
|
||||||
words = chunk.split()
|
words = chunk.split()
|
||||||
|
|
|
||||||
157
tests/test_vtt2txt.py
Normal file
157
tests/test_vtt2txt.py
Normal file
|
|
@ -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><c> Zeile</c>\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
|
||||||
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