29b8a2988a
Give Claude the ability to watch any video — yt-dlp download, ffmpeg frame extraction with auto-scaled fps, native-caption transcript with Whisper (Groq/OpenAI) fallback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
97 lines
2.8 KiB
Python
Executable File
97 lines
2.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Parse a WebVTT subtitle file into a clean, timestamped transcript.
|
|
|
|
YouTube auto-subs emit rolling-duplicate cues (each line appears 2-3 times as it
|
|
scrolls). We dedupe consecutive identical cues and merge their time ranges.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
TS_RE = re.compile(
|
|
r"(\d{2}):(\d{2}):(\d{2})[.,](\d{3})\s+-->\s+(\d{2}):(\d{2}):(\d{2})[.,](\d{3})"
|
|
)
|
|
TAG_RE = re.compile(r"<[^>]+>")
|
|
|
|
|
|
def _to_seconds(h: str, m: str, s: str, ms: str) -> float:
|
|
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0
|
|
|
|
|
|
def parse_vtt(path: str) -> list[dict]:
|
|
text = Path(path).read_text(encoding="utf-8", errors="ignore")
|
|
lines = text.splitlines()
|
|
|
|
segments: list[dict] = []
|
|
i = 0
|
|
while i < len(lines):
|
|
match = TS_RE.match(lines[i])
|
|
if not match:
|
|
i += 1
|
|
continue
|
|
|
|
start = _to_seconds(*match.groups()[:4])
|
|
end = _to_seconds(*match.groups()[4:])
|
|
i += 1
|
|
|
|
cue_lines: list[str] = []
|
|
while i < len(lines) and lines[i].strip():
|
|
cleaned = TAG_RE.sub("", lines[i]).strip()
|
|
if cleaned:
|
|
cue_lines.append(cleaned)
|
|
i += 1
|
|
|
|
cue_text = " ".join(cue_lines).strip()
|
|
if cue_text:
|
|
segments.append({"start": round(start, 2), "end": round(end, 2), "text": cue_text})
|
|
i += 1
|
|
|
|
return _dedupe(segments)
|
|
|
|
|
|
def _dedupe(segments: list[dict]) -> list[dict]:
|
|
"""Collapse rolling duplicates common in YouTube auto-subs."""
|
|
out: list[dict] = []
|
|
for seg in segments:
|
|
if out and seg["text"] == out[-1]["text"]:
|
|
out[-1]["end"] = seg["end"]
|
|
continue
|
|
if out and seg["text"].startswith(out[-1]["text"] + " "):
|
|
out[-1]["text"] = seg["text"]
|
|
out[-1]["end"] = seg["end"]
|
|
continue
|
|
out.append(seg)
|
|
return out
|
|
|
|
|
|
def filter_range(
|
|
segments: list[dict],
|
|
start_seconds: float | None,
|
|
end_seconds: float | None,
|
|
) -> list[dict]:
|
|
"""Return segments whose time range overlaps [start, end]."""
|
|
if start_seconds is None and end_seconds is None:
|
|
return segments
|
|
lo = start_seconds if start_seconds is not None else float("-inf")
|
|
hi = end_seconds if end_seconds is not None else float("inf")
|
|
return [seg for seg in segments if seg["end"] >= lo and seg["start"] <= hi]
|
|
|
|
|
|
def format_transcript(segments: list[dict]) -> str:
|
|
lines = []
|
|
for seg in segments:
|
|
start = int(seg["start"])
|
|
stamp = f"[{start // 60:02d}:{start % 60:02d}]"
|
|
lines.append(f"{stamp} {seg['text']}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2:
|
|
print("usage: transcribe.py <vtt-path>", file=sys.stderr)
|
|
raise SystemExit(2)
|
|
print(format_transcript(parse_vtt(sys.argv[1])))
|