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:
parent
faea4bf465
commit
0bf42dee74
1 changed files with 700 additions and 254 deletions
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue