feat: Zeitraum-Auswahl (-s/-e) in vtt2txt.py
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3d9a5037c2
commit
1668a8dcad
3 changed files with 159 additions and 15 deletions
|
|
@ -138,12 +138,15 @@ zweimal, bleibt sie zweimal erhalten.
|
||||||
./vtt2txt.py -i "ramstein.de*.vtt" # Muster löst das Programm auf (Windows)
|
./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 rede.de.vtt -o out/rede.txt # eigene Ausgabedatei
|
||||||
./vtt2txt.py -i *.vtt -o out/ # alle in ein Verzeichnis
|
./vtt2txt.py -i *.vtt -o out/ # alle in ein Verzeichnis
|
||||||
|
./vtt2txt.py -i rede.de.vtt -s 3:17 -e 5:25 # nur ein Ausschnitt → rede.de_00-03-17_bis_00-05-25.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Bedeutung |
|
| Option | Bedeutung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `-i`, `--input` | eine oder mehrere VTT-Dateien oder Muster, 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 |
|
| `-o`, `--output` | Ausgabedatei (nur bei einer Eingabedatei) oder Verzeichnis (vorhanden oder mit `/` am Ende); Standard: `<Eingabe ohne Endung>.txt` neben der Eingabe, mit `-s`/`-e` zusätzlich der Zeitraum im Namen |
|
||||||
|
| `-s`, `--start` | nur Untertitel ab dieser Zeit (`00:03:17`, `3:17` oder `197` Sekunden) |
|
||||||
|
| `-e`, `--ende` | nur Untertitel vor dieser Zeit (gleiches Format) |
|
||||||
|
|
||||||
Eine fehlerhafte Datei (keine Untertitel, nicht UTF-8) wird gemeldet, die übrigen
|
Eine fehlerhafte Datei (keine Untertitel, nicht UTF-8) wird gemeldet, die übrigen
|
||||||
werden trotzdem umgewandelt; der Exit-Code ist dann 1.
|
werden trotzdem umgewandelt; der Exit-Code ist dann 1.
|
||||||
|
|
|
||||||
|
|
@ -162,3 +162,66 @@ def test_cli_errors(sample_copy, monkeypatch, capsys, args, message):
|
||||||
run_main(monkeypatch, *args)
|
run_main(monkeypatch, *args)
|
||||||
assert exc.value.code == 2
|
assert exc.value.code == 2
|
||||||
assert message in capsys.readouterr().err
|
assert message in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
@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),
|
||||||
|
])
|
||||||
|
def test_parse_time(text, seconds):
|
||||||
|
assert vtt2txt.parse_time(text) == seconds
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("text", ["", "abc", "1:2:3:4", "-5"])
|
||||||
|
def test_parse_time_rejects_invalid(text):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
vtt2txt.parse_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"),
|
||||||
|
])
|
||||||
|
def test_time_suffix(start, end, suffix):
|
||||||
|
assert vtt2txt.time_suffix(start, end) == suffix
|
||||||
|
|
||||||
|
|
||||||
|
def test_vtt_to_lines_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) == ["B", "C"]
|
||||||
|
assert vtt2txt.vtt_to_lines(vtt, start=3) == ["C", "D"]
|
||||||
|
assert vtt2txt.vtt_to_lines(vtt, end=2) == ["A"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_vtt_to_lines_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) == ["C"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_time_range_default_name(sample_copy, monkeypatch):
|
||||||
|
run_main(monkeypatch, "-i", sample_copy, "-s", "0:30", "-e", "1:00")
|
||||||
|
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 0 < len(part) < len(full)
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
assert exc.value.code == 1
|
||||||
|
assert "im gewählten Zeitraum" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("args, message", [
|
||||||
|
(["-s", "abc"], "ungültige Zeit"),
|
||||||
|
(["-s", "1:00", "-e", "0:30"], "muss vor -e/--ende liegen"),
|
||||||
|
])
|
||||||
|
def test_cli_time_errors(sample_copy, monkeypatch, capsys, args, message):
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
run_main(monkeypatch, "-i", sample_copy, *args)
|
||||||
|
assert exc.value.code == 2
|
||||||
|
assert message in capsys.readouterr().err
|
||||||
|
|
|
||||||
104
vtt2txt.py
104
vtt2txt.py
|
|
@ -11,23 +11,84 @@ import sys
|
||||||
|
|
||||||
TAG = re.compile(r"<[^>]*>")
|
TAG = re.compile(r"<[^>]*>")
|
||||||
GLOB_CHARS = re.compile(r"[*?\[]")
|
GLOB_CHARS = re.compile(r"[*?\[]")
|
||||||
|
TIME = re.compile(r"(?:(\d+):)??(?:(\d+):)?(\d+(?:[.,]\d+)?)")
|
||||||
|
CUE_START = re.compile(r"^\s*([\d:.,]+)\s*-->")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time(text):
|
||||||
|
"""Seconds from "00:03:17", "3:17", "197" or "00:03:17.5"."""
|
||||||
|
match = TIME.fullmatch(text.strip())
|
||||||
|
if not match:
|
||||||
|
raise ValueError(f"Ungültige Zeit: {text}")
|
||||||
|
hours, minutes, seconds = match.groups()
|
||||||
|
return int(hours or 0) * 3600 + int(minutes or 0) * 60 + float(seconds.replace(",", "."))
|
||||||
|
|
||||||
|
|
||||||
|
def time_option(text):
|
||||||
|
"""argparse type for -s/--start and -e/--ende."""
|
||||||
|
try:
|
||||||
|
return parse_time(text)
|
||||||
|
except ValueError:
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
f"ungültige Zeit „{text}“ (z. B. 00:03:17, 3:17 oder 197)"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
|
||||||
|
def format_time(seconds, separator=":"):
|
||||||
|
seconds = int(seconds)
|
||||||
|
return separator.join(
|
||||||
|
f"{value:02d}" for value in (seconds // 3600, seconds // 60 % 60, seconds % 60)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def time_suffix(start, end):
|
||||||
|
"""Part of a default file name for a time range: "_00-03-17_bis_00-05-25"."""
|
||||||
|
if start is None and end is None:
|
||||||
|
return ""
|
||||||
|
if end is None:
|
||||||
|
return f"_ab_{format_time(start, '-')}"
|
||||||
|
if start is None:
|
||||||
|
return f"_bis_{format_time(end, '-')}"
|
||||||
|
return f"_{format_time(start, '-')}_bis_{format_time(end, '-')}"
|
||||||
|
|
||||||
|
|
||||||
|
def add_time_options(parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"-s", "--start", type=time_option, metavar="ZEIT",
|
||||||
|
help="nur Untertitel ab dieser Zeit, z. B. 00:03:17",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-e", "--ende", type=time_option, metavar="ZEIT",
|
||||||
|
help="nur Untertitel vor dieser Zeit, z. B. 00:05:25",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_time_range(parser, args):
|
||||||
|
if args.start is not None and args.ende is not None and args.start >= args.ende:
|
||||||
|
parser.error("-s/--start muss vor -e/--ende liegen.")
|
||||||
|
|
||||||
|
|
||||||
def read_cues(vtt):
|
def read_cues(vtt):
|
||||||
"""Yield the non-empty, tag-free text lines of each cue."""
|
"""Yield (start in seconds, non-empty tag-free text lines) of each cue."""
|
||||||
cue = None
|
cue = None
|
||||||
|
start = None
|
||||||
|
|
||||||
for line in vtt.splitlines():
|
for line in vtt.splitlines():
|
||||||
if "-->" in line:
|
if "-->" in line:
|
||||||
if cue is not None:
|
if cue is not None:
|
||||||
yield cue
|
yield start, cue
|
||||||
|
match = CUE_START.match(line)
|
||||||
|
try:
|
||||||
|
start = parse_time(match.group(1)) if match else None
|
||||||
|
except ValueError:
|
||||||
|
start = None
|
||||||
cue = []
|
cue = []
|
||||||
continue
|
continue
|
||||||
# Only a truly empty line ends a cue; YouTube puts lines with a single
|
# Only a truly empty line ends a cue; YouTube puts lines with a single
|
||||||
# space inside cues, followed by more text
|
# space inside cues, followed by more text
|
||||||
if not line:
|
if not line:
|
||||||
if cue is not None:
|
if cue is not None:
|
||||||
yield cue
|
yield start, cue
|
||||||
cue = None
|
cue = None
|
||||||
continue
|
continue
|
||||||
# Header, NOTE/STYLE blocks and cue identifiers
|
# Header, NOTE/STYLE blocks and cue identifiers
|
||||||
|
|
@ -39,7 +100,7 @@ def read_cues(vtt):
|
||||||
cue.append(text)
|
cue.append(text)
|
||||||
|
|
||||||
if cue is not None:
|
if cue is not None:
|
||||||
yield cue
|
yield start, cue
|
||||||
|
|
||||||
|
|
||||||
def overlap(previous, cue):
|
def overlap(previous, cue):
|
||||||
|
|
@ -50,36 +111,48 @@ def overlap(previous, cue):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def vtt_to_lines(vtt):
|
def in_range(time, start, end):
|
||||||
|
if time is None:
|
||||||
|
return start is None and end is None
|
||||||
|
return (start is None or time >= start) and (end is None or time < end)
|
||||||
|
|
||||||
|
|
||||||
|
def vtt_to_lines(vtt, start=None, end=None):
|
||||||
"""Return the caption text lines of a VTT document without rolling duplicates.
|
"""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
|
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.
|
||||||
|
|
||||||
|
With `start`/`end` (seconds) only the new lines of cues beginning in that
|
||||||
|
range are kept. The window is followed through all cues, so a line repeated
|
||||||
|
from before `start` does not slip in.
|
||||||
"""
|
"""
|
||||||
lines = []
|
lines = []
|
||||||
previous = []
|
previous = []
|
||||||
|
|
||||||
for cue in read_cues(vtt):
|
for time, cue in read_cues(vtt):
|
||||||
# Empty cues (YouTube transitions) say nothing about the window
|
# Empty cues (YouTube transitions) say nothing about the window
|
||||||
if not cue:
|
if not cue:
|
||||||
continue
|
continue
|
||||||
|
if in_range(time, start, end):
|
||||||
lines.extend(cue[overlap(previous, cue):])
|
lines.extend(cue[overlap(previous, cue):])
|
||||||
previous = cue
|
previous = cue
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def convert(source, target):
|
def convert(source, target, start=None, end=None):
|
||||||
"""Convert one file; return an error message or None on success."""
|
"""Convert one file; return an error message or None on success."""
|
||||||
try:
|
try:
|
||||||
vtt = source.read_text(encoding="utf-8-sig")
|
vtt = source.read_text(encoding="utf-8-sig")
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
return f"Keine UTF-8-Datei: {source}"
|
return f"Keine UTF-8-Datei: {source}"
|
||||||
|
|
||||||
lines = vtt_to_lines(vtt)
|
lines = vtt_to_lines(vtt, start, end)
|
||||||
if not lines:
|
if not lines:
|
||||||
return f"Keine Untertitel gefunden: {source}"
|
where = "" if start is None and end is None else " im gewählten Zeitraum"
|
||||||
|
return f"Keine Untertitel{where} gefunden: {source}"
|
||||||
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
|
@ -119,9 +192,13 @@ def parse_args():
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-o", "--output",
|
"-o", "--output",
|
||||||
help="Ausgabedatei (nur bei einer Eingabedatei) oder Verzeichnis "
|
help="Ausgabedatei (nur bei einer Eingabedatei) oder Verzeichnis "
|
||||||
"(vorhanden oder mit / am Ende); Standard: <Eingabe ohne Endung>.txt",
|
"(vorhanden oder mit / am Ende); Standard: <Eingabe ohne Endung>.txt, "
|
||||||
|
"mit -s/-e zusätzlich der Zeitraum im Namen",
|
||||||
)
|
)
|
||||||
|
add_time_options(parser)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
check_time_range(parser, args)
|
||||||
|
suffix = time_suffix(args.start, args.ende)
|
||||||
|
|
||||||
sources, unmatched = expand_inputs(args.input)
|
sources, unmatched = expand_inputs(args.input)
|
||||||
if unmatched is not None:
|
if unmatched is not None:
|
||||||
|
|
@ -141,10 +218,11 @@ def parse_args():
|
||||||
|
|
||||||
args.jobs = []
|
args.jobs = []
|
||||||
for source in sources:
|
for source in sources:
|
||||||
|
name = f"{source.stem}{suffix}.txt"
|
||||||
if output is None:
|
if output is None:
|
||||||
target = source.with_suffix(".txt")
|
target = source.with_name(name)
|
||||||
elif to_dir:
|
elif to_dir:
|
||||||
target = Path(output) / source.with_suffix(".txt").name
|
target = Path(output) / name
|
||||||
else:
|
else:
|
||||||
target = Path(output)
|
target = Path(output)
|
||||||
if target.resolve() == source.resolve():
|
if target.resolve() == source.resolve():
|
||||||
|
|
@ -160,7 +238,7 @@ def main():
|
||||||
failed = 0
|
failed = 0
|
||||||
# Keep going on errors so one bad file does not stop a batch
|
# Keep going on errors so one bad file does not stop a batch
|
||||||
for source, target in args.jobs:
|
for source, target in args.jobs:
|
||||||
error = convert(source, target)
|
error = convert(source, target, args.start, args.ende)
|
||||||
if error:
|
if error:
|
||||||
print(f"FEHLER: {error}", file=sys.stderr)
|
print(f"FEHLER: {error}", file=sys.stderr)
|
||||||
failed += 1
|
failed += 1
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue