Bei -s/-e werden angeschnittene Sätze an beiden Enden vollständig aufgenommen (höchstens 30 s über den Zeitraum hinaus). rede_korrigieren.py weist das Modell bei Ausschnitten zusätzlich an, nichts zu ergänzen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
316 lines
10 KiB
Python
Executable file
316 lines
10 KiB
Python
Executable file
#!/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"[*?\[]")
|
|
TIME = re.compile(r"(?:(\d+):)??(?:(\d+):)?(\d+(?:[.,]\d+)?)")
|
|
CUE_START = re.compile(r"^\s*([\d:.,]+)\s*-->")
|
|
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
|
|
|
|
|
|
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",
|
|
)
|
|
parser.add_argument(
|
|
"--exakt", action="store_true",
|
|
help="genau an -s/-e schneiden, statt angeschnittene Sätze vollständig "
|
|
f"aufzunehmen (bis {MAX_SNAP_SECONDS} s über den Zeitraum hinaus)",
|
|
)
|
|
|
|
|
|
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):
|
|
"""Yield (start in seconds, non-empty tag-free text lines) of each cue."""
|
|
cue = None
|
|
start = None
|
|
|
|
for line in vtt.splitlines():
|
|
if "-->" in line:
|
|
if cue is not None:
|
|
yield start, cue
|
|
match = CUE_START.match(line)
|
|
try:
|
|
start = parse_time(match.group(1)) if match else None
|
|
except ValueError:
|
|
start = 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, 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, 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 start is None and end is None
|
|
return (start is None or time >= start) and (end is None or time < end)
|
|
|
|
|
|
def timed_lines(vtt):
|
|
"""Return (cue start in seconds, 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 time, cue in read_cues(vtt):
|
|
# Empty cues (YouTube transitions) say nothing about the window
|
|
if not cue:
|
|
continue
|
|
lines.extend((time, line) for line in cue[overlap(previous, cue):])
|
|
previous = cue
|
|
|
|
return lines
|
|
|
|
|
|
def sentence_start(timed, first, start):
|
|
"""Lines before index `first` back to the start of the sentence cut by `start`."""
|
|
if start is None or first == 0 or LINE_ENDS_SENTENCE.search(timed[first - 1][1]):
|
|
return []
|
|
extra = []
|
|
for time, line in reversed(timed[:first]):
|
|
if time is None or time < start - MAX_SNAP_SECONDS:
|
|
return []
|
|
ends = list(SENTENCE_END.finditer(line))
|
|
if ends:
|
|
rest = line[ends[-1].end():].strip()
|
|
return ([rest] if rest else []) + extra
|
|
extra.insert(0, line)
|
|
# Reached the first caption: the speech starts there
|
|
return extra
|
|
|
|
|
|
def sentence_end(timed, last, end):
|
|
"""Lines after index `last` up to the end of the sentence cut by `end`."""
|
|
if end is None or LINE_ENDS_SENTENCE.search(timed[last][1]):
|
|
return []
|
|
extra = []
|
|
for time, line in timed[last + 1:]:
|
|
if time is None or time >= end + MAX_SNAP_SECONDS:
|
|
return []
|
|
match = SENTENCE_END.search(line)
|
|
if match:
|
|
return extra + [line[:match.end()]]
|
|
extra.append(line)
|
|
# Reached the last caption: the speech ends there
|
|
return extra
|
|
|
|
|
|
def snap_to_sentences(timed, start, end):
|
|
"""Lines in the range, extended so that no sentence is cut at either side.
|
|
|
|
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 []
|
|
first, last = inside[0], inside[-1]
|
|
lines = [line for _, line in timed[first:last + 1]]
|
|
return sentence_start(timed, first, start) + lines + sentence_end(timed, last, end)
|
|
|
|
|
|
def vtt_to_lines(vtt, start=None, end=None, exact=False):
|
|
"""Return the caption text lines of a VTT document without rolling duplicates.
|
|
|
|
With `start`/`end` (seconds) only the new lines of cues beginning in that
|
|
range are kept, completed to whole sentences unless `exact`. The window is
|
|
followed through all cues, so a line repeated from before `start` does not
|
|
slip in.
|
|
"""
|
|
timed = timed_lines(vtt)
|
|
if start is None and end is None:
|
|
return [line for _, line in timed]
|
|
if exact:
|
|
return [line for time, line in timed if in_range(time, start, end)]
|
|
return snap_to_sentences(timed, start, end)
|
|
|
|
|
|
def convert(source, target, start=None, end=None, 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}"
|
|
|
|
lines = vtt_to_lines(vtt, start, end, exact)
|
|
if not lines:
|
|
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.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
print(f"Erstellt: {target} ({len(lines)} 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 -s/-e zusätzlich der Zeitraum im Namen",
|
|
)
|
|
add_time_options(parser)
|
|
args = parser.parse_args()
|
|
check_time_range(parser, args)
|
|
suffix = time_suffix(args.start, args.ende)
|
|
|
|
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.start, args.ende, args.exakt)
|
|
if error:
|
|
print(f"FEHLER: {error}", file=sys.stderr)
|
|
failed += 1
|
|
if failed:
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|