fix: Untertitel ohne rollendes Fenster verlieren keine wiederholten Blöcke

vtt2txt.py entfernt Überlappungen nur noch, wenn die Datei ein rollendes
Fenster zeigt: mindestens die Hälfte der Blöcke wiederholt das Ende des
vorigen (YouTube-Auto-Untertitel: 97–99 %). Manuell erstellte Untertitel
werden vollständig übernommen, zwei gleiche Blöcke hintereinander bleiben
zwei.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-11 21:36:32 +02:00
commit 3da372a941
3 changed files with 53 additions and 10 deletions

View file

@ -144,7 +144,10 @@ Macht aus VTT-Untertiteln (z. B. von `yt-dlp --write-auto-subs`) die Rohabschrif
für `abschrift_korrigieren.py`: Zeitstempel und Tags werden entfernt. YouTube-Auto-Untertitel für `abschrift_korrigieren.py`: Zeitstempel und Tags werden entfernt. YouTube-Auto-Untertitel
zeigen ein rollendes Fenster, jeder Block wiederholt das Ende des vorigen. Übernommen 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 wird nur, was nach dieser Überlappung neu ist; sagt der Redner eine Zeile wirklich
zweimal, bleibt sie zweimal erhalten. zweimal, bleibt sie zweimal erhalten. Ob eine Datei rollt, wird pro Datei bestimmt:
Wiederholt mindestens die Hälfte der Blöcke das Ende des vorigen (Auto-Untertitel:
über 95 %), gilt sie als rollend. Sonst, etwa bei manuell erstellten Untertiteln,
wird jeder Block vollständig übernommen.
```bash ```bash
./vtt2txt.py -i rede.de.vtt # → rede.de.txt ./vtt2txt.py -i rede.de.vtt # → rede.de.txt
@ -457,9 +460,6 @@ Oben in `abschrift_korrigieren.py`:
gehören ins Glossar. gehören ins Glossar.
- `qwen3:32b` ohne Denkmodus erkennt Hörfehler ohne Glossar nur teilweise und - `qwen3:32b` ohne Denkmodus erkennt Hörfehler ohne Glossar nur teilweise und
setzt kaum `[prüfen: …]`-Marken. Bekannte Fehler gehören deshalb ins Glossar. setzt kaum `[prüfen: …]`-Marken. Bekannte Fehler gehören deshalb ins Glossar.
- `vtt2txt.py` erkennt Wiederholungen an der Überlappung aufeinanderfolgender
Untertitel-Blöcke. Bei Untertiteln ohne rollendes Fenster gehen deshalb zwei
direkt aufeinanderfolgende, identische Blöcke zu einem zusammen.
- Einzelbuchstaben und ein- bis zweistellige Zahlen mit Punkt gelten nie als - Einzelbuchstaben und ein- bis zweistellige Zahlen mit Punkt gelten nie als
Satzende („Plan B.“, „Es waren 12.“); das beeinflusst nur mögliche Absatzgrenzen, Satzende („Plan B.“, „Es waren 12.“); das beeinflusst nur mögliche Absatzgrenzen,
nicht den Text. nicht den Text.

View file

@ -75,6 +75,36 @@ def test_vtt_to_lines_without_rolling_window():
assert vtt2txt.vtt_to_lines(vtt) == ["A", "B", "C"] assert vtt2txt.vtt_to_lines(vtt) == ["A", "B", "C"]
def test_vtt_to_lines_without_rolling_window_keeps_identical_cues():
# Hand-written captions: the speaker says "Nein." twice, as two cues, and
# a cue may start with the line that ended the previous one
vtt = "WEBVTT\n\n" + "".join([
cue(1, "Er sagt:"),
cue(2, "Nein."),
cue(3, "Nein."),
cue(4, "Ja, wirklich", "Nein."),
cue(5, "Nein.", "Und dann?"),
cue(6, "Dann kam"),
cue(7, "der Rest."),
cue(8, "Ende."),
])
assert vtt2txt.vtt_to_lines(vtt) == [
"Er sagt:", "Nein.", "Nein.", "Ja, wirklich", "Nein.", "Nein.", "Und dann?",
"Dann kam", "der Rest.", "Ende.",
]
@pytest.mark.parametrize("cues, expected", [
([], False),
([["A"]], False),
([["A"], ["A", "B"]], True),
([["A"], ["A", "B"], ["C"]], True), # half of the pairs overlap
([["A"], ["B"], ["C"], ["C", "D"]], False), # one of three
])
def test_is_rolling(cues, expected):
assert vtt2txt.is_rolling(cues) is expected
def test_main_default_output_matches_sample(sample_copy, monkeypatch, capsys): def test_main_default_output_matches_sample(sample_copy, monkeypatch, capsys):
run_main(monkeypatch, "-i", sample_copy) run_main(monkeypatch, "-i", sample_copy)
target = sample_copy.with_name("ramstein.de.txt") target = sample_copy.with_name("ramstein.de.txt")

View file

@ -19,6 +19,9 @@ SENTENCE_END = re.compile(r"[.!?…][\"'»«“”)\]]*(?=\s|$)")
LINE_ENDS_SENTENCE = re.compile(r"[.!?…][\"'»«“”)\]]*$") LINE_ENDS_SENTENCE = re.compile(r"[.!?…][\"'»«“”)\]]*$")
MAX_SNAP_SECONDS = 30 # so weit darf ein Zeitraum bis zur nächsten Satzgrenze wachsen MAX_SNAP_SECONDS = 30 # so weit darf ein Zeitraum bis zur nächsten Satzgrenze wachsen
# Ab diesem Anteil von Blöcken, die das Ende des vorherigen wiederholen, gelten
# die Untertitel als rollendes Fenster (YouTube-Auto-Untertitel: über 95 %)
ROLLING_SHARE = 0.5
TIME_EXAMPLES = "z. B. 00:05:10, 00:20 (Minute 20) oder 25 (Minute 25)" TIME_EXAMPLES = "z. B. 00:05:10, 00:20 (Minute 20) oder 25 (Minute 25)"
@ -204,21 +207,31 @@ def in_range(time, start, end):
return (start is None or time >= start) and (end is None or time < end) return (start is None or time >= start) and (end is None or time < end)
def is_rolling(cues):
"""True if most cues repeat the end of the previous one (rolling window)."""
pairs = list(zip(cues, cues[1:]))
overlapping = sum(1 for previous, cue in pairs if overlap(previous, cue))
return bool(pairs) and overlapping >= ROLLING_SHARE * len(pairs)
def timed_lines(vtt): def timed_lines(vtt):
"""Return (cue start, cue end, line) for the new caption lines of a VTT document. """Return (cue start, cue end, line) for the new caption lines of a VTT document.
Auto captions show a rolling window: each cue repeats the end of the previous 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 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. line the speaker really says twice in a row is kept. Captions without a
rolling window (e.g. written by hand) are taken as they are, so identical
cues in a row stay separate.
""" """
# Empty cues (YouTube transitions) say nothing about the window
cues = [(start, end, cue) for start, end, cue in read_cues(vtt) if cue]
rolling = is_rolling([cue for _, _, cue in cues])
lines = [] lines = []
previous = [] previous = []
for start, end, cue in read_cues(vtt): for start, end, cue in cues:
# Empty cues (YouTube transitions) say nothing about the window new = cue[overlap(previous, cue):] if rolling else cue
if not cue: lines.extend((start, end, line) for line in new)
continue
lines.extend((start, end, line) for line in cue[overlap(previous, cue):])
previous = cue previous = cue
return lines return lines