feat: mehrere Zeitbereiche (-z) mit Zeit-Überschriften in einer Datei

- Zeitformat: 00:05:10 (hⓂ️s), 00:20 (h:m = Minute 20), 25 (Minute 25)
- -z "00:05-00:06 00:10-00:12 00:25": A-B, A (bis Ende), -B (ab Anfang)
- jeder Bereich unter einer Überschrift [HH:MM:SS - HH:MM:SS] mit den
  tatsächlichen Zeiten; nach der Satz-Erweiterung überlappende Blöcke
  werden verschmolzen
- rede_korrigieren.py korrigiert und gliedert jeden Block einzeln; das
  Modell sieht die Überschriften nicht

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-11 20:12:49 +02:00
commit 0bf42dee74

View file

@ -164,51 +164,215 @@ def test_cli_errors(sample_copy, monkeypatch, capsys, args, message):
assert message in capsys.readouterr().err
# === Time format and ranges ===
@pytest.mark.parametrize("text, seconds", [
("00:03:17", 197), ("3:17", 197), ("197", 197), ("00:03:17.5", 197.5),
("00:03:17,5", 197.5), ("1:02:03", 3723),
("00:05:10", 310), ("00:20", 1200), ("1:30", 5400), ("25", 1500), ("00:5:00", 300),
("00:03:17.5", 197.5), ("00:03:17,5", 197.5), ("1:02:03", 3723),
])
def test_parse_time(text, seconds):
assert vtt2txt.parse_time(text) == seconds
def test_parse_user_time(text, seconds):
assert vtt2txt.parse_user_time(text) == seconds
@pytest.mark.parametrize("text", ["", "abc", "1:2:3:4", "-5"])
def test_parse_time_rejects_invalid(text):
@pytest.mark.parametrize("text", ["", "abc", "1:2:3:4", "-5", "1:75", "0:10:60", "1:30.5"])
def test_parse_user_time_rejects_invalid(text):
with pytest.raises(ValueError):
vtt2txt.parse_time(text)
vtt2txt.parse_user_time(text)
@pytest.mark.parametrize("start, end, suffix", [
(None, None, ""),
(197, 325, "_00-03-17_bis_00-05-25"),
(197, None, "_ab_00-03-17"),
(None, 325, "_bis_00-05-25"),
@pytest.mark.parametrize("text, seconds", [
("00:03:17.500", 197.5), ("03:17.500", 197.5), ("1:02:03.000", 3723),
])
def test_time_suffix(start, end, suffix):
assert vtt2txt.time_suffix(start, end) == suffix
def test_parse_cue_time_keeps_minutes_seconds(text, seconds):
assert vtt2txt.parse_cue_time(text) == seconds
def test_vtt_to_lines_exact_time_range():
@pytest.mark.parametrize("text, ranges", [
("00:5:00-00:6:00 00:10-00:12:00 00:25", [(300, 360), (600, 720), (1500, None)]),
("-00:20", [(None, 1200)]),
("00:25- 00:10-00:12 -00:05", [(None, 300), (600, 720), (1500, None)]),
("00:05-00:06 00:06-00:07", [(300, 360), (360, 420)]),
])
def test_parse_ranges(text, ranges):
assert vtt2txt.parse_ranges(text) == ranges
@pytest.mark.parametrize("text, message", [
("", "keine Bereiche"),
("-", "ungültiger Bereich"),
("00:06-00:05", "Start muss vor Ende"),
("00:05-00:10 00:08-00:12", "überschneiden"),
("00:05 00:10", "überschneiden"),
("-00:05 -00:10", "überschneiden"),
("00:05-x", "ungültiger Bereich"),
])
def test_parse_ranges_rejects(text, message):
with pytest.raises(ValueError, match=message):
vtt2txt.parse_ranges(text)
@pytest.mark.parametrize("ranges, suffix", [
([], ""),
([(197, 325)], "_00-03-17_bis_00-05-25"),
([(197, None)], "_ab_00-03-17"),
([(None, 325)], "_bis_00-05-25"),
([(300, 360), (600, 720), (1500, None)],
"_00-05-00_bis_00-06-00+00-10-00_bis_00-12-00+ab_00-25-00"),
])
def test_time_suffix(ranges, suffix):
assert vtt2txt.time_suffix(ranges) == suffix
# === Excerpts ===
def timed_cue(seconds, *lines):
return f"{vtt2txt.format_time(seconds)}.000 --> {vtt2txt.format_time(seconds + 1)}.000\n" \
+ "\n".join(lines) + "\n\n"
def captions(*cues):
"""VTT with one new line per cue: cues are (seconds, text)."""
return "WEBVTT\n\n" + "".join(timed_cue(t, text) for t, text in cues)
def block(vtt, start=None, end=None, exact=False):
"""Lines of the single excerpt of one time range ([] without captions)."""
blocks = vtt2txt.excerpts(vtt2txt.timed_lines(vtt), [(start, end)], exact)
return blocks[0][2] if blocks else []
SPEECH = captions(
(100, "Erster Satz ist hier."), (102, "Der zweite Satz"), (104, "beginnt vorher. Dann"),
(106, "kommt der dritte"), (108, "und endet. Der vierte"), (110, "läuft weiter"),
(112, "und endet auch. Schluss"), (114, "folgt."),
)
def test_exact_time_range():
vtt = "WEBVTT\n\n" + cue(1, "A") + cue(2, "A", "B") + cue(3, "B", "C") + cue(4, "C", "D")
assert vtt2txt.vtt_to_lines(vtt, start=2, end=4, exact=True) == ["B", "C"]
assert vtt2txt.vtt_to_lines(vtt, start=3, exact=True) == ["C", "D"]
assert vtt2txt.vtt_to_lines(vtt, end=2, exact=True) == ["A"]
assert block(vtt, 2, 4, exact=True) == ["B", "C"]
assert block(vtt, 3, exact=True) == ["C", "D"]
assert block(vtt, end=2, exact=True) == ["A"]
def test_vtt_to_lines_time_range_skips_window_from_before_start():
def test_time_range_skips_window_from_before_start():
# "B" was said before the range; its repetition in the first cue must not count
vtt = "WEBVTT\n\n" + cue(1, "A", "B") + cue(2, "B", "C")
assert vtt2txt.vtt_to_lines(vtt, start=2, exact=True) == ["C"]
assert block(vtt, 2, exact=True) == ["C"]
def test_snap_extends_both_sides_to_sentences():
# Range holds only "kommt der dritte" and "und endet. Der vierte"
assert block(SPEECH, 106, 110) == [
"Dann", "kommt der dritte", "und endet. Der vierte", "läuft weiter",
"und endet auch.",
]
def test_snap_keeps_range_already_at_sentence_bounds():
assert block(SPEECH, 102, 104) == ["Der zweite Satz", "beginnt vorher."]
assert block(SPEECH, 100, 102) == ["Erster Satz ist hier."]
def test_snap_reaches_start_and_end_of_captions():
assert block(SPEECH, 114) == ["Schluss", "folgt."]
speech = captions((0, "ohne Satzzeichen"), (2, "am Anfang. Rest"))
assert block(speech, 2) == ["ohne Satzzeichen", "am Anfang. Rest"]
def test_snap_cuts_exactly_without_sentence_end_in_reach():
far = vtt2txt.MAX_SNAP_SECONDS + 5
speech = captions(
(0, "Satz."), (2, "ein endloser"), (far, "Satz ohne"), (2 * far, "Ende"), (3 * far, "jetzt."),
)
assert block(speech, far, far + 1) == ["Satz ohne"]
def test_snap_exact_keeps_cut():
assert block(SPEECH, 106, 110, exact=True) == ["kommt der dritte", "und endet. Der vierte"]
def test_snap_closing_quote_belongs_to_sentence():
speech = captions((0, "Er sagte „Nein.“ Und"), (2, "dann"), (4, "ging er."))
assert block(speech, 2, 4) == ["Und", "dann", "ging er."]
def test_vtt_to_text_headers_with_actual_times():
text = vtt2txt.vtt_to_text(SPEECH, [(102, 104), (106, 110)])
assert text == (
"[00:01:42 - 00:01:45]\nDer zweite Satz\nbeginnt vorher.\n\n"
"[00:01:44 - 00:01:53]\nDann\nkommt der dritte\nund endet. Der vierte\n"
"läuft weiter\nund endet auch."
)
def test_vtt_to_text_merges_blocks_overlapping_after_snap():
# 106-110 grows to "und endet auch." (cue 112), 110-114 back to "Der vierte" (cue 108)
text = vtt2txt.vtt_to_text(SPEECH, [(106, 110), (110, 114)])
assert text == (
"[00:01:44 - 00:01:55]\nDann\nkommt der dritte\nund endet. Der vierte\n"
"läuft weiter\nund endet auch. Schluss\nfolgt."
)
def test_vtt_to_text_shared_line_split_at_sentence_end():
# Both blocks get their part of "und endet. Der vierte", without overlap
text = vtt2txt.vtt_to_text(SPEECH, [(106, 108), (110, 112)])
assert vtt2txt.split_segments(text) == [
("[00:01:44 - 00:01:49]", "Dann\nkommt der dritte\nund endet."),
("[00:01:48 - 00:01:53]", "Der vierte\nläuft weiter\nund endet auch."),
]
def test_vtt_to_text_skips_empty_range(capsys):
text = vtt2txt.vtt_to_text(SPEECH, [(0, 50), (100, 102), (200, None)])
assert text == "[00:01:40 - 00:01:41]\nErster Satz ist hier."
out = capsys.readouterr().out
assert "keine Untertitel im Bereich 00:00:00 – 00:00:50" in out
assert "keine Untertitel im Bereich 00:03:20 – Ende" in out
assert vtt2txt.vtt_to_text(SPEECH, [(200, None)]) == ""
def test_vtt_to_text_without_ranges_has_no_header():
assert vtt2txt.vtt_to_text(SPEECH).splitlines()[0] == "Erster Satz ist hier."
def test_split_segments():
text = "Vorspann\n\n[00:01:00 - 00:02:00]\nA\nB\n\n[applaus]\n[00:03:00 - 00:04:00]\n\nC\n"
assert vtt2txt.split_segments(text) == [
(None, "Vorspann"),
("[00:01:00 - 00:02:00]", "A\nB\n\n[applaus]"),
("[00:03:00 - 00:04:00]", "C"),
]
assert vtt2txt.split_segments("ohne\nÜberschrift") == [(None, "ohne\nÜberschrift")]
assert vtt2txt.split_segments(" \n") == []
def test_split_segments_inverts_join_segments():
text = vtt2txt.vtt_to_text(SPEECH, [(102, 104), (112, None)])
assert vtt2txt.join_segments(vtt2txt.split_segments(text)) == text
# === Command line ===
def test_main_time_range_default_name(sample_copy, monkeypatch):
run_main(monkeypatch, "-i", sample_copy, "-s", "0:30", "-e", "1:00")
run_main(monkeypatch, "-i", sample_copy, "-s", "0:00:30", "-e", "0:01")
target = sample_copy.with_name("ramstein.de_00-00-30_bis_00-01-00.txt")
full = SAMPLE_TXT.read_text(encoding="utf-8").splitlines()
part = target.read_text(encoding="utf-8").splitlines()
assert vtt2txt.HEADER.fullmatch(part[0])
assert 0 < len(part) < len(full)
def test_main_several_ranges(sample_copy, monkeypatch):
run_main(monkeypatch, "-i", sample_copy, "-z", "0:01-0:02 0:05")
target = sample_copy.with_name("ramstein.de_00-01-00_bis_00-02-00+ab_00-05-00.txt")
segments = vtt2txt.split_segments(target.read_text(encoding="utf-8"))
assert [header for header, _ in segments] == [
"[00:00:56 - 00:02:09]", "[00:04:57 - 00:48:07]",
]
def test_main_time_range_without_captions(sample_copy, monkeypatch, capsys):
with pytest.raises(SystemExit) as exc:
run_main(monkeypatch, "-i", sample_copy, "-s", "99:00:00")
@ -219,6 +383,9 @@ def test_main_time_range_without_captions(sample_copy, monkeypatch, capsys):
@pytest.mark.parametrize("args, message", [
(["-s", "abc"], "ungültige Zeit"),
(["-s", "1:00", "-e", "0:30"], "muss vor -e/--ende liegen"),
(["-z", "0:05-0:04"], "Start muss vor Ende"),
(["-z", "0:05 0:10"], "überschneiden"),
(["-z", "0:05", "-s", "0:01"], "nicht zusammen mit -s/-e"),
])
def test_cli_time_errors(sample_copy, monkeypatch, capsys, args, message):
with pytest.raises(SystemExit) as exc:
@ -227,68 +394,13 @@ def test_cli_time_errors(sample_copy, monkeypatch, capsys, args, message):
assert message in capsys.readouterr().err
def timed_cue(seconds, *lines):
return f"{vtt2txt.format_time(seconds)}.000 --> {vtt2txt.format_time(seconds + 1)}.000\n" \
+ "\n".join(lines) + "\n\n"
def captions(*cues):
"""VTT with one new line per cue: cues are (seconds, text)."""
return "WEBVTT\n\n" + "".join(timed_cue(t, text) for t, text in cues)
SPEECH = captions(
(100, "Erster Satz ist hier."), (102, "Der zweite Satz"), (104, "beginnt vorher. Dann"),
(106, "kommt der dritte"), (108, "und endet. Der vierte"), (110, "läuft weiter"),
(112, "und endet auch. Schluss"), (114, "folgt."),
)
def test_snap_extends_both_sides_to_sentences():
# Range holds only "kommt der dritte" and "und endet. Der vierte"
assert vtt2txt.vtt_to_lines(SPEECH, start=106, end=110) == [
"Dann", "kommt der dritte", "und endet. Der vierte", "läuft weiter",
"und endet auch.",
]
def test_snap_keeps_range_already_at_sentence_bounds():
assert vtt2txt.vtt_to_lines(SPEECH, start=102, end=104) == [
"Der zweite Satz", "beginnt vorher.",
]
assert vtt2txt.vtt_to_lines(SPEECH, start=100, end=102) == ["Erster Satz ist hier."]
def test_snap_reaches_start_and_end_of_captions():
assert vtt2txt.vtt_to_lines(SPEECH, start=114) == ["Schluss", "folgt."]
speech = captions((0, "ohne Satzzeichen"), (2, "am Anfang. Rest"))
assert vtt2txt.vtt_to_lines(speech, start=2) == ["ohne Satzzeichen", "am Anfang. Rest"]
def test_snap_cuts_exactly_without_sentence_end_in_reach():
far = vtt2txt.MAX_SNAP_SECONDS + 5
speech = captions(
(0, "Satz."), (2, "ein endloser"), (far, "Satz ohne"), (2 * far, "Ende"), (3 * far, "jetzt."),
)
assert vtt2txt.vtt_to_lines(speech, start=far, end=far + 1) == ["Satz ohne"]
def test_snap_exact_keeps_cut():
assert vtt2txt.vtt_to_lines(SPEECH, start=106, end=110, exact=True) == [
"kommt der dritte", "und endet. Der vierte",
]
def test_snap_closing_quote_belongs_to_sentence():
speech = captions((0, "Er sagte „Nein.“ Und"), (2, "dann"), (4, "ging er."))
assert vtt2txt.vtt_to_lines(speech, start=2, end=4) == ["Und", "dann", "ging er."]
def test_main_exact_option(tmp_path, monkeypatch):
source = tmp_path / "rede.vtt"
source.write_text(SPEECH, encoding="utf-8")
run_main(monkeypatch, "-i", source, "-s", "106", "-e", "110", "--exakt")
run_main(monkeypatch, "-i", source, "-s", "0:01:46", "-e", "0:01:50", "--exakt")
target = tmp_path / "rede_00-01-46_bis_00-01-50.txt"
assert target.read_text(encoding="utf-8") == "kommt der dritte\nund endet. Der vierte\n"
run_main(monkeypatch, "-i", source, "-s", "106", "-e", "110")
assert target.read_text(encoding="utf-8") == (
"[00:01:46 - 00:01:49]\nkommt der dritte\nund endet. Der vierte\n"
)
run_main(monkeypatch, "-i", source, "-s", "0:01:46", "-e", "0:01:50")
assert target.read_text(encoding="utf-8").endswith("und endet auch.\n")