429f3143e5
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>
383 lines
16 KiB
Python
Executable File
383 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""/watch entry point: download video, extract frames, parse transcript.
|
|
|
|
Prints a markdown report to stdout listing frame paths + transcript. Claude
|
|
then Reads each frame path to see the video.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
|
|
SCRIPT_DIR = Path(__file__).parent.resolve()
|
|
sys.path.insert(0, str(SCRIPT_DIR))
|
|
|
|
from config import frame_cap, get_config # noqa: E402
|
|
from download import download, fetch_captions, is_url # noqa: E402
|
|
from frames import MAX_FPS, auto_fps, auto_fps_focus, extract_at_timestamps, extract_keyframes, extract_scene_or_uniform, format_time, get_metadata, merge_frames, parse_time, parse_timestamps # noqa: E402
|
|
from transcribe import filter_range, format_transcript, parse_vtt # noqa: E402
|
|
from whisper import load_api_key, transcribe_video # noqa: E402
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(
|
|
prog="watch",
|
|
description="Download a video, extract auto-scaled frames, and surface the transcript.",
|
|
)
|
|
ap.add_argument("source", help="Video URL or local file path")
|
|
ap.add_argument("--max-frames", type=int, default=None, help="Override frame cap")
|
|
ap.add_argument("--resolution", type=int, default=512, help="Frame width in pixels (default 512)")
|
|
ap.add_argument("--fps", type=float, default=None, help="Override auto-fps")
|
|
ap.add_argument(
|
|
"--detail",
|
|
choices=["transcript", "efficient", "balanced", "token-burner"],
|
|
default=None,
|
|
help="Fidelity/speed dial: transcript (no frames), efficient (fast keyframes, cap 50), "
|
|
"balanced (scene, cap 100), token-burner (scene, uncapped).",
|
|
)
|
|
ap.add_argument(
|
|
"--timestamps",
|
|
type=str,
|
|
default=None,
|
|
help="Comma-separated absolute timestamps (SS, MM:SS, HH:MM:SS) to grab a frame at, "
|
|
"e.g. transcript-flagged 'look here' moments. Added on top of the detail frames "
|
|
"(reserved against the cap); with --detail transcript these become the only frames.",
|
|
)
|
|
ap.add_argument("--start", type=str, default=None, help="Range start (SS, MM:SS, or HH:MM:SS)")
|
|
ap.add_argument("--end", type=str, default=None, help="Range end (SS, MM:SS, or HH:MM:SS)")
|
|
ap.add_argument("--out-dir", type=str, default=None, help="Working directory (default: tmp)")
|
|
ap.add_argument(
|
|
"--no-whisper",
|
|
action="store_true",
|
|
help="Disable Whisper fallback. Report frames-only if no captions available.",
|
|
)
|
|
ap.add_argument(
|
|
"--whisper",
|
|
choices=["groq", "openai"],
|
|
default=None,
|
|
help="Force a specific Whisper backend. Default: prefer Groq, fall back to OpenAI.",
|
|
)
|
|
args = ap.parse_args()
|
|
|
|
config = get_config()
|
|
detail = args.detail or str(config["detail"])
|
|
configured_cap = frame_cap(detail)
|
|
if args.max_frames is not None:
|
|
max_frames = args.max_frames
|
|
else:
|
|
max_frames = configured_cap
|
|
if max_frames is not None and max_frames < 1:
|
|
raise SystemExit("--max-frames must be greater than zero")
|
|
budget_cap = max_frames if max_frames is not None else 100
|
|
cue_timestamps = parse_timestamps(args.timestamps)
|
|
|
|
if args.out_dir:
|
|
work = Path(args.out_dir).expanduser().resolve()
|
|
else:
|
|
work = Path(tempfile.mkdtemp(prefix="watch-"))
|
|
work.mkdir(parents=True, exist_ok=True)
|
|
print(f"[watch] working dir: {work}", file=sys.stderr)
|
|
|
|
url_source = is_url(args.source)
|
|
dl: dict = {"subtitle_path": None, "info": {}, "downloaded": False}
|
|
transcript_segments: list[dict] = []
|
|
transcript_text: str | None = None
|
|
transcript_source: str | None = None
|
|
video_path: str | None = None
|
|
|
|
if url_source:
|
|
print("[watch] checking metadata/captions via yt-dlp…", file=sys.stderr)
|
|
dl = fetch_captions(args.source, work / "download")
|
|
if dl.get("subtitle_path"):
|
|
try:
|
|
transcript_segments = parse_vtt(dl["subtitle_path"])
|
|
transcript_text = format_transcript(transcript_segments)
|
|
transcript_source = "captions"
|
|
except Exception as exc:
|
|
print(f"[watch] subtitle parse failed: {exc}", file=sys.stderr)
|
|
transcript_segments = []
|
|
|
|
# --timestamps needs the video for frame grabs, so it overrides the
|
|
# transcript-mode download skip (and forces a full, not audio-only, fetch).
|
|
audio_only = detail == "transcript" and not cue_timestamps
|
|
if detail == "transcript" and transcript_segments and not cue_timestamps:
|
|
video_path = None
|
|
else:
|
|
if url_source:
|
|
print(
|
|
"[watch] downloading audio via yt-dlp…" if audio_only
|
|
else "[watch] downloading video via yt-dlp…",
|
|
file=sys.stderr,
|
|
)
|
|
dl = download(
|
|
args.source,
|
|
work / "download",
|
|
audio_only=audio_only,
|
|
)
|
|
else:
|
|
print("[watch] using local file…", file=sys.stderr)
|
|
dl = download(args.source, work / "download")
|
|
video_path = dl["video_path"]
|
|
|
|
meta = get_metadata(video_path) if video_path else {
|
|
"duration_seconds": float((dl.get("info") or {}).get("duration") or 0),
|
|
"width": None,
|
|
"height": None,
|
|
"codec": None,
|
|
"has_audio": False,
|
|
}
|
|
full_duration = meta["duration_seconds"]
|
|
|
|
start_sec = parse_time(args.start)
|
|
end_sec = parse_time(args.end)
|
|
|
|
if start_sec is not None and start_sec < 0:
|
|
raise SystemExit("--start must be non-negative")
|
|
if end_sec is not None and start_sec is not None and end_sec <= start_sec:
|
|
raise SystemExit("--end must be greater than --start")
|
|
if full_duration > 0 and start_sec is not None and start_sec >= full_duration:
|
|
raise SystemExit(f"--start {start_sec:.1f}s is past end of video ({full_duration:.1f}s)")
|
|
|
|
effective_start = start_sec if start_sec is not None else 0.0
|
|
effective_end = end_sec if end_sec is not None else full_duration
|
|
effective_duration = max(0.0, effective_end - effective_start)
|
|
focused = start_sec is not None or end_sec is not None
|
|
|
|
if focused:
|
|
fps, target = auto_fps_focus(effective_duration, max_frames=budget_cap)
|
|
else:
|
|
fps, target = auto_fps(effective_duration, max_frames=budget_cap)
|
|
if args.fps is not None:
|
|
fps = min(args.fps, MAX_FPS)
|
|
target = max(1, int(round(fps * effective_duration)))
|
|
|
|
if transcript_segments and focused:
|
|
transcript_segments = filter_range(transcript_segments, start_sec, end_sec)
|
|
transcript_text = format_transcript(transcript_segments)
|
|
|
|
scope = (
|
|
f"{format_time(effective_start)}-{format_time(effective_end)} ({effective_duration:.1f}s)"
|
|
if focused else f"full {effective_duration:.1f}s"
|
|
)
|
|
frames: list[dict] = []
|
|
frame_meta: dict = {"engine": "none", "candidate_count": 0, "selected_count": 0, "fallback": False}
|
|
cue_frames: list[dict] = []
|
|
cue_meta: dict = {}
|
|
|
|
# Transcript cues are pinned: extracted first and counted against the cap so
|
|
# the detail engine never evicts the moments the user explicitly asked for.
|
|
if cue_timestamps and video_path:
|
|
cue_frames, cue_meta = extract_at_timestamps(
|
|
video_path,
|
|
work / "frames",
|
|
cue_timestamps,
|
|
resolution=args.resolution,
|
|
max_frames=max_frames,
|
|
start_seconds=start_sec,
|
|
end_seconds=end_sec,
|
|
)
|
|
if cue_meta.get("dropped_out_of_window"):
|
|
print(
|
|
f"[watch] {cue_meta['dropped_out_of_window']} cue timestamp(s) outside the "
|
|
"focus range — dropped",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
detail_budget = max_frames if max_frames is None else max(0, max_frames - len(cue_frames))
|
|
if detail != "transcript" and video_path and detail_budget != 0:
|
|
cap_label = "unlimited" if detail_budget is None else str(detail_budget)
|
|
engine_label = "keyframes" if detail == "efficient" else "scene-aware frames"
|
|
print(
|
|
f"[watch] extracting {engine_label} over {scope} "
|
|
f"(target {target}, cap {cap_label})…",
|
|
file=sys.stderr,
|
|
)
|
|
if detail == "efficient":
|
|
frames, frame_meta = extract_keyframes(
|
|
video_path,
|
|
work / "frames",
|
|
resolution=args.resolution,
|
|
max_frames=detail_budget,
|
|
start_seconds=start_sec,
|
|
end_seconds=end_sec,
|
|
)
|
|
else: # balanced, token-burner
|
|
frames, frame_meta = extract_scene_or_uniform(
|
|
video_path,
|
|
work / "frames",
|
|
fps=fps,
|
|
target_frames=target,
|
|
resolution=args.resolution,
|
|
max_frames=detail_budget,
|
|
start_seconds=start_sec,
|
|
end_seconds=end_sec,
|
|
)
|
|
|
|
if cue_frames:
|
|
frames = merge_frames(frames, cue_frames)
|
|
|
|
if not transcript_segments and dl.get("subtitle_path"):
|
|
try:
|
|
all_segments = parse_vtt(dl["subtitle_path"])
|
|
transcript_segments = filter_range(all_segments, start_sec, end_sec) if focused else all_segments
|
|
transcript_text = format_transcript(transcript_segments)
|
|
transcript_source = "captions"
|
|
except Exception as exc:
|
|
print(f"[watch] subtitle parse failed: {exc}", file=sys.stderr)
|
|
|
|
if not transcript_segments and not args.no_whisper and video_path and meta.get("has_audio"):
|
|
backend, api_key = load_api_key(args.whisper)
|
|
if backend and api_key:
|
|
try:
|
|
all_segments, used_backend = transcribe_video(
|
|
video_path,
|
|
work / "audio.mp3",
|
|
backend=backend,
|
|
api_key=api_key,
|
|
)
|
|
transcript_segments = filter_range(all_segments, start_sec, end_sec) if focused else all_segments
|
|
transcript_text = format_transcript(transcript_segments)
|
|
transcript_source = f"whisper ({used_backend})"
|
|
except SystemExit as exc:
|
|
print(f"[watch] whisper fallback failed: {exc}", file=sys.stderr)
|
|
else:
|
|
hint = (
|
|
f"--whisper {args.whisper} was set but the matching API key is missing"
|
|
if args.whisper else
|
|
"no subtitles and no Whisper API key found"
|
|
)
|
|
setup_py = SCRIPT_DIR / "setup.py"
|
|
print(
|
|
f"[watch] {hint} — run `python3 {setup_py}` to enable the Whisper fallback",
|
|
file=sys.stderr,
|
|
)
|
|
elif not transcript_segments and video_path and not meta.get("has_audio"):
|
|
print("[watch] no audio stream found — proceeding without transcription", file=sys.stderr)
|
|
|
|
info = dl.get("info") or {}
|
|
|
|
print()
|
|
print("# watch: video report")
|
|
print()
|
|
print(f"- **Source:** {args.source}")
|
|
if info.get("title"):
|
|
print(f"- **Title:** {info['title']}")
|
|
if info.get("uploader"):
|
|
print(f"- **Uploader:** {info['uploader']}")
|
|
print(f"- **Duration:** {format_time(full_duration)} ({full_duration:.1f}s)")
|
|
if focused:
|
|
print(
|
|
f"- **Focus range:** {format_time(effective_start)} → {format_time(effective_end)} "
|
|
f"({effective_duration:.1f}s)"
|
|
)
|
|
if meta.get("width") and meta.get("height"):
|
|
print(f"- **Resolution:** {meta['width']}x{meta['height']} ({meta.get('codec') or 'unknown codec'})")
|
|
range_mode = "focused" if focused else "full"
|
|
print(f"- **Detail:** {detail}")
|
|
detail_count = frame_meta.get("selected_count", 0)
|
|
if detail != "transcript":
|
|
cap_label = "unlimited" if detail_budget is None else str(detail_budget)
|
|
engine = frame_meta.get("engine", "scene")
|
|
fallback = " with uniform fallback" if frame_meta.get("fallback") else ""
|
|
print(
|
|
f"- **Frames:** {detail_count} selected from {frame_meta.get('candidate_count', detail_count)} "
|
|
f"candidates ({engine}{fallback}, {range_mode} range, budget {target}, cap {cap_label})"
|
|
)
|
|
elif not cue_frames:
|
|
print("- **Frames:** skipped (transcript detail)")
|
|
if cue_frames:
|
|
dropped = cue_meta.get("dropped_out_of_window", 0)
|
|
drop_note = f", {dropped} dropped outside range" if dropped else ""
|
|
print(
|
|
f"- **Cue frames:** {len(cue_frames)} at transcript-flagged timestamps "
|
|
f"(transcript-cue{drop_note})"
|
|
)
|
|
if frames:
|
|
print(f"- **Frame size:** max {args.resolution}px wide, max 1998px tall")
|
|
if transcript_segments:
|
|
in_range = " in range" if focused else ""
|
|
print(
|
|
f"- **Transcript:** {len(transcript_segments)} segments{in_range} "
|
|
f"(via {transcript_source or 'captions'})"
|
|
)
|
|
else:
|
|
print("- **Transcript:** none available")
|
|
|
|
if detail == "token-burner" and len(frames) > 250:
|
|
print()
|
|
print(
|
|
f"> **Warning:** token-burner detail selected {len(frames)} frames. "
|
|
"This may use a large number of image tokens."
|
|
)
|
|
|
|
if not focused and full_duration > 600 and detail != "transcript":
|
|
mins = int(full_duration // 60)
|
|
print()
|
|
print(
|
|
f"> **Warning:** This is a {mins}-minute video. Frame coverage is sparse at this length — "
|
|
"accuracy degrades noticeably on anything over 10 minutes. For better results, "
|
|
"re-run with `--start HH:MM:SS --end HH:MM:SS` to zoom into a specific section."
|
|
)
|
|
|
|
print()
|
|
print("## Frames")
|
|
print()
|
|
if frames:
|
|
print(f"Frames live at: `{work / 'frames'}`")
|
|
print()
|
|
print(
|
|
"**Read each frame path below with the Read tool to view the image.** "
|
|
"Frames are in chronological order; `t=MM:SS` is the absolute timestamp in the source video."
|
|
)
|
|
print()
|
|
for frame in frames:
|
|
print(
|
|
f"- `{frame['path']}` "
|
|
f"(t={format_time(frame['timestamp_seconds'])}, reason={frame.get('reason', 'selected')})"
|
|
)
|
|
else:
|
|
print("_No frames extracted._")
|
|
|
|
print()
|
|
print("## Transcript")
|
|
print()
|
|
if transcript_text:
|
|
label = transcript_source or "captions"
|
|
if focused:
|
|
print(f"_Source: {label}. Filtered to {format_time(effective_start)} → {format_time(effective_end)}:_")
|
|
else:
|
|
print(f"_Source: {label}._")
|
|
print()
|
|
print("```")
|
|
print(transcript_text)
|
|
print("```")
|
|
elif detail == "transcript":
|
|
print(
|
|
"_No transcript available at transcript detail. Captions were missing and Whisper was "
|
|
"unavailable or failed, so there is no visual fallback here. Re-run with "
|
|
"`--detail balanced` for frames._"
|
|
)
|
|
elif focused and dl.get("subtitle_path"):
|
|
print(f"_No transcript lines fell inside {format_time(effective_start)} → {format_time(effective_end)}._")
|
|
else:
|
|
setup_py = SCRIPT_DIR / "setup.py"
|
|
print(
|
|
"_No transcript available — proceed with frames only. "
|
|
"Captions were missing and the Whisper fallback was unavailable "
|
|
"(no API key set, or `--no-whisper` was used). "
|
|
f"Run `python3 {setup_py}` to enable Whisper, then re-run._"
|
|
)
|
|
|
|
print()
|
|
print("---")
|
|
print(f"_Work dir: `{work}` — delete when done._")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|