Files
claude-video/skills/watch/scripts/transcribe.py
T
bradautomates 429f3143e5 Restructure as a self-contained Agent Skills package (fix Codex install)
Move the skill into skills/watch/ so SKILL.md and its scripts/ runtime are
siblings inside one folder. `npx skills add` (Codex/Cursor/Copilot/agents) now
copies a working skill as a unit; previously it grabbed the root SKILL.md alone
and left scripts/ behind, so the skill was dead on arrival on every non-Claude
host. Mirrors the layout last30days-skill adopted for the same reason.

- skills/watch/{SKILL.md,scripts/}: self-contained skill folder
- SKILL.md: resolve a harness-agnostic $SKILL_DIR (the dir it was Read from)
  instead of the Claude-Code-only ${CLAUDE_SKILL_DIR}; guard + 19 call sites
- drop commands/watch.md: /watch derives from frontmatter (name + user-invocable)
- .codex-plugin/plugin.json: full manifest with "skills": "./skills/" + interface
- add .agents/plugins/marketplace.json, AGENTS.md, CLAUDE.md, .skillignore
- build-skill.sh: archive the skills/watch subtree (one SKILL.md, no zip -d)
- fix paths in tests, hooks hint, .gitattributes, release.yml
- relocate dev-sync.sh to repo root and fix REPO_ROOT
- README: content-ideas structure, npx skills install, star history

Verified: 37/37 tests pass; npx skills add bundles the full scripts/ runtime;
manifests valid; versions synced at 0.1.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 22:12:54 +10:00

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])))