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
322
vtt2txt.py
322
vtt2txt.py
|
|
@ -11,30 +11,89 @@ 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*-->")
|
||||
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_time(text):
|
||||
"""Seconds from "00:03:17", "3:17", "197" or "00:03:17.5"."""
|
||||
match = TIME.fullmatch(text.strip())
|
||||
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 time_option(text):
|
||||
"""argparse type for -s/--start and -e/--ende."""
|
||||
try:
|
||||
return parse_time(text)
|
||||
return parse_user_time(text)
|
||||
except ValueError:
|
||||
raise argparse.ArgumentTypeError(
|
||||
f"ungültige Zeit „{text}“ (z. B. 00:03:17, 3:17 oder 197)"
|
||||
f"ungültige Zeit „{text}“ ({TIME_EXAMPLES})"
|
||||
) from None
|
||||
|
||||
|
||||
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, "-B" from the beginning; None marks an open side.
|
||||
"""
|
||||
ranges = []
|
||||
for item in text.split():
|
||||
start, dash, end = item.partition("-")
|
||||
try:
|
||||
span = (
|
||||
parse_user_time(start) if start else 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"ungültiger Bereich „{item}“")
|
||||
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 ranges_option(text):
|
||||
"""argparse type for -z/--zeitraeume."""
|
||||
try:
|
||||
return parse_ranges(text)
|
||||
except ValueError as error:
|
||||
raise argparse.ArgumentTypeError(
|
||||
f"{error} (Bereiche durch Leerzeichen getrennt, je A-B, A = ab A, "
|
||||
f"-B = bis B; Zeiten {TIME_EXAMPLES})"
|
||||
) from None
|
||||
|
||||
|
||||
|
|
@ -45,59 +104,84 @@ def format_time(seconds, separator=":"):
|
|||
)
|
||||
|
||||
|
||||
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 ""
|
||||
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, '-')}"
|
||||
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, '-')}"
|
||||
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)
|
||||
|
||||
|
||||
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",
|
||||
help=f"nur Untertitel ab dieser Zeit, {TIME_EXAMPLES}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-e", "--ende", type=time_option, metavar="ZEIT",
|
||||
help="nur Untertitel vor dieser Zeit, z. B. 00:05:25",
|
||||
help="nur Untertitel vor dieser Zeit (Format wie -s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-z", "--zeitraeume", type=ranges_option, metavar="BEREICHE",
|
||||
help="mehrere Bereiche statt -s/-e, durch Leerzeichen getrennt: A-B, "
|
||||
"A (ab A bis zum Ende) oder -B (vom Anfang bis B), "
|
||||
"z. B. \"00:05:00-00:06:00 00:10-00:12 00:25\"",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exakt", action="store_true",
|
||||
help="genau an -s/-e schneiden, statt angeschnittene Sätze vollständig "
|
||||
help="genau an den Zeiten 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 resolve_time_ranges(parser, args):
|
||||
"""Check the time options and set args.bereiche: list of (start, end), maybe empty."""
|
||||
single = args.start is not None or args.ende is not None
|
||||
if args.zeitraeume is not None:
|
||||
if single:
|
||||
parser.error("-z/--zeitraeume nicht zusammen mit -s/-e angeben.")
|
||||
args.bereiche = args.zeitraeume
|
||||
elif single:
|
||||
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.")
|
||||
args.bereiche = [(args.start, args.ende)]
|
||||
else:
|
||||
args.bereiche = []
|
||||
|
||||
|
||||
def read_cues(vtt):
|
||||
"""Yield (start in seconds, non-empty tag-free text lines) of each cue."""
|
||||
"""Yield (start, end in seconds, non-empty tag-free text lines) of each cue."""
|
||||
cue = None
|
||||
start = None
|
||||
start = end = None
|
||||
|
||||
for line in vtt.splitlines():
|
||||
if "-->" in line:
|
||||
if cue is not None:
|
||||
yield start, cue
|
||||
match = CUE_START.match(line)
|
||||
yield start, end, cue
|
||||
match = CUE_TIMES.match(line)
|
||||
try:
|
||||
start = parse_time(match.group(1)) if match else None
|
||||
start, end = map(parse_cue_time, match.groups()) if match else (None, None)
|
||||
except ValueError:
|
||||
start = None
|
||||
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, cue
|
||||
yield start, end, cue
|
||||
cue = None
|
||||
continue
|
||||
# Header, NOTE/STYLE blocks and cue identifiers
|
||||
|
|
@ -109,7 +193,7 @@ def read_cues(vtt):
|
|||
cue.append(text)
|
||||
|
||||
if cue is not None:
|
||||
yield start, cue
|
||||
yield start, end, cue
|
||||
|
||||
|
||||
def overlap(previous, cue):
|
||||
|
|
@ -122,12 +206,12 @@ def overlap(previous, cue):
|
|||
|
||||
def in_range(time, start, end):
|
||||
if time is None:
|
||||
return start is None and end 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 in seconds, line) for the new caption lines of a VTT document.
|
||||
"""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
|
||||
|
|
@ -136,93 +220,173 @@ def timed_lines(vtt):
|
|||
lines = []
|
||||
previous = []
|
||||
|
||||
for time, cue in read_cues(vtt):
|
||||
for start, end, 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):])
|
||||
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):
|
||||
"""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]):
|
||||
"""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 []
|
||||
return first, 0
|
||||
ends = list(SENTENCE_END.finditer(line))
|
||||
if ends:
|
||||
rest = line[ends[-1].end():].strip()
|
||||
return ([rest] if rest else []) + extra
|
||||
extra.insert(0, line)
|
||||
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 extra
|
||||
return 0, 0
|
||||
|
||||
|
||||
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:]:
|
||||
"""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 []
|
||||
return last + 1, 0
|
||||
match = SENTENCE_END.search(line)
|
||||
if match:
|
||||
return extra + [line[:match.end()]]
|
||||
extra.append(line)
|
||||
if match.end() == len(line):
|
||||
return index + 1, 0
|
||||
return index, match.end()
|
||||
# Reached the last caption: the speech ends there
|
||||
return extra
|
||||
return len(timed), 0
|
||||
|
||||
|
||||
def snap_to_sentences(timed, start, end):
|
||||
"""Lines in the range, extended so that no sentence is cut at either side.
|
||||
def excerpt_bounds(timed, start, end, exact=False):
|
||||
"""(begin, end) positions of a time range, None without captions in it.
|
||||
|
||||
Without a sentence end within MAX_SNAP_SECONDS that side is cut exactly.
|
||||
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)]
|
||||
inside = [i for i, (time, _, _) in enumerate(timed) if in_range(time, start, end)]
|
||||
if not inside:
|
||||
return []
|
||||
return None
|
||||
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)
|
||||
if exact:
|
||||
return (first, 0), (last + 1, 0)
|
||||
return sentence_start(timed, first, start), 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.
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
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 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)
|
||||
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, start=None, end=None, exact=False):
|
||||
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}"
|
||||
|
||||
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"
|
||||
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("\n".join(lines) + "\n", encoding="utf-8")
|
||||
print(f"Erstellt: {target} ({len(lines)} Zeilen)")
|
||||
target.write_text(text + "\n", encoding="utf-8")
|
||||
print(f"Erstellt: {target} ({len(text.splitlines())} Zeilen)")
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -259,12 +423,12 @@ def parse_args():
|
|||
"-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",
|
||||
"mit -s/-e/-z zusätzlich die Zeiträume im Namen",
|
||||
)
|
||||
add_time_options(parser)
|
||||
args = parser.parse_args()
|
||||
check_time_range(parser, args)
|
||||
suffix = time_suffix(args.start, args.ende)
|
||||
resolve_time_ranges(parser, args)
|
||||
suffix = time_suffix(args.bereiche)
|
||||
|
||||
sources, unmatched = expand_inputs(args.input)
|
||||
if unmatched is not None:
|
||||
|
|
@ -304,7 +468,7 @@ def main():
|
|||
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)
|
||||
error = convert(source, target, args.bereiche, args.exakt)
|
||||
if error:
|
||||
print(f"FEHLER: {error}", file=sys.stderr)
|
||||
failed += 1
|
||||
|
|
|
|||
Loading…
Reference in a new issue