Initial commit: /watch skill v0.1.0

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>
This commit is contained in:
bradautomates
2026-04-24 14:40:34 +10:00
commit 29b8a2988a
19 changed files with 1976 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
{
"name": "claude-video",
"metadata": {
"description": "Give Claude a video input. /watch downloads, extracts frames, transcribes, and hands it all to Claude."
},
"owner": {
"name": "Bradley Bonanno"
},
"plugins": [
{
"name": "watch",
"description": "Watch a video (URL or local path). Downloads with yt-dlp, extracts frames with ffmpeg, transcribes via captions or Whisper.",
"author": {
"name": "Bradley Bonanno"
},
"source": "./",
"category": "productivity",
"homepage": "https://github.com/bradautomates/claude-video"
}
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "watch",
"version": "0.1.0",
"description": "Let Claude watch a video. Downloads with yt-dlp, extracts auto-scaled frames with ffmpeg, pulls captions or falls back to Whisper, and hands frames + transcript to Claude so it can answer questions about the video.",
"author": {
"name": "Bradley Bonanno"
},
"homepage": "https://github.com/bradautomates/claude-video",
"repository": "https://github.com/bradautomates/claude-video",
"license": "MIT",
"keywords": ["video", "watch", "youtube", "vimeo", "tiktok", "transcription", "whisper", "yt-dlp", "ffmpeg", "multimodal", "frames"]
}
+3
View File
@@ -0,0 +1,3 @@
{
"name": "watch"
}
+28
View File
@@ -0,0 +1,28 @@
# Exclude non-runtime files from `git archive` output.
# Used by scripts/build-skill.sh to produce a claude.ai-upload-ready .skill file.
# Anthropic canonical skill-packaging excludes
# (mirrors anthropics/skills/skills/skill-creator/scripts/package_skill.py)
__pycache__/ export-ignore
node_modules/ export-ignore
*.pyc export-ignore
.DS_Store export-ignore
# Dev, docs, test, and media — not needed at skill runtime
tests/ export-ignore
docs/ export-ignore
fixtures/ export-ignore
assets/ export-ignore
examples/ export-ignore
# NOTE: commands/, hooks/, and .claude-plugin/ are NOT export-ignored because
# Claude Code's /plugin install fetches this same git archive tarball — they
# must be in the archive for the plugin to install correctly. The claude.ai
# .skill bundle strips them afterward via `zip -d` in scripts/build-skill.sh.
# CI workflows — repo-only, not needed at skill runtime
.github/ export-ignore
# Build config itself
.gitignore export-ignore
.gitattributes export-ignore
+31
View File
@@ -0,0 +1,31 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build .skill artifact
run: |
bash scripts/build-skill.sh
test -f dist/watch.skill
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
files: dist/watch.skill
generate_release_notes: true
draft: false
prerelease: false
+16
View File
@@ -0,0 +1,16 @@
# OS / editor / tooling
.DS_Store
.entire/
__pycache__/
*.pyc
.venv/
.coverage
htmlcov/
.idea/
.vscode/
# build artifact from scripts/build-skill.sh
/dist/
# local Claude Code settings
.claude/settings.local.json
+17
View File
@@ -0,0 +1,17 @@
# Changelog
All notable changes to `/watch` are documented here.
## [0.1.0] — 2026-04-24
Initial marketplace release.
### Added
- `/watch <url-or-path> [question]` slash command.
- yt-dlp download with native caption extraction (manual + auto-subs).
- ffmpeg frame extraction with auto-scaled fps (≤2 fps, ≤100 frames, duration-aware budget).
- `--start` / `--end` focused mode with denser frame budget and transcript range filtering.
- Whisper fallback (Groq preferred, OpenAI secondary) for videos without captions.
- `setup.py` preflight: silent `--check`, structured `--json`, and installer that auto-runs `brew install` on macOS.
- Session-start hook that prints a one-line status on first run / partial config.
- `.skill` bundle packaging for claude.ai upload via `scripts/build-skill.sh`.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Bradley Bonanno
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+200
View File
@@ -0,0 +1,200 @@
# /watch
**Give Claude the ability to watch any video.**
Claude Code:
```
/plugin marketplace add bradautomates/claude-video
/plugin install watch@claude-video
```
claude.ai (web): [download `watch.skill`](https://github.com/bradautomates/claude-video/releases/latest) and drop it into Settings → Capabilities → Skills.
Codex / generic skills:
```bash
git clone https://github.com/bradautomates/claude-video.git ~/.codex/skills/watch
```
Zero config to start — `yt-dlp` and `ffmpeg` install on first run via `brew` on macOS (Linux/Windows print exact commands). Captions cover most public videos for free. Whisper API key is only needed when a video has no captions.
---
Claude can read a webpage, run a script, browse a repo. What it can't do, out of the box, is *watch a video*. You paste a YouTube link and it has to either guess from the title or pull a transcript that's missing 90% of what's on screen.
With Claude Video `/watch` you can paste a URL or a local path, ask a question, and Claude downloads the video, extracts frames at an auto-scaled rate, pulls a timestamped transcript (free captions when available, Whisper API as fallback), and `Read`s every frame as an image. By the time it answers, it has *seen* the video and *heard* the audio.
```
/watch https://youtu.be/dQw4w9WgXcQ what happens at the 30 second mark?
```
## Why this exists
I built this because I'm constantly using video to keep up with content. If I see a YouTube video that's blowing up, I want to know how the creator structured the hook — what's on screen in the first 3 seconds, what they said, why it worked. That used to mean watching it myself with a notepad. Now I just paste the URL and ask.
The other half is summarization. Most YouTube videos don't deserve 20 minutes of my attention. I hand the URL to Claude, it pulls the transcript, and tells me what actually happened. If the visual matters, frames come along too. If it's a podcast or a talking head, transcript is enough.
Claude is great at reading and synthesizing — but until now, video was the one input I couldn't hand it. Pasting a YouTube link got you nothing useful. `/watch` closes that gap.
## What people actually use it for
**Analyze someone else's content.** `/watch https://youtu.be/<viral-video> what hook did they open with?` Claude looks at the first frames, reads the opening transcript, breaks down the structure. Same for ad creative, competitor launches, podcast intros, anything where the *how* matters as much as the *what*.
**Diagnose a bug from a video.** Someone sends you a screen recording of something broken. `/watch bug-repro.mov what's going wrong?` Claude watches the recording, finds the frame where the issue appears, describes what's on screen, often catches the cause without you ever opening the file.
**Summarize a video.** `/watch https://youtu.be/<long-thing> summarize this` does the obvious thing — pulls the structure, the key moments, what was actually said and shown. Faster than watching at 2x.
## How it works
1. **You paste a video and a question.** URL (anything yt-dlp supports — YouTube, Loom, TikTok, X, Instagram, plus a few hundred more) or a local path (`.mp4`, `.mov`, `.mkv`, `.webm`).
2. **`yt-dlp` downloads it.** For URLs, into a temp working directory. For local files, no download — just probed in place.
3. **`ffmpeg` extracts frames at an auto-scaled rate.** The frame budget is duration-aware: ≤30s gets ~30 frames, 30-60s gets ~40, 1-3min gets ~60, 3-10min gets ~80, longer gets 100 sparsely. Hard ceilings: 2 fps, 100 frames. JPEGs at 512px wide by default — bump with `--resolution 1024` if Claude needs to read on-screen text.
4. **The transcript comes from one of two places.** First try: `yt-dlp` pulls native captions (manual or auto-generated) from the source. Free, instant, accurate-ish. Fallback: extract a mono 16 kHz audio clip and ship it to Whisper — Groq's `whisper-large-v3` (preferred — cheaper and faster) or OpenAI's `whisper-1`.
5. **Frames + transcript are handed to Claude.** The script prints frame paths with `t=MM:SS` markers and the transcript with timestamps. Claude `Read`s each frame in parallel — JPEGs render directly as images in its context.
6. **Claude answers grounded in what's actually on screen and in the audio.** Not "based on the description" or "according to the title." It saw the frames. It heard the transcript. It answers the way someone who watched the video would.
7. **Cleanup.** The script prints a working directory at the end. If you're not asking follow-ups, Claude removes it.
## Frame budget — why it matters
Token cost is dominated by frames. Every frame is an image; image tokens add up fast. The script's auto-fps logic exists so you don't blow your context budget on a sparse scan of a 30-minute video that would have been better answered by a focused 30-second window.
| Duration | Default frame budget | What you get |
|----------|---------------------|--------------|
| ≤30 s | ~30 frames | Dense — basically every key moment |
| 30 s - 1 min | ~40 frames | Still dense |
| 1 - 3 min | ~60 frames | Comfortable |
| 3 - 10 min | ~80 frames | Sparse but workable |
| > 10 min | 100 frames | "Sparse scan" warning — re-run focused |
When the user names a moment ("around 2:30", "the last 30 seconds", "from 0:45 to 1:00"), pass `--start` / `--end`. Focused mode gets denser per-second budgets, capped at 2 fps. Far more useful than a sparse pass over the whole thing.
## Install
| Surface | Install |
|---------|---------|
| **Claude Code** | `/plugin marketplace add bradautomates/claude-video` then `/plugin install watch@claude-video` |
| **claude.ai** (web) | [Download `watch.skill`](https://github.com/bradautomates/claude-video/releases/latest) → Settings → Capabilities → Skills → `+` |
| **Codex** | `git clone https://github.com/bradautomates/claude-video.git ~/.codex/skills/watch` |
| **Manual / dev** | `git clone https://github.com/bradautomates/claude-video.git ~/.claude/skills/watch` |
### Claude Code
```
/plugin marketplace add bradautomates/claude-video
/plugin install watch@claude-video
```
Update later with `/plugin update watch@claude-video`.
### claude.ai (web)
1. [Download `watch.skill`](https://github.com/bradautomates/claude-video/releases/latest) from the latest release.
2. Go to Settings → Capabilities → Skills.
3. Click `+` and drop the file in.
Enable "Code execution and file creation" under Capabilities first — the skill shells out to `ffmpeg` and `yt-dlp`, so it won't run without it.
### Codex
```bash
git clone https://github.com/bradautomates/claude-video.git ~/.codex/skills/watch
```
### Manual (developer)
```bash
git clone https://github.com/bradautomates/claude-video.git ~/.claude/skills/watch
```
## First run
On the first `/watch` call, the skill runs `scripts/setup.py --check`. If `ffmpeg` / `yt-dlp` aren't on your PATH, or no Whisper API key is set, it walks you through fixing it:
- **macOS** — auto-runs `brew install ffmpeg yt-dlp`.
- **Linux** — prints the exact `apt` / `dnf` / `pipx` commands.
- **Windows** — prints the `winget` / `pip` commands.
- **API key** — scaffolds `~/.config/watch/.env` (mode `0600`) with commented placeholders for `GROQ_API_KEY` (preferred) and `OPENAI_API_KEY`.
After setup, preflight is silent and `/watch` just works. The check is a sub-100ms lookup, so it doesn't slow you down on subsequent runs.
## Bring your own keys
Captions cover the majority of public videos for free. The Whisper fallback only kicks in when a video genuinely has no caption track — typically local files, TikToks, some Vimeos, and the occasional caption-less YouTube upload.
| Capability | What you need | Cost |
|------------|---------------|------|
| Download + native captions | `yt-dlp` + `ffmpeg` | Free |
| Whisper fallback (preferred) | [Groq API key](https://console.groq.com/keys) — `whisper-large-v3` | Cheap, fast |
| Whisper fallback (alt) | [OpenAI API key](https://platform.openai.com/api-keys) — `whisper-1` | Standard pricing |
| Disable Whisper entirely | `--no-whisper` | Free, frames-only when no captions |
## Usage
```
/watch https://youtu.be/dQw4w9WgXcQ what happens at the 30 second mark?
/watch https://www.tiktok.com/@user/video/123 summarize this
/watch ~/Movies/screen-recording.mp4 when does the UI break?
/watch https://vimeo.com/123 what tools does she mention?
```
Focused on a specific section — denser frame budget, lower token cost:
```
/watch https://youtu.be/abc --start 2:15 --end 2:45
/watch video.mp4 --start 50 --end 60
/watch "$URL" --start 1:12:00 # from 1h12m to end
```
Other knobs (passed to `scripts/watch.py`):
- `--max-frames N` — lower the frame cap for a tighter token budget.
- `--resolution W` — bump frame width to 1024 px when Claude needs to read on-screen text (slides, terminals, code).
- `--fps F` — override the auto-fps calculation (still capped at 2 fps).
- `--whisper groq|openai` — force a specific Whisper backend.
- `--no-whisper` — disable transcription entirely; frames only.
- `--out-dir DIR` — keep working files somewhere specific (default: auto-generated tmp dir).
## Limits
- **Best accuracy: under 10 minutes.** Past that the script prints a "sparse scan" warning — re-run focused on the part you actually care about with `--start`/`--end`.
- **Hard caps: 2 fps, 100 frames.** Frame count drives token cost; the script enforces this even when the auto-fps math would imply higher.
- **Whisper upload limit: 25 MB.** At mono 16 kHz that's about 50 minutes of audio. Longer videos need either captions or `--start`/`--end` to a smaller window.
- **No private platforms.** This skill doesn't log into anything. Public URLs and local files only. If yt-dlp can't reach it without auth, neither can `/watch`.
## Structure
```
.
├── SKILL.md # skill contract — loaded by all three surfaces
├── scripts/
│ ├── watch.py # entry point — orchestrates download → frames → transcript
│ ├── download.py # yt-dlp wrapper
│ ├── frames.py # ffmpeg frame extraction + auto-fps logic
│ ├── transcribe.py # VTT parsing + dedupe + Whisper orchestration
│ ├── whisper.py # Groq / OpenAI clients (pure stdlib)
│ ├── setup.py # preflight + installer
│ └── build-skill.sh # build dist/watch.skill for claude.ai upload
├── hooks/ # SessionStart status hook (Claude Code only)
├── .claude-plugin/ # plugin.json + marketplace.json (Claude Code)
├── .codex-plugin/ # codex packaging
└── .github/workflows/ # release.yml — auto-builds watch.skill on tag push
```
## Develop
```bash
# Build the claude.ai upload bundle:
bash scripts/build-skill.sh # → dist/watch.skill
```
Releasing: tag `vX.Y.Z`, push the tag. The workflow builds `dist/watch.skill` and attaches it to the GitHub release.
See [CHANGELOG.md](CHANGELOG.md) for version history.
## Open source
MIT license.
Built on `yt-dlp`, `ffmpeg`, and Claude's multimodal `Read` tool. Whisper transcription via [Groq](https://groq.com) or [OpenAI](https://openai.com).
---
[github.com/bradautomates/claude-video](https://github.com/bradautomates/claude-video) · [LICENSE](LICENSE)
+171
View File
@@ -0,0 +1,171 @@
---
name: watch
description: Watch a video (URL or local path). Downloads with yt-dlp, extracts auto-scaled frames with ffmpeg, pulls the transcript from captions (or Whisper API fallback), and hands the result to Claude so it can answer questions about what's in the video.
argument-hint: "<video-url-or-path> [question]"
allowed-tools: Bash, Read, AskUserQuestion
homepage: https://github.com/bradautomates/claude-video
repository: https://github.com/bradautomates/claude-video
author: bradautomates
license: MIT
user-invocable: true
---
# /watch — Claude watches a video
You don't have a video input; this skill gives you one. A Python script downloads the video, extracts frames as JPEGs, gets a timestamped transcript (native captions first, then Whisper API as fallback), and prints frame paths. You then `Read` each frame path to see the images and combine them with the transcript to answer the user.
## Step 0 — Setup preflight (runs every `/watch` invocation, silent on success)
Before every `/watch` run, verify that dependencies and an API key are in place:
```bash
python3 "${CLAUDE_SKILL_DIR}/scripts/setup.py" --check
```
This is a <100ms lookup. On exit 0, the script emits **nothing** — proceed to Step 1 without comment. **Do NOT announce "setup is complete" to the user** — they don't need a status message on every turn. The only acceptable user-visible output from Step 0 is when remediation is required.
On non-zero exit, follow the table:
| Exit | Meaning | Action |
|------|---------|--------|
| `2` | Missing binaries (`ffmpeg` / `ffprobe` / `yt-dlp`) | Run installer |
| `3` | No Whisper API key | Run installer to scaffold `.env`, then ask user for a key |
| `4` | Both missing | Run installer, then ask for a key |
The installer is idempotent — safe to re-run:
```bash
python3 "${CLAUDE_SKILL_DIR}/scripts/setup.py"
```
On macOS with Homebrew, it auto-installs `ffmpeg` and `yt-dlp`. On Linux/Windows, it prints the exact install commands for the user to run. It scaffolds `~/.config/watch/.env` with commented placeholders at `0600` perms, and writes `SETUP_COMPLETE=true` once deps + a key are in place so the next session knows this user has already been through the wizard.
**If an API key is still missing after install:** use `AskUserQuestion` to ask the user whether they have a Groq API key (preferred — cheaper, faster) or an OpenAI key. Then write it into `~/.config/watch/.env` — set the matching `GROQ_API_KEY=...` or `OPENAI_API_KEY=...` line. If they don't want to set up Whisper, proceed with `--no-whisper` and tell them videos without native captions will come back frames-only.
**Structured mode (optional):** `python3 "${CLAUDE_SKILL_DIR}/scripts/setup.py" --json` emits `{status, first_run, missing_binaries, whisper_backend, has_api_key, config_file, platform}` where `status` is one of `ready | needs_install | needs_key | needs_install_and_key`. Use this when you need to branch on specifics (e.g. "is this the user's very first run?" → `first_run: true`).
Within a single session, you can skip Step 0 on follow-up `/watch` calls — once `--check` returned 0, nothing about the environment changes between turns.
## When to use
- User pastes a video URL (YouTube, Vimeo, X, TikTok, Twitch clip, most yt-dlp-supported sites) and asks about it.
- User points at a local video file (`.mp4`, `.mov`, `.mkv`, `.webm`, etc.) and asks about it.
- User types `/watch <url-or-path> [question]`.
## Recommended limits
- **Best accuracy: videos under 10 minutes.** Frame coverage scales inversely with duration.
- **Hard caps: 100 frames total and 2 fps.** Token cost grows with frame count, so the script targets a frame budget by duration (and never exceeds 2 fps even when the budget would imply more):
- ≤30s → ~1-2 fps (up to 30 frames)
- 30s-1min → ~40 frames
- 1-3min → ~60 frames
- 3-10min → ~80 frames
- \>10min → 100 frames, sparsely spaced (warning printed)
- If the user hands you a long video, consider asking whether they want a specific section before burning tokens on a sparse scan.
## How to invoke
**Step 1 — parse the user input.** Separate the video source (URL or path) from any question the user asked. Example: `/watch https://youtu.be/abc what language is this in?` → source = `https://youtu.be/abc`, question = `what language is this in?`.
**Step 2 — run the watch script.** Pass the source verbatim. Do not shell-escape it yourself beyond normal quoting:
```bash
python3 "${CLAUDE_SKILL_DIR}/scripts/watch.py" "<source>"
```
Optional flags:
- `--start T` / `--end T` — focus on a section. Accepts `SS`, `MM:SS`, or `HH:MM:SS`. When either is set, fps auto-scales denser (see "Focusing on a section" below).
- `--max-frames N` — lower the cap for tighter token budget (e.g. `--max-frames 40`)
- `--resolution W` — change frame width in px (default 512; bump to 1024 only if the user needs to read on-screen text)
- `--fps F` — override auto-fps (clamped to 2 fps max)
- `--out-dir DIR` — keep working files somewhere specific (default: an auto-generated tmp dir)
- `--whisper groq|openai` — force a specific Whisper backend (default: prefer Groq if both keys exist)
- `--no-whisper` — disable the Whisper fallback entirely (frames-only if no captions)
### Focusing on a section (higher frame rate)
When the user asks about a specific moment — "what happens at the 2 minute mark?", "zoom into 0:45 to 1:00", "the first 10 seconds" — pass `--start` and/or `--end`. The script switches to focused-mode budgets, which are denser than full-video budgets (still capped at 2 fps):
- ≤5s → 2 fps (up to 10 frames)
- 5-15s → 2 fps (up to 30 frames)
- 15-30s → ~2 fps (up to 60 frames)
- 30-60s → ~1.3 fps (up to 80 frames)
- 60-180s → ~0.6 fps (100 frames, capped)
Focused mode is the right call for:
- Any moment/range the user names explicitly ("around 2:30", "the intro", "the last 30 seconds").
- Any video longer than ~10 minutes where the user's question is about a specific part — running focused on the relevant section is far more useful than a sparse scan of the whole thing.
- Re-runs after a full scan didn't have enough detail in some region.
Transcript is auto-filtered to the same range. Frame timestamps are absolute (real video timeline, not offset-from-start).
Examples:
```bash
# Last 10 seconds of a 1 minute video
python3 "${CLAUDE_SKILL_DIR}/scripts/watch.py" video.mp4 --start 50 --end 60
# Zoom into 2:15 → 2:45 at 3 fps (90 frames)
python3 "${CLAUDE_SKILL_DIR}/scripts/watch.py" "$URL" --start 2:15 --end 2:45 --fps 3
# From 1h12m to the end of the video
python3 "${CLAUDE_SKILL_DIR}/scripts/watch.py" "$URL" --start 1:12:00
```
**Step 3 — Read every frame path the script lists.** The Read tool renders JPEGs directly as images for you. Read all frames in a single message (parallel tool calls) so you see them together. The frames are in chronological order with a `t=MM:SS` timestamp so you can align them to the transcript.
**Step 4 — answer the user.** You now have two streams of evidence:
- **Frames** — what's on screen at each timestamp
- **Transcript** — what's said at each timestamp. The report's header shows the source (`captions` = yt-dlp pulled native subs; `whisper (groq)` or `whisper (openai)` = transcribed by API).
If the user asked a specific question, answer it directly citing timestamps. If they didn't ask anything, summarize what happens in the video — structure, key moments, notable visuals, spoken content.
**Step 5 — clean up.** The script prints a working directory at the end. If the user isn't going to ask follow-ups about this video, delete it with `rm -rf <dir>`. If they might, leave it in place.
## Transcription
The script gets a timestamped transcript in one of two ways:
1. **Native captions (free, preferred).** yt-dlp pulls manual or auto-generated subtitles from the source platform if available.
2. **Whisper API fallback.** If no captions came back (or the source is a local file), the script extracts audio (`ffmpeg -vn -ac 1 -ar 16000 -b:a 64k`, ~0.5 MB/min) and uploads it to whichever Whisper API has a key configured:
- **Groq** — `whisper-large-v3`. Preferred default: cheaper, faster. Get a key at console.groq.com/keys.
- **OpenAI** — `whisper-1`. Fallback. Get a key at platform.openai.com/api-keys.
Both keys live in `~/.config/watch/.env`. The script prefers Groq when both are set; override with `--whisper openai` to force OpenAI. Use `--no-whisper` to skip the fallback entirely.
## Failure modes and handling
- **Setup preflight failed** → run `python3 "${CLAUDE_SKILL_DIR}/scripts/setup.py"` (auto-installs ffmpeg/yt-dlp via brew on macOS, scaffolds the `.env`). For API key, ask the user via `AskUserQuestion` and write it to `~/.config/watch/.env`.
- **No transcript available** → captions missing AND (no Whisper key OR Whisper API failed). Script prints a hint pointing to setup. Proceed frames-only and tell the user.
- **Long video warning printed** → acknowledge it in your answer. Offer to re-run focused on a specific section via `--start`/`--end` rather than a sparse full-video scan.
- **Download fails** → yt-dlp's error goes to stderr. If it's a login-required or region-locked video, tell the user plainly; do not keep retrying.
- **Whisper request fails** → the error is printed to stderr (likely: invalid key, rate limit, or 25 MB upload limit on a very long video). The report will say "none available" for transcript. You can retry with `--whisper openai` if Groq failed (or vice versa).
## Token efficiency
This skill burns tokens primarily on frames. Order of magnitude:
- 80 frames at 512px wide is roughly 50-80k image tokens depending on aspect ratio.
- The transcript is cheap (a few thousand tokens at most for a 10-minute video).
- Bumping `--resolution` to 1024 roughly quadruples the image tokens per frame. Only do it when necessary.
If you already watched a video this session and the user asks a follow-up, do **not** re-run the script — you already have the frames and transcript in context. Just answer from what you have.
## Security & Permissions
**What this skill does:**
- Runs `yt-dlp` locally to download the video and pull native captions when the source supports them (public data; the request goes directly to whatever host the URL points at)
- Runs `ffmpeg` / `ffprobe` locally to extract frames as JPEGs and, when Whisper is needed, a mono 16 kHz audio clip
- Sends the extracted audio clip to Groq's Whisper API (`api.groq.com/openai/v1/audio/transcriptions`) when `GROQ_API_KEY` is set (preferred — cheaper, faster)
- Sends the extracted audio clip to OpenAI's audio transcription API (`api.openai.com/v1/audio/transcriptions`) when `OPENAI_API_KEY` is set and Groq is not, or when `--whisper openai` is forced
- Writes the downloaded video, frames, audio, and an intermediate transcript to a working directory under the system temp dir (or `--out-dir` if specified) so Claude can `Read` them
- Reads / creates `~/.config/watch/.env` (mode `0600`) to store the Whisper API key(s) and a `SETUP_COMPLETE` marker. As a fallback, also reads `.env` in the current working directory
**What this skill does NOT do:**
- Does not upload the video itself to any API — only the extracted audio goes out, and only when native captions are missing AND Whisper is not disabled with `--no-whisper`
- Does not access any platform account (no login, no session cookies, no posting)
- Does not share API keys between providers (Groq key only goes to `api.groq.com`, OpenAI key only goes to `api.openai.com`)
- Does not log, cache, or write API keys to stdout, stderr, or output files
- Does not persist anything outside the working directory and `~/.config/watch/.env` — clean up the working directory when you're done (Step 5)
**Bundled scripts:** `scripts/watch.py` (entry point), `scripts/download.py` (yt-dlp wrapper), `scripts/frames.py` (ffmpeg frame extraction), `scripts/transcribe.py` (caption selection + Whisper orchestration), `scripts/whisper.py` (Groq / OpenAI clients), `scripts/setup.py` (preflight + installer)
Review scripts before first use to verify behavior.
+16
View File
@@ -0,0 +1,16 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/check-setup.sh",
"timeout": 5
}
]
}
]
}
}
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# SessionStart hook for /watch — one-line status so users know what's wired up.
# Silent on ready state to avoid spam. Points at the installer when something
# is missing.
set -euo pipefail
CONFIG_FILE="$HOME/.config/watch/.env"
# Warn if the secrets file has loose permissions.
if [[ -f "$CONFIG_FILE" ]]; then
perms=$(stat -c '%a' "$CONFIG_FILE" 2>/dev/null || stat -f '%Lp' "$CONFIG_FILE" 2>/dev/null || echo "")
if [[ -n "$perms" && "$perms" != "600" && "$perms" != "400" ]]; then
echo "/watch: WARNING — $CONFIG_FILE has permissions $perms (should be 600)."
echo " Fix: chmod 600 $CONFIG_FILE"
fi
fi
# Load API keys from the config file without exporting them.
read_key() {
local name="$1"
if [[ -n "${!name:-}" ]]; then
echo "${!name}"
return
fi
if [[ -f "$CONFIG_FILE" ]]; then
awk -F= -v k="$name" '
/^[[:space:]]*#/ { next }
$1 == k {
sub(/^[[:space:]]*/, "", $2); sub(/[[:space:]]*$/, "", $2);
gsub(/^["'\'']|["'\'']$/, "", $2);
print $2; exit
}
' "$CONFIG_FILE"
fi
}
HAS_FFMPEG=""
HAS_YTDLP=""
command -v ffmpeg >/dev/null 2>&1 && HAS_FFMPEG="yes"
command -v yt-dlp >/dev/null 2>&1 && HAS_YTDLP="yes"
HAS_GROQ="$(read_key GROQ_API_KEY)"
HAS_OPENAI="$(read_key OPENAI_API_KEY)"
SETUP_COMPLETE="$(read_key SETUP_COMPLETE)"
# Fully configured → silent (Claude can surface status on demand via --check).
if [[ "$SETUP_COMPLETE" == "true" && -n "$HAS_FFMPEG" && -n "$HAS_YTDLP" ]]; then
exit 0
fi
# First-run / partially-configured → one-line hint.
if [[ -z "$HAS_FFMPEG" || -z "$HAS_YTDLP" ]]; then
echo "/watch: needs ffmpeg + yt-dlp. Run \`python3 \$CLAUDE_PLUGIN_ROOT/scripts/setup.py\` once to install and scaffold config."
elif [[ -z "$HAS_GROQ" && -z "$HAS_OPENAI" ]]; then
echo "/watch: ready for videos with native captions. Add GROQ_API_KEY (preferred) or OPENAI_API_KEY to ~/.config/watch/.env to unlock Whisper fallback."
else
echo "/watch: ready."
fi
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# build-skill.sh — package this repo as a claude.ai-upload-ready .skill file.
# Usage: bash scripts/build-skill.sh (run from repo root)
#
# Produces dist/watch.skill, a zip with a single top-level `watch/` directory
# containing SKILL.md and the scripts/ runtime. claude.ai's skill upload has a
# 200-file cap; `export-ignore` in .gitattributes + the zip -d strips below
# keep the bundle lean.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "error: working tree is dirty; commit or stash before building" >&2
exit 1
fi
mkdir -p dist
OUT="dist/watch.skill"
git archive --format=zip --prefix=watch/ --output="$OUT" HEAD
# claude.ai's .skill bundle needs only SKILL.md + scripts/ runtime. Claude Code
# needs hooks/ and .claude-plugin/ in the git archive (that's why they are NOT
# in .gitattributes export-ignore), but the .skill bundle should strip them to
# keep a single canonical SKILL.md and stay well under the 200-file cap.
zip -d "$OUT" \
"watch/hooks/*" \
"watch/.claude-plugin/*" \
> /dev/null 2>&1 || true
COUNT=$(unzip -l "$OUT" | tail -1 | awk '{print $2}')
SIZE=$(du -h "$OUT" | cut -f1)
if [ "$COUNT" -gt 200 ]; then
echo "error: $COUNT files in zip, claude.ai's cap is 200" >&2
echo " check .gitattributes export-ignore entries and this script's zip -d excludes" >&2
exit 1
fi
SKILL_MD_COUNT=$(unzip -l "$OUT" | grep -c "SKILL.md" || true)
if [ "$SKILL_MD_COUNT" -ne 1 ]; then
echo "error: expected exactly one SKILL.md, found $SKILL_MD_COUNT" >&2
exit 1
fi
echo "built $OUT ($COUNT files, $SIZE)"
echo "upload via the claude.ai skill UI"
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Download a video via yt-dlp, or resolve a local file path.
Also fetches subtitles (manual first, then auto-generated) in VTT format so
transcribe.py can parse them without needing Whisper.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from pathlib import Path
from urllib.parse import urlparse
VIDEO_EXTS = {".mp4", ".mkv", ".webm", ".mov", ".m4v", ".avi", ".flv", ".wmv"}
def is_url(source: str) -> bool:
parsed = urlparse(source)
return parsed.scheme in ("http", "https")
def resolve_local(path: str) -> dict:
p = Path(path).expanduser().resolve()
if not p.exists():
raise SystemExit(f"File not found: {p}")
if p.suffix.lower() not in VIDEO_EXTS:
print(
f"[watch] warning: {p.suffix} is not a known video extension, proceeding anyway",
file=sys.stderr,
)
return {
"video_path": str(p),
"subtitle_path": None,
"info": {"title": p.name, "url": str(p)},
"downloaded": False,
}
def _pick_subtitle(out_dir: Path) -> Path | None:
candidates = sorted(out_dir.glob("video*.vtt"))
if not candidates:
return None
preferred = [c for c in candidates if ".en" in c.name]
return preferred[0] if preferred else candidates[0]
def _pick_video(out_dir: Path) -> Path | None:
for ext in (".mp4", ".mkv", ".webm", ".mov"):
for candidate in out_dir.glob(f"video*{ext}"):
return candidate
for candidate in out_dir.glob("video.*"):
if candidate.suffix.lower() in VIDEO_EXTS:
return candidate
return None
def download_url(url: str, out_dir: Path) -> dict:
if shutil.which("yt-dlp") is None:
raise SystemExit("yt-dlp is not installed. Install with: brew install yt-dlp")
out_dir.mkdir(parents=True, exist_ok=True)
output_template = str(out_dir / "video.%(ext)s")
cmd = [
"yt-dlp",
"-N", "8",
"-f", "bv*[height<=720]+ba/b[height<=720]/bv+ba/b",
"--merge-output-format", "mp4",
"--write-info-json",
"--write-subs",
"--write-auto-subs",
"--sub-langs", "en,en-US,en-GB,en-orig",
"--sub-format", "vtt",
"--convert-subs", "vtt",
"--no-playlist",
"--ignore-errors",
"-o", output_template,
url,
]
# yt-dlp may exit non-zero if a subtitle variant fails (e.g. 429) even when
# the video itself downloaded fine. Treat "video file present" as success.
result = subprocess.run(cmd, stdout=sys.stderr, stderr=sys.stderr)
video = _pick_video(out_dir)
if video is None:
raise SystemExit(
f"yt-dlp did not produce a video file in {out_dir} (exit {result.returncode})"
)
subtitle = _pick_subtitle(out_dir)
info_path = out_dir / "video.info.json"
info: dict = {}
if info_path.exists():
try:
raw = json.loads(info_path.read_text())
info = {
"title": raw.get("title"),
"uploader": raw.get("uploader") or raw.get("channel"),
"duration": raw.get("duration"),
"url": raw.get("webpage_url") or url,
}
except Exception:
info = {"url": url}
return {
"video_path": str(video),
"subtitle_path": str(subtitle) if subtitle else None,
"info": info or {"url": url},
"downloaded": True,
}
def download(source: str, out_dir: Path) -> dict:
if is_url(source):
return download_url(source, out_dir)
return resolve_local(source)
if __name__ == "__main__":
if len(sys.argv) < 3:
print("usage: download.py <url-or-path> <out-dir>", file=sys.stderr)
raise SystemExit(2)
result = download(sys.argv[1], Path(sys.argv[2]))
print(json.dumps(result, indent=2))
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""Probe video metadata and extract frames at an auto-scaled fps.
Auto-fps targets a frame budget, not a fixed rate. Token cost scales with frame
count, so budget-by-duration keeps short videos dense and long videos capped.
When a user-specified range is passed, focused-mode budgets denser (they are
zooming in for detail).
"""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from pathlib import Path
MAX_FPS = 2.0
def _clamp_fps(fps: float, duration_seconds: float, max_frames: int) -> tuple[float, int]:
fps = min(fps, MAX_FPS)
target = min(max_frames, max(1, int(round(fps * duration_seconds))))
return fps, target
def parse_time(value: str | float | int | None) -> float | None:
"""Parse SS, MM:SS, or HH:MM:SS (with optional .ms) into seconds."""
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
s = str(value).strip()
if not s:
return None
parts = s.split(":")
try:
if len(parts) == 1:
return float(parts[0])
if len(parts) == 2:
return int(parts[0]) * 60 + float(parts[1])
if len(parts) == 3:
return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2])
except ValueError:
pass
raise SystemExit(f"Cannot parse time value: {value!r} (expected SS, MM:SS, or HH:MM:SS)")
def format_time(seconds: float) -> str:
total = int(round(seconds))
hours, rem = divmod(total, 3600)
minutes, sec = divmod(rem, 60)
if hours:
return f"{hours}:{minutes:02d}:{sec:02d}"
return f"{minutes:02d}:{sec:02d}"
def get_metadata(video_path: str) -> dict:
if shutil.which("ffprobe") is None:
raise SystemExit("ffprobe is not installed. Install with: brew install ffmpeg")
result = subprocess.run(
[
"ffprobe",
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
video_path,
],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise SystemExit(f"ffprobe failed: {result.stderr.strip()}")
data = json.loads(result.stdout or "{}")
streams = data.get("streams", [])
fmt = data.get("format", {})
video_stream = next((s for s in streams if s.get("codec_type") == "video"), {})
audio_stream = next((s for s in streams if s.get("codec_type") == "audio"), None)
duration = float(fmt.get("duration") or video_stream.get("duration") or 0)
return {
"duration_seconds": duration,
"width": video_stream.get("width"),
"height": video_stream.get("height"),
"codec": video_stream.get("codec_name"),
"size_bytes": int(fmt.get("size") or 0),
"has_audio": audio_stream is not None,
}
def auto_fps(duration_seconds: float, max_frames: int = 100) -> tuple[float, int]:
"""Pick fps that targets a sensible frame budget for full-video scans."""
if duration_seconds <= 0:
return 1.0, 1
if duration_seconds <= 30:
target = min(max_frames, max(12, int(round(duration_seconds))))
elif duration_seconds <= 60:
target = min(max_frames, 40)
elif duration_seconds <= 180: # 3 min
target = min(max_frames, 60)
elif duration_seconds <= 600: # 10 min
target = min(max_frames, 80)
else:
target = max_frames
return _clamp_fps(target / duration_seconds, duration_seconds, max_frames)
def auto_fps_focus(duration_seconds: float, max_frames: int = 100) -> tuple[float, int]:
"""Denser budget for user-specified ranges — they are zooming in for detail."""
if duration_seconds <= 0:
return min(MAX_FPS, 2.0), 2
if duration_seconds <= 5:
target = min(max_frames, max(10, int(round(duration_seconds * 6))))
elif duration_seconds <= 15:
target = min(max_frames, max(30, int(round(duration_seconds * 4))))
elif duration_seconds <= 30:
target = min(max_frames, 60)
elif duration_seconds <= 60:
target = min(max_frames, 80)
elif duration_seconds <= 180:
target = max_frames
else:
target = max_frames
return _clamp_fps(target / duration_seconds, duration_seconds, max_frames)
def extract(
video_path: str,
out_dir: Path,
fps: float,
resolution: int = 512,
max_frames: int = 100,
start_seconds: float | None = None,
end_seconds: float | None = None,
) -> list[dict]:
if shutil.which("ffmpeg") is None:
raise SystemExit("ffmpeg is not installed. Install with: brew install ffmpeg")
out_dir.mkdir(parents=True, exist_ok=True)
for existing in out_dir.glob("frame_*.jpg"):
existing.unlink()
output_pattern = str(out_dir / "frame_%04d.jpg")
cmd: list[str] = [
"ffmpeg",
"-hide_banner",
"-loglevel", "error",
"-y",
]
# -ss before -i = fast seek (keyframe-snap, good enough for preview frames).
if start_seconds is not None:
cmd += ["-ss", f"{start_seconds:.3f}"]
if end_seconds is not None:
cmd += ["-to", f"{end_seconds:.3f}"]
cmd += [
"-i", video_path,
"-vf", f"fps={fps},scale={resolution}:-2",
"-frames:v", str(max_frames),
"-q:v", "4",
output_pattern,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise SystemExit(f"ffmpeg frame extraction failed: {result.stderr.strip()}")
offset = start_seconds or 0.0
frames = sorted(out_dir.glob("frame_*.jpg"))
return [
{
"index": i,
"timestamp_seconds": round(offset + (i / fps if fps > 0 else 0.0), 2),
"path": str(p),
}
for i, p in enumerate(frames)
]
if __name__ == "__main__":
if len(sys.argv) < 3:
print(
"usage: frames.py <video-path> <out-dir> [--fps F] [--resolution W] "
"[--max-frames N] [--start T] [--end T]",
file=sys.stderr,
)
raise SystemExit(2)
video = sys.argv[1]
out = Path(sys.argv[2])
args = sys.argv[3:]
fps_override = None
resolution = 512
max_frames = 100
start_arg = None
end_arg = None
i = 0
while i < len(args):
if args[i] == "--fps":
fps_override = float(args[i + 1]); i += 2
elif args[i] == "--resolution":
resolution = int(args[i + 1]); i += 2
elif args[i] == "--max-frames":
max_frames = int(args[i + 1]); i += 2
elif args[i] == "--start":
start_arg = args[i + 1]; i += 2
elif args[i] == "--end":
end_arg = args[i + 1]; i += 2
else:
i += 1
meta = get_metadata(video)
start_sec = parse_time(start_arg)
end_sec = parse_time(end_arg)
full_duration = meta["duration_seconds"]
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=max_frames)
else:
fps, target = auto_fps(effective_duration, max_frames=max_frames)
if fps_override is not None:
fps = fps_override
target = max(1, int(round(fps * effective_duration)))
frames = extract(
video, out,
fps=fps,
resolution=resolution,
max_frames=max_frames,
start_seconds=start_sec,
end_seconds=end_sec,
)
print(json.dumps(
{"meta": meta, "fps": fps, "target": target, "focused": focused, "frames": frames},
indent=2,
))
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""Setup / preflight for /watch.
Modes:
setup.py --check Silent preflight. Exit 0 if ready, 2/3/4 on failure.
setup.py --json Machine-readable status for Claude to parse.
setup.py Installer. Auto-installs deps, scaffolds .env, marks SETUP_COMPLETE.
Design:
- Silent on success: --check exits 0 with no output when everything's ready so
that /watch doesn't spam "setup is complete" on every turn.
- Idempotent: re-running the installer is safe — it never clobbers existing
keys and only appends missing ones.
- SETUP_COMPLETE=true in ~/.config/watch/.env tells us the user has been
through a successful installer run at least once.
- Never sudo. On macOS, auto-install via brew. Elsewhere, print exact commands.
- Never write an API key to disk automatically — only scaffold placeholders.
"""
from __future__ import annotations
import json
import os
import platform
import shutil
import subprocess
import sys
from pathlib import Path
REQUIRED_BINARIES = ["ffmpeg", "ffprobe", "yt-dlp"]
CONFIG_DIR = Path.home() / ".config" / "watch"
CONFIG_FILE = CONFIG_DIR / ".env"
ENV_TEMPLATE = """# /watch API configuration
#
# Whisper transcription fallback — used only when yt-dlp cannot get captions
# (or when you point /watch at a local file with no subtitles).
#
# Groq is preferred: it runs whisper-large-v3 at a fraction of OpenAI's price
# and is faster in practice. OpenAI is the compatible fallback.
#
# Get a Groq key: https://console.groq.com/keys
# Get an OpenAI key: https://platform.openai.com/api-keys
#
# Leave both blank to disable Whisper — /watch will still work, but videos
# without native captions will come back frames-only.
GROQ_API_KEY=
OPENAI_API_KEY=
"""
def _which(name: str) -> str | None:
return shutil.which(name)
def _check_binaries() -> list[str]:
return [b for b in REQUIRED_BINARIES if not _which(b)]
def _check_file_permissions(path: Path) -> None:
"""Warn to stderr if a secrets file is world/group readable."""
try:
mode = path.stat().st_mode
if mode & 0o044:
sys.stderr.write(
f"[watch] WARNING: {path} is readable by other users. "
f"Run: chmod 600 {path}\n"
)
sys.stderr.flush()
except OSError:
pass
def _read_env_key(name: str) -> str | None:
value = os.environ.get(name)
if value and value.strip():
return value.strip()
if not CONFIG_FILE.exists():
return None
_check_file_permissions(CONFIG_FILE)
try:
for line in CONFIG_FILE.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, raw = line.partition("=")
if key.strip() != name:
continue
raw = raw.strip()
if len(raw) >= 2 and raw[0] in ('"', "'") and raw[-1] == raw[0]:
raw = raw[1:-1]
return raw or None
except OSError:
return None
return None
def _have_api_key() -> tuple[bool, str | None]:
if _read_env_key("GROQ_API_KEY"):
return True, "groq"
if _read_env_key("OPENAI_API_KEY"):
return True, "openai"
return False, None
def is_first_run() -> bool:
"""True if the installer hasn't completed successfully yet."""
return _read_env_key("SETUP_COMPLETE") != "true"
def _scaffold_env() -> bool:
"""Create ~/.config/watch/.env with placeholders if missing."""
if CONFIG_FILE.exists():
return False
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_FILE.write_text(ENV_TEMPLATE)
try:
CONFIG_FILE.chmod(0o600)
except OSError:
pass
return True
def _write_setup_complete() -> None:
"""Idempotently append SETUP_COMPLETE=true to .env.
Used only after a fully successful install (deps + key). Future sessions
detect this marker to skip wizard-style UI and stay silent.
"""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
existing = ""
if CONFIG_FILE.exists():
existing = CONFIG_FILE.read_text()
for line in existing.splitlines():
if line.strip().startswith("SETUP_COMPLETE="):
return
if existing and not existing.endswith("\n"):
existing += "\n"
CONFIG_FILE.write_text(existing + "SETUP_COMPLETE=true\n")
else:
CONFIG_FILE.write_text(ENV_TEMPLATE + "\nSETUP_COMPLETE=true\n")
try:
CONFIG_FILE.chmod(0o600)
except OSError:
pass
def _brew_pkg(missing: list[str]) -> list[str]:
pkgs: list[str] = []
for bin_name in missing:
if bin_name in ("ffmpeg", "ffprobe"):
if "ffmpeg" not in pkgs:
pkgs.append("ffmpeg")
elif bin_name == "yt-dlp":
if "yt-dlp" not in pkgs:
pkgs.append("yt-dlp")
else:
pkgs.append(bin_name)
return pkgs
def _install_macos(missing: list[str]) -> tuple[bool, str]:
if _which("brew") is None:
return False, (
"Homebrew is not installed. Install it from https://brew.sh, then re-run setup. "
"Or install manually: `brew install " + " ".join(_brew_pkg(missing)) + "`"
)
pkgs = _brew_pkg(missing)
if not pkgs:
return True, "nothing to install"
cmd = ["brew", "install", *pkgs]
print(f"[setup] running: {' '.join(cmd)}", file=sys.stderr)
result = subprocess.run(cmd)
if result.returncode != 0:
return False, f"brew install failed with exit code {result.returncode}"
return True, f"installed via brew: {', '.join(pkgs)}"
def _install_hint_linux(missing: list[str]) -> str:
pkgs = _brew_pkg(missing)
hints = []
if "ffmpeg" in pkgs:
hints.append("apt: `sudo apt install ffmpeg` or dnf: `sudo dnf install ffmpeg`")
if "yt-dlp" in pkgs:
hints.append("`pipx install yt-dlp` (recommended) or `pip install --user yt-dlp`")
return "\n ".join(hints) if hints else "nothing to install"
def _status() -> dict:
"""Structured preflight snapshot."""
missing = _check_binaries()
has_key, backend = _have_api_key()
if not missing and has_key:
status = "ready"
elif missing and not has_key:
status = "needs_install_and_key"
elif missing:
status = "needs_install"
else:
status = "needs_key"
return {
"status": status,
"first_run": is_first_run(),
"missing_binaries": missing,
"whisper_backend": backend,
"has_api_key": has_key,
"config_file": str(CONFIG_FILE),
"platform": platform.system(),
}
def cmd_check() -> int:
"""Silent-on-success preflight.
Exit 0 with no output when ready. On failure, print one actionable line
to stderr and return:
2 → binaries missing
3 → API key missing
4 → both missing
"""
s = _status()
if s["status"] == "ready":
return 0
parts = []
if s["missing_binaries"]:
parts.append(f"missing binaries: {', '.join(s['missing_binaries'])}")
if not s["has_api_key"]:
parts.append("no Whisper API key (GROQ_API_KEY or OPENAI_API_KEY)")
installer = Path(__file__).resolve()
sys.stderr.write(
f"[watch] setup incomplete ({'; '.join(parts)}). "
f"Run: python3 {installer}\n"
)
sys.stderr.flush()
if s["missing_binaries"] and not s["has_api_key"]:
return 4
if s["missing_binaries"]:
return 2
return 3
def cmd_json() -> int:
json.dump(_status(), sys.stdout, indent=2)
sys.stdout.write("\n")
return 0
def cmd_install() -> int:
missing = _check_binaries()
installed_deps = False
if missing:
system = platform.system()
if system == "Darwin":
ok, msg = _install_macos(missing)
print(f"[setup] {msg}", file=sys.stderr)
if not ok:
return 2
still_missing = _check_binaries()
if still_missing:
print(f"[setup] still missing after install: {', '.join(still_missing)}", file=sys.stderr)
return 2
installed_deps = True
elif system == "Linux":
print("[setup] dependencies missing on Linux — please install:", file=sys.stderr)
print(" " + _install_hint_linux(missing), file=sys.stderr)
return 2
else:
print(f"[setup] unsupported platform ({system}) for auto-install. Install manually:", file=sys.stderr)
print(f" missing: {', '.join(missing)}", file=sys.stderr)
return 2
created = _scaffold_env()
if created:
print(f"[setup] created config: {CONFIG_FILE}")
else:
print(f"[setup] config exists: {CONFIG_FILE}")
has_key, backend = _have_api_key()
if has_key:
_write_setup_complete()
print(f"[setup] ready. whisper backend: {backend}")
if installed_deps:
print("[setup] installed dependencies; /watch is fully set up.")
return 0
print("")
print("[setup] one step left: add a Whisper API key.")
print("")
print(f" Edit {CONFIG_FILE} and set either:")
print(" GROQ_API_KEY=... (preferred — cheaper, faster; get one at console.groq.com/keys)")
print(" OPENAI_API_KEY=... (fallback; get one at platform.openai.com/api-keys)")
print("")
print(" Without a key, /watch still works but videos without captions come back frames-only.")
return 3
def main() -> int:
if len(sys.argv) > 1:
arg = sys.argv[1]
if arg == "--check":
return cmd_check()
if arg == "--json":
return cmd_json()
return cmd_install()
if __name__ == "__main__":
raise SystemExit(main())
+96
View File
@@ -0,0 +1,96 @@
#!/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])))
+230
View File
@@ -0,0 +1,230 @@
#!/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 download import download, is_url # noqa: E402
from frames import MAX_FPS, auto_fps, auto_fps_focus, extract, format_time, get_metadata, parse_time # 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=80, help="Cap on frame count (default 80, hard max 100)")
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("--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()
max_frames = min(args.max_frames, 100)
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)
print(
"[watch] downloading via yt-dlp…" if is_url(args.source) else "[watch] using local file…",
file=sys.stderr,
)
dl = download(args.source, work / "download")
video_path = dl["video_path"]
meta = get_metadata(video_path)
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=max_frames)
else:
fps, target = auto_fps(effective_duration, max_frames=max_frames)
if args.fps is not None:
fps = min(args.fps, MAX_FPS)
target = max(1, int(round(fps * effective_duration)))
scope = (
f"{format_time(effective_start)}-{format_time(effective_end)} ({effective_duration:.1f}s)"
if focused else f"full {effective_duration:.1f}s"
)
print(f"[watch] extracting ~{target} frames at {fps:.3f} fps over {scope}", file=sys.stderr)
frames = extract(
video_path,
work / "frames",
fps=fps,
resolution=args.resolution,
max_frames=max_frames,
start_seconds=start_sec,
end_seconds=end_sec,
)
transcript_segments: list[dict] = []
transcript_text: str | None = None
transcript_source: str | None = None
if 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:
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,
)
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'})")
mode = "focused" if focused else "full"
print(f"- **Frames:** {len(frames)} @ {fps:.3f} fps, {mode} mode (budget {target}, max {max_frames})")
print(f"- **Frame size:** {args.resolution}px wide")
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 not focused and full_duration > 600:
mins = int(full_duration // 60)
print()
print(
f"> ⚠️ 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()
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']}` (t={format_time(frame['timestamp_seconds'])})")
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 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())
+319
View File
@@ -0,0 +1,319 @@
#!/usr/bin/env python3
"""Transcribe a video via Groq or OpenAI Whisper API.
Strategy: extract audio (mono 16kHz mp3, tiny payload), upload to whichever
API has a key. Returns segments in the same shape as transcribe.parse_vtt so
the rest of the pipeline (filter_range, format_transcript) doesn't care where
the transcript came from.
Pure stdlib — no `pip install groq` or `pip install openai` needed.
"""
from __future__ import annotations
import io
import json
import mimetypes
import os
import shutil
import ssl
import subprocess
import sys
import time
import urllib.error
import uuid
from pathlib import Path
from urllib.request import Request, urlopen
GROQ_ENDPOINT = "https://api.groq.com/openai/v1/audio/transcriptions"
GROQ_MODEL = "whisper-large-v3"
OPENAI_ENDPOINT = "https://api.openai.com/v1/audio/transcriptions"
OPENAI_MODEL = "whisper-1"
def load_api_key(preferred: str | None = None) -> tuple[str, str] | tuple[None, None]:
"""Return (backend, api_key). Prefers Groq, falls back to OpenAI.
If `preferred` is "groq" or "openai", only that backend's key is considered.
"""
def _from_env(name: str) -> str | None:
value = os.environ.get(name)
return value.strip() if value else None
def _from_dotenv(path: Path, name: str) -> str | None:
if not path.exists():
return None
try:
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
if key.strip() != name:
continue
value = value.strip()
if len(value) >= 2 and value[0] in ('"', "'") and value[-1] == value[0]:
value = value[1:-1]
return value or None
except OSError:
return None
return None
dotenv_paths = [
Path.home() / ".config" / "watch" / ".env",
Path.cwd() / ".env",
]
candidates = (("GROQ_API_KEY", "groq"), ("OPENAI_API_KEY", "openai"))
if preferred is not None:
candidates = tuple(c for c in candidates if c[1] == preferred)
for key_name, backend in candidates:
value = _from_env(key_name)
if not value:
for candidate in dotenv_paths:
value = _from_dotenv(candidate, key_name)
if value:
break
if value:
return backend, value
return None, None
def extract_audio(video_path: str, out_path: Path) -> Path:
"""Extract mono 16kHz 64kbps mp3 — ~480 kB/min, fits any Whisper limit."""
if shutil.which("ffmpeg") is None:
raise SystemExit("ffmpeg is not installed. Install with: brew install ffmpeg")
out_path.parent.mkdir(parents=True, exist_ok=True)
cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel", "error",
"-y",
"-i", video_path,
"-vn",
"-acodec", "libmp3lame",
"-ar", "16000",
"-ac", "1",
"-b:a", "64k",
str(out_path),
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise SystemExit(f"ffmpeg audio extraction failed: {result.stderr.strip()}")
if not out_path.exists() or out_path.stat().st_size == 0:
raise SystemExit("ffmpeg produced no audio — video may have no audio track")
return out_path
def _build_multipart(fields: dict[str, str], file_path: Path) -> tuple[bytes, str]:
"""Assemble a multipart/form-data body the Whisper APIs accept.
Whisper's multipart upload is small and predictable — doing it by hand
keeps us on pure stdlib instead of pulling requests/groq/openai SDKs.
"""
boundary = f"----WatchBoundary{uuid.uuid4().hex}"
eol = b"\r\n"
buf = io.BytesIO()
for name, value in fields.items():
buf.write(f"--{boundary}".encode()); buf.write(eol)
buf.write(f'Content-Disposition: form-data; name="{name}"'.encode()); buf.write(eol)
buf.write(eol)
buf.write(str(value).encode()); buf.write(eol)
mimetype = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
buf.write(f"--{boundary}".encode()); buf.write(eol)
buf.write(
f'Content-Disposition: form-data; name="file"; filename="{file_path.name}"'.encode()
)
buf.write(eol)
buf.write(f"Content-Type: {mimetype}".encode()); buf.write(eol)
buf.write(eol)
buf.write(file_path.read_bytes())
buf.write(eol)
buf.write(f"--{boundary}--".encode()); buf.write(eol)
return buf.getvalue(), boundary
MAX_ATTEMPTS = 4 # initial + 3 retries
MAX_429_RETRIES = 2
RETRY_BASE_DELAY = 2.0
def _post_whisper(endpoint: str, api_key: str, model: str, audio_path: Path) -> dict:
fields = {
"model": model,
"response_format": "verbose_json",
"temperature": "0",
}
body, boundary = _build_multipart(fields, audio_path)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": f"multipart/form-data; boundary={boundary}",
# Groq sits behind Cloudflare — the default `Python-urllib/3.x` UA
# trips WAF rule 1010 (403) before auth even runs. Any non-default
# UA clears it; we identify honestly.
"User-Agent": "watch-skill/1.0 (+claude-code; python-urllib)",
}
context = ssl.create_default_context()
rate_limit_hits = 0
last_exc: Exception | None = None
last_detail = ""
for attempt in range(MAX_ATTEMPTS):
request = Request(endpoint, data=body, headers=headers, method="POST")
try:
with urlopen(request, timeout=300, context=context) as response:
payload = response.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
detail = _read_error_body(exc)
last_exc, last_detail = exc, detail
# 4xx other than 429 are client errors — no retry will fix them.
if 400 <= exc.code < 500 and exc.code != 429:
raise SystemExit(f"Whisper request failed: {exc}{detail}")
if exc.code == 429:
rate_limit_hits += 1
if rate_limit_hits >= MAX_429_RETRIES:
raise SystemExit(f"Whisper request failed: {exc}{detail}")
delay = _retry_after(exc) or RETRY_BASE_DELAY * (2 ** attempt) + 1
else:
delay = RETRY_BASE_DELAY * (2 ** attempt)
if attempt < MAX_ATTEMPTS - 1:
print(
f"[watch] whisper HTTP {exc.code} — retrying in {delay:.1f}s "
f"(attempt {attempt + 2}/{MAX_ATTEMPTS})",
file=sys.stderr,
)
time.sleep(delay)
continue
except (urllib.error.URLError, TimeoutError, ConnectionResetError, OSError) as exc:
last_exc, last_detail = exc, ""
if attempt < MAX_ATTEMPTS - 1:
delay = RETRY_BASE_DELAY * (attempt + 1)
print(
f"[watch] whisper network error ({type(exc).__name__}: {exc}) — "
f"retrying in {delay:.1f}s (attempt {attempt + 2}/{MAX_ATTEMPTS})",
file=sys.stderr,
)
time.sleep(delay)
continue
try:
return json.loads(payload)
except json.JSONDecodeError as exc:
raise SystemExit(f"Whisper returned non-JSON response: {exc}: {payload[:200]}")
raise SystemExit(
f"Whisper request failed after {MAX_ATTEMPTS} attempts: {last_exc}{last_detail}"
)
def _read_error_body(exc: urllib.error.HTTPError) -> str:
try:
body = exc.read()
except Exception:
return ""
if not body:
return ""
try:
return f"{body.decode('utf-8', errors='replace')[:400]}"
except Exception:
return ""
def _retry_after(exc: urllib.error.HTTPError) -> float | None:
header = exc.headers.get("Retry-After") if getattr(exc, "headers", None) else None
if not header:
return None
try:
return float(header)
except ValueError:
return None
def _segments_from_response(data: dict) -> list[dict]:
"""Convert Whisper verbose_json into our {start, end, text} segment format."""
out: list[dict] = []
for seg in data.get("segments") or []:
text = (seg.get("text") or "").strip()
if not text:
continue
out.append({
"start": round(float(seg.get("start") or 0.0), 2),
"end": round(float(seg.get("end") or 0.0), 2),
"text": text,
})
if not out:
full = (data.get("text") or "").strip()
if full:
out.append({"start": 0.0, "end": 0.0, "text": full})
return out
def transcribe_video(
video_path: str,
audio_out: Path,
backend: str | None = None,
api_key: str | None = None,
) -> tuple[list[dict], str]:
"""Run the full flow: extract audio → upload → parse segments.
Returns (segments, backend_used). Raises SystemExit on any failure.
"""
if backend is None or api_key is None:
detected_backend, detected_key = load_api_key()
backend = backend or detected_backend
api_key = api_key or detected_key
if not backend or not api_key:
setup_py = Path(__file__).resolve().parent / "setup.py"
raise SystemExit(
"No Whisper API key available. Set GROQ_API_KEY (preferred) or OPENAI_API_KEY "
"in the environment or in ~/.config/watch/.env. "
f"Run `python3 {setup_py}` to configure."
)
print(f"[watch] extracting audio for Whisper ({backend})…", file=sys.stderr)
audio_path = extract_audio(video_path, audio_out)
size_kb = audio_path.stat().st_size / 1024
print(f"[watch] audio: {size_kb:.0f} kB — uploading to {backend} Whisper…", file=sys.stderr)
if backend == "groq":
response = _post_whisper(GROQ_ENDPOINT, api_key, GROQ_MODEL, audio_path)
elif backend == "openai":
response = _post_whisper(OPENAI_ENDPOINT, api_key, OPENAI_MODEL, audio_path)
else:
raise SystemExit(f"Unknown whisper backend: {backend}")
segments = _segments_from_response(response)
if not segments:
raise SystemExit("Whisper returned no transcript segments")
print(f"[watch] transcribed {len(segments)} segments via {backend}", file=sys.stderr)
return segments, backend
if __name__ == "__main__":
if len(sys.argv) < 2:
print("usage: whisper.py <video-path> [<audio-out.mp3>] [--backend groq|openai]", file=sys.stderr)
raise SystemExit(2)
video = sys.argv[1]
audio_out = Path(sys.argv[2]) if len(sys.argv) > 2 and not sys.argv[2].startswith("--") else Path("audio.mp3")
backend_override = None
if "--backend" in sys.argv:
backend_override = sys.argv[sys.argv.index("--backend") + 1]
segments, backend = transcribe_video(video, audio_out, backend=backend_override)
print(json.dumps({"backend": backend, "segments": segments}, indent=2))