youtube_abschrift_korrigieren/vtt2txt.py
dschlueter 8edef11c78 refactor: Zeitauswahl nur noch über -z, -s/-e entfallen
-z 00:05:10 (ab), -z 0-00:20 (vom Anfang bis), -z 00:05-00:06 und mehrere
Bereiche, mit oder ohne Anführungszeichen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:20:54 +02:00

452 lines
15 KiB
Python
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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"[*?\[]")
CUE_TIME = re.compile(r"(?:(\d+):)??(?:(\d+):)?(\d+(?:[.,]\d+)?)")
CUE_TIMES = re.compile(r"^\s*([\d:.,]+)\s*-->\s*([\d:.,]+)")
USER_TIME = re.compile(r"(\d+)(?::(\d+)(?::(\d+(?:[.,]\d+)?))?)?")
HEADER = re.compile(r"\[(\d{2,}:\d{2}:\d{2}) - (\d{2,}:\d{2}:\d{2})\]")
SENTENCE_END = re.compile(r"[.!?…][\"'»«“”)\]]*(?=\s|$)")
LINE_ENDS_SENTENCE = re.compile(r"[.!?…][\"'»«“”)\]]*$")
MAX_SNAP_SECONDS = 30 # so weit darf ein Zeitraum bis zur nächsten Satzgrenze wachsen
TIME_EXAMPLES = "z. B. 00:05:10, 00:20 (Minute 20) oder 25 (Minute 25)"
def parse_cue_time(text):
"""Seconds from a VTT time: "00:03:17.500" or, without hours, "03:17.500"."""
match = CUE_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 parse_user_time(text):
"""Seconds from "00:05:10" (h:m:s), "00:20" (h:m, minute 20) or "25" (minutes)."""
match = USER_TIME.fullmatch(text.strip())
if not match:
raise ValueError(f"Ungültige Zeit: {text}")
first, minutes, seconds = match.groups()
if minutes is None:
return int(first) * 60
seconds = float(seconds.replace(",", ".")) if seconds is not None else 0
if int(minutes) >= 60 or seconds >= 60:
raise ValueError(f"Ungültige Zeit: {text}")
return int(first) * 3600 + int(minutes) * 60 + seconds
def parse_ranges(text):
"""Sorted (start, end) seconds from "00:05:00-00:06:00 00:10-00:12 00:25".
"A" or "A-" runs to the end, "0-B" from the beginning; None marks an open side.
"""
ranges = []
for item in text.split():
if item.startswith("-"):
raise ValueError(f"ungültiger Bereich „{item}“, vom Anfang: 0-B schreiben")
start, _, end = item.partition("-")
try:
span = (parse_user_time(start) or None, parse_user_time(end) if end else None)
except ValueError:
raise ValueError(f"ungültiger Bereich „{item}“") from None
if span == (None, None):
raise ValueError(f"Bereich „{item}“ ist das ganze Video: -z weglassen")
if None not in span and span[0] >= span[1]:
raise ValueError(f"Bereich „{item}“: Start muss vor Ende liegen")
ranges.append(span)
if not ranges:
raise ValueError("keine Bereiche angegeben")
# Open start first, then by start time
ranges.sort(key=lambda span: (span[0] is not None, span[0] or 0))
for (_, end), (start, _) in zip(ranges, ranges[1:]):
if end is None or start is None or start < end:
raise ValueError("Bereiche überschneiden sich")
return ranges
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 format_header(start, end):
"""Header line in front of a block of text: "[00:04:57 - 00:06:04]"."""
return f"[{format_time(start)} - {format_time(end)}]"
def range_name(start, end):
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 time_suffix(ranges):
"""Part of a default file name: "_00-03-17_bis_00-05-25", several joined with "+"."""
if not ranges:
return ""
return "_" + "+".join(range_name(start, end) for start, end in ranges)
RANGE_FORMS = (
"A-B (von A bis B), A (ab A bis zum Ende) oder 0-B (vom Anfang bis B); "
f"Zeiten {TIME_EXAMPLES}"
)
def add_time_options(parser):
parser.add_argument(
"-z", "--zeitraeume", nargs="+", metavar="BEREICH",
help=f"nur Untertitel in diesen Bereichen: {RANGE_FORMS}; mehrere durch "
"Leerzeichen getrennt, z. B. -z 00:05-00:06 00:10-00:12 00:25",
)
parser.add_argument(
"--exakt", action="store_true",
help="genau an den Zeiten schneiden, statt angeschnittene Sätze vollständig "
f"aufzunehmen (bis {MAX_SNAP_SECONDS} s über den Bereich hinaus)",
)
def resolve_time_ranges(parser, args):
"""Check -z and set args.bereiche: sorted list of (start, end), maybe empty."""
if args.zeitraeume is None:
args.bereiche = []
return
# Parts with and without quotes alike: "-z '0:05-0:06 0:25'" or "-z 0:05-0:06 0:25"
try:
args.bereiche = parse_ranges(" ".join(args.zeitraeume))
except ValueError as error:
parser.error(f"-z/--zeitraeume: {error} ({RANGE_FORMS})")
def read_cues(vtt):
"""Yield (start, end in seconds, non-empty tag-free text lines) of each cue."""
cue = None
start = end = None
for line in vtt.splitlines():
if "-->" in line:
if cue is not None:
yield start, end, cue
match = CUE_TIMES.match(line)
try:
start, end = map(parse_cue_time, match.groups()) if match else (None, None)
except ValueError:
start = end = None
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 start, end, 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 start, end, 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 in_range(time, start, end):
if time is None:
return False
return (start is None or time >= start) and (end is None or time < end)
def timed_lines(vtt):
"""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
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 start, end, cue in read_cues(vtt):
# Empty cues (YouTube transitions) say nothing about the window
if not cue:
continue
lines.extend((start, end, line) for line in cue[overlap(previous, cue):])
previous = cue
return lines
# Positions in the caption text are (line index, character offset); an end
# position is exclusive.
def sentence_start(timed, first, start):
"""Position where the sentence cut by `start` begins, line `first` if none is cut."""
if start is None or first == 0 or LINE_ENDS_SENTENCE.search(timed[first - 1][2]):
return first, 0
for index in range(first - 1, -1, -1):
time, _, line = timed[index]
if time is None or time < start - MAX_SNAP_SECONDS:
return first, 0
ends = list(SENTENCE_END.finditer(line))
if ends:
rest = line[ends[-1].end():]
if not rest.strip():
return index + 1, 0
return index, len(line) - len(rest.lstrip())
# Reached the first caption: the speech starts there
return 0, 0
def sentence_end(timed, last, end):
"""Position after the sentence cut by `end`, after line `last` if none is cut."""
if end is None or LINE_ENDS_SENTENCE.search(timed[last][2]):
return last + 1, 0
for index in range(last + 1, len(timed)):
time, _, line = timed[index]
if time is None or time >= end + MAX_SNAP_SECONDS:
return last + 1, 0
match = SENTENCE_END.search(line)
if match:
if match.end() == len(line):
return index + 1, 0
return index, match.end()
# Reached the last caption: the speech ends there
return len(timed), 0
def excerpt_bounds(timed, start, end, exact=False):
"""(begin, end) positions of a time range, None without captions in it.
Unless `exact`, the range grows to whole sentences; without a sentence end
within MAX_SNAP_SECONDS that side is cut exactly.
"""
inside = [i for i, (time, _, _) in enumerate(timed) if in_range(time, start, end)]
if not inside:
return None
first, last = inside[0], inside[-1]
if exact:
return (first, 0), (last + 1, 0)
return sentence_start(timed, first, start), sentence_end(timed, last, end)
def lines_between(timed, begin, end):
(first, begin_offset), (last, end_offset) = begin, end
lines = []
for index in range(first, min(last + 1, len(timed))):
line = timed[index][2]
part = line[begin_offset if index == first else 0:end_offset if index == last else None]
if part.strip():
lines.append(part.strip())
return lines
def range_label(start, end):
left = "Anfang" if start is None else format_time(start)
right = "Ende" if end is None else format_time(end)
return f"{left} – {right}"
def excerpts(timed, ranges, exact=False):
"""Return (start, end in seconds, lines) of the text block of each time range.
Blocks that overlap after growing to whole sentences are merged; ranges
without captions are skipped with a note.
"""
bounds = []
for start, end in ranges:
found = excerpt_bounds(timed, start, end, exact)
if found is None:
print(f"Hinweis: keine Untertitel im Bereich {range_label(start, end)}")
continue
if bounds and found[0] < bounds[-1][1]:
bounds[-1] = (bounds[-1][0], max(bounds[-1][1], found[1]))
else:
bounds.append(found)
blocks = []
for begin, end in bounds:
last = end[0] if end[1] else end[0] - 1
blocks.append((timed[begin[0]][0], timed[last][1], lines_between(timed, begin, end)))
return blocks
def join_segments(segments, header_gap="\n"):
"""Text of (header or None, body) blocks, separated by blank lines."""
return "\n\n".join(
body if header is None else f"{header}{header_gap}{body}" for header, body in segments
)
def split_segments(text):
"""Split text at header lines into [(header or None, body)]; see join_segments."""
segments = []
header, body = None, []
def close():
if header is not None or "".join(body).strip():
segments.append((header, "\n".join(body).strip()))
for line in text.splitlines():
if HEADER.fullmatch(line.strip()):
close()
header, body = line.strip(), []
else:
body.append(line)
close()
return segments
def vtt_to_lines(vtt):
"""Return the caption text lines of a VTT document without rolling duplicates."""
return [line for _, _, line in timed_lines(vtt)]
def vtt_to_text(vtt, ranges=(), exact=False):
"""Caption text of a VTT document; empty if there is none.
With time ranges (seconds) one block per range under a header with its
actual times, completed to whole sentences unless `exact`. The rolling
window is followed through all cues, so a line repeated from before a
range does not slip in.
"""
timed = timed_lines(vtt)
if not ranges:
return "\n".join(line for _, _, line in timed)
return join_segments(
(format_header(start, end), "\n".join(lines))
for start, end, lines in excerpts(timed, ranges, exact)
)
def convert(source, target, ranges=(), exact=False):
"""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}"
text = vtt_to_text(vtt, ranges, exact)
if not text:
where = " im gewählten Zeitraum" if ranges else ""
return f"Keine Untertitel{where} gefunden: {source}"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(text + "\n", encoding="utf-8")
print(f"Erstellt: {target} ({len(text.splitlines())} 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, "
"mit -z zusätzlich die Bereiche im Namen",
)
add_time_options(parser)
args = parser.parse_args()
resolve_time_ranges(parser, args)
suffix = time_suffix(args.bereiche)
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:
name = f"{source.stem}{suffix}.txt"
if output is None:
target = source.with_name(name)
elif to_dir:
target = Path(output) / 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, args.bereiche, args.exakt)
if error:
print(f"FEHLER: {error}", file=sys.stderr)
failed += 1
if failed:
sys.exit(1)
if __name__ == "__main__":
main()