watch-video-mcp 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- watch_video_mcp-0.2.0/LICENSE +21 -0
- watch_video_mcp-0.2.0/PKG-INFO +200 -0
- watch_video_mcp-0.2.0/README.md +171 -0
- watch_video_mcp-0.2.0/pyproject.toml +51 -0
- watch_video_mcp-0.2.0/setup.cfg +4 -0
- watch_video_mcp-0.2.0/tests/test_frames.py +66 -0
- watch_video_mcp-0.2.0/tests/test_ocr.py +24 -0
- watch_video_mcp-0.2.0/tests/test_text.py +60 -0
- watch_video_mcp-0.2.0/video_mcp/__init__.py +14 -0
- watch_video_mcp-0.2.0/video_mcp/cli.py +70 -0
- watch_video_mcp-0.2.0/video_mcp/core.py +129 -0
- watch_video_mcp-0.2.0/video_mcp/frames.py +216 -0
- watch_video_mcp-0.2.0/video_mcp/hw.py +134 -0
- watch_video_mcp-0.2.0/video_mcp/ocr.py +72 -0
- watch_video_mcp-0.2.0/video_mcp/server.py +76 -0
- watch_video_mcp-0.2.0/video_mcp/source.py +161 -0
- watch_video_mcp-0.2.0/video_mcp/transcribe.py +126 -0
- watch_video_mcp-0.2.0/watch_video_mcp.egg-info/PKG-INFO +200 -0
- watch_video_mcp-0.2.0/watch_video_mcp.egg-info/SOURCES.txt +21 -0
- watch_video_mcp-0.2.0/watch_video_mcp.egg-info/dependency_links.txt +1 -0
- watch_video_mcp-0.2.0/watch_video_mcp.egg-info/entry_points.txt +3 -0
- watch_video_mcp-0.2.0/watch_video_mcp.egg-info/requires.txt +15 -0
- watch_video_mcp-0.2.0/watch_video_mcp.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marwan Mahmoud
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: watch-video-mcp
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: MCP server and Claude skill that let an AI watch videos: timestamped transcript plus scene-selected keyframes, from local files or any yt-dlp URL.
|
|
5
|
+
Author: Marwan Mahmoud
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/marwanm7moud/watch-video
|
|
8
|
+
Project-URL: Issues, https://github.com/marwanm7moud/watch-video/issues
|
|
9
|
+
Keywords: mcp,claude,video,transcription,keyframes,yt-dlp,parakeet,whisper
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Topic :: Multimedia :: Video
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: mcp>=1.2
|
|
17
|
+
Requires-Dist: yt-dlp
|
|
18
|
+
Requires-Dist: numpy
|
|
19
|
+
Requires-Dist: pillow
|
|
20
|
+
Requires-Dist: onnx-asr[cpu,hub]>=0.10
|
|
21
|
+
Provides-Extra: whisper
|
|
22
|
+
Requires-Dist: faster-whisper>=1.0; extra == "whisper"
|
|
23
|
+
Provides-Extra: ocr
|
|
24
|
+
Requires-Dist: rapidocr-onnxruntime>=1.3; extra == "ocr"
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# watch-video
|
|
31
|
+
|
|
32
|
+
<!-- mcp-name: io.github.marwanm7moud/watch-video -->
|
|
33
|
+
|
|
34
|
+
Let Claude (or any MCP client) **watch a video**. Give it a local file or a URL (YouTube and 1,500+
|
|
35
|
+
other sites via yt-dlp) and it gets a timestamped transcript plus a handful of scene-selected
|
|
36
|
+
keyframes, then drills into exact frames only when needed. No cloud transcription, no API keys.
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
> Watch https://www.youtube.com/watch?v=jNQXAC9IVRw and tell me what he says about the elephants.
|
|
40
|
+
|
|
41
|
+
$ video-cli overview https://www.youtube.com/watch?v=jNQXAC9IVRw --max-frames 8
|
|
42
|
+
# Me at the zoo
|
|
43
|
+
duration: 00:19
|
|
44
|
+
transcript: platform-subs:en, language=en, 6 segments, took 0.4s
|
|
45
|
+
keyframes: 3 scene-selected at 00:00, 00:05, 00:11
|
|
46
|
+
total time: 6.7s
|
|
47
|
+
|
|
48
|
+
## Transcript
|
|
49
|
+
[00:01] All right, so here we are, in front of the elephants
|
|
50
|
+
[00:05] the cool thing about these guys is that they have really... really really long trunks and that's cool
|
|
51
|
+
[00:16] and that's pretty much all there is to say
|
|
52
|
+
IMAGE: ~/.cache/video_mcp/Youtube_jNQXAC9IVRw/sheets_8/sheet_01.jpg
|
|
53
|
+
|
|
54
|
+
Claude: He stands in front of the elephants (00:01) and says the cool thing about them is
|
|
55
|
+
their really long trunks (00:05). That is the whole video (00:16).
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Why this one
|
|
59
|
+
|
|
60
|
+
| | watch-video | Cloud video APIs (e.g. Gemini) | Transcript-only tools |
|
|
61
|
+
|---|---|---|---|
|
|
62
|
+
| Sees the screen (code, slides, charts, UI) | Yes: keyframes, hi-res frames, OCR | Yes | No |
|
|
63
|
+
| Runs locally, nothing uploaded | Yes | No: the video goes to the provider | Varies |
|
|
64
|
+
| API key or account | None | Required, billed per use | Varies |
|
|
65
|
+
| Videos without captions | Local Parakeet / Whisper | Yes | Often fails |
|
|
66
|
+
| Token cost of the visual pass | ~20 frames per contact sheet (cost of ~1.5 images) | Billed by video length | None (no visuals) |
|
|
67
|
+
| Private recordings (meetings, bug repros) | Safe: never leave the machine | Uploaded | Varies |
|
|
68
|
+
|
|
69
|
+
It ships as two parts:
|
|
70
|
+
|
|
71
|
+
- **`video` MCP server** with six tools (below).
|
|
72
|
+
- **`watch-video` skill** that teaches Claude the cheap-first workflow: overview, then frames, then ranges.
|
|
73
|
+
|
|
74
|
+
## How it works
|
|
75
|
+
|
|
76
|
+
| Step | What happens |
|
|
77
|
+
|---|---|
|
|
78
|
+
| Transcript | Platform subtitles if the video has them (seconds), else **Parakeet TDT 0.6B v3** (int8, CPU, 25 European languages), else **faster-whisper large-v3-turbo** (other languages, GPU or `language="auto"`). |
|
|
79
|
+
| Keyframes | Decodes *only* keyframes at thumbnail size, drops repeats and static slides, then fills the largest time gaps. The selection adapts to how much the video changes (screencast vs. action). |
|
|
80
|
+
| Contact sheets | Labelled frames tiled into grids: about 20 frames for the token cost of 1.5 full frames. Black bars are cropped automatically. |
|
|
81
|
+
| Drill-down | `get_frames` seeks to exact timestamps at 1280 px (readable code and charts). For URLs it seeks into the stream without downloading the whole video. |
|
|
82
|
+
|
|
83
|
+
Everything is cached under `~/.cache/video_mcp`, so repeat calls are instant.
|
|
84
|
+
|
|
85
|
+
Measured on a mid-range CPU (i5-11400, no GPU): 35.7-minute local video overview in about 2.6 minutes;
|
|
86
|
+
YouTube video with subtitles in about 7 seconds; a drill-down frame in about 0.5 seconds.
|
|
87
|
+
|
|
88
|
+
## Install
|
|
89
|
+
|
|
90
|
+
Requirements: Python 3.10+ and [ffmpeg](https://ffmpeg.org/download.html) (including `ffprobe`) on your PATH.
|
|
91
|
+
|
|
92
|
+
### Claude Code: one command (plugin)
|
|
93
|
+
|
|
94
|
+
Installs the MCP server and the skill together. Needs [uv](https://docs.astral.sh/uv/) for `uvx`.
|
|
95
|
+
|
|
96
|
+
```text
|
|
97
|
+
/plugin marketplace add marwanm7moud/watch-video
|
|
98
|
+
/plugin install watch-video@watch-video
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Any agent: package, server, skill
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pipx install watch-video-mcp # or: uv tool install watch-video-mcp / pip install watch-video-mcp
|
|
105
|
+
# optional extras: Whisper (non-European languages / GPU) and OCR (on-screen text as text)
|
|
106
|
+
pipx install "watch-video-mcp[whisper,ocr]"
|
|
107
|
+
# latest from GitHub instead of PyPI:
|
|
108
|
+
pip install git+https://github.com/marwanm7moud/watch-video
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
No install needed to try the CLI: `uvx --from watch-video-mcp video-cli hw`.
|
|
112
|
+
|
|
113
|
+
Install ffmpeg with `winget install Gyan.FFmpeg` (Windows), `brew install ffmpeg` (macOS) or
|
|
114
|
+
`sudo apt install ffmpeg` (Linux). The first transcription downloads the Parakeet model (about 650 MB).
|
|
115
|
+
|
|
116
|
+
### Register the MCP server
|
|
117
|
+
|
|
118
|
+
Claude Code:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
claude mcp add video --scope user -- video-mcp
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Claude Desktop (`claude_desktop_config.json`) or any other MCP client:
|
|
125
|
+
|
|
126
|
+
```json
|
|
127
|
+
{ "mcpServers": { "video": { "command": "video-mcp" } } }
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
If `video-mcp` is not on your PATH, use the full path to the executable or `python -m video_mcp.server`.
|
|
131
|
+
|
|
132
|
+
### Install the skill
|
|
133
|
+
|
|
134
|
+
The skill works with or without the MCP server: it falls back to `video-cli` and installs it on first use.
|
|
135
|
+
Copy the skill folder so your agent knows when and how to use the tools:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
# macOS / Linux
|
|
139
|
+
mkdir -p ~/.claude/skills && cp -r skills/watch-video ~/.claude/skills/
|
|
140
|
+
# Windows (PowerShell)
|
|
141
|
+
Copy-Item -Recurse skills\watch-video $HOME\.claude\skills\
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Then ask Claude: *"Watch https://youtu.be/... and summarize it"*, or *"At what point do they explain the stop-loss, and what does the chart show then?"*
|
|
145
|
+
|
|
146
|
+
## Tools
|
|
147
|
+
|
|
148
|
+
| Tool | Use |
|
|
149
|
+
|---|---|
|
|
150
|
+
| `video_overview(source, max_frames=40, ocr=False)` | Start here: metadata, chapters, full transcript, contact sheets. `ocr=True` adds on-screen text. |
|
|
151
|
+
| `get_frames(source, timestamps, hi_res=True)` | Exact frames (`"12:30"`, `"1:02:05"` or seconds), up to 20. |
|
|
152
|
+
| `scan_range(source, start, end, count=12)` | Evenly spaced frames in one sheet, to follow motion or a demo. |
|
|
153
|
+
| `get_transcript(source, start, end)` | Transcript slice. |
|
|
154
|
+
| `search_transcript(source, query)` | Timestamped hits for any of the words, exact phrase first; plus on-screen text after an `ocr=True` overview. |
|
|
155
|
+
| `check_hardware()` | Detected tools, GPU and the transcription profile in use. |
|
|
156
|
+
|
|
157
|
+
## Command line
|
|
158
|
+
|
|
159
|
+
The same operations work without MCP:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
video-cli overview video.mp4 --max-frames 16
|
|
163
|
+
video-cli overview demo.mp4 --ocr # also read code/slides on screen
|
|
164
|
+
video-cli frames https://youtu.be/... 12:30 13:05
|
|
165
|
+
video-cli range video.mp4 10:00 10:30 --count 12
|
|
166
|
+
video-cli search video.mp4 "stop loss"
|
|
167
|
+
video-cli hw
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Configuration
|
|
171
|
+
|
|
172
|
+
| Variable | Default | Effect |
|
|
173
|
+
|---|---|---|
|
|
174
|
+
| `VIDEO_MCP_CACHE` | `~/.cache/video_mcp` | Cache directory (delete it to reclaim space). |
|
|
175
|
+
| `VIDEO_MCP_LANG` | `en` | Default transcription language; `auto` detects it with Whisper. |
|
|
176
|
+
| `VIDEO_MCP_THREADS` | `4` (CPU) | Threads for CPU transcription. |
|
|
177
|
+
| `VIDEO_MCP_AVX512` | unset | Set to `1` to let Whisper use AVX-512 (faster, but a very heavy sustained load). |
|
|
178
|
+
|
|
179
|
+
On CPU the server runs below normal priority, one heavy job at a time, with Whisper held to AVX2, so a
|
|
180
|
+
long transcription does not freeze your desktop.
|
|
181
|
+
|
|
182
|
+
## Limitations
|
|
183
|
+
|
|
184
|
+
- English is assumed unless you pass `language`. Parakeet covers 25 European languages on CPU (`language="de"`,
|
|
185
|
+
`"fr"`, `"es"`, ...). Other languages (Arabic, Chinese, Japanese, Hindi, ...) need Whisper, which is slow on CPU.
|
|
186
|
+
- Frames are sampled, not every frame is analysed; the transcript can mishear names and terms. The skill tells
|
|
187
|
+
Claude to cite timestamps and confirm exact on-screen text with a hi-res frame.
|
|
188
|
+
- Use it only on videos you are allowed to access and process; respect each site's terms and copyright.
|
|
189
|
+
|
|
190
|
+
## Development
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
pip install -e ".[dev]"
|
|
194
|
+
ruff check .
|
|
195
|
+
pytest
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# watch-video
|
|
2
|
+
|
|
3
|
+
<!-- mcp-name: io.github.marwanm7moud/watch-video -->
|
|
4
|
+
|
|
5
|
+
Let Claude (or any MCP client) **watch a video**. Give it a local file or a URL (YouTube and 1,500+
|
|
6
|
+
other sites via yt-dlp) and it gets a timestamped transcript plus a handful of scene-selected
|
|
7
|
+
keyframes, then drills into exact frames only when needed. No cloud transcription, no API keys.
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
> Watch https://www.youtube.com/watch?v=jNQXAC9IVRw and tell me what he says about the elephants.
|
|
11
|
+
|
|
12
|
+
$ video-cli overview https://www.youtube.com/watch?v=jNQXAC9IVRw --max-frames 8
|
|
13
|
+
# Me at the zoo
|
|
14
|
+
duration: 00:19
|
|
15
|
+
transcript: platform-subs:en, language=en, 6 segments, took 0.4s
|
|
16
|
+
keyframes: 3 scene-selected at 00:00, 00:05, 00:11
|
|
17
|
+
total time: 6.7s
|
|
18
|
+
|
|
19
|
+
## Transcript
|
|
20
|
+
[00:01] All right, so here we are, in front of the elephants
|
|
21
|
+
[00:05] the cool thing about these guys is that they have really... really really long trunks and that's cool
|
|
22
|
+
[00:16] and that's pretty much all there is to say
|
|
23
|
+
IMAGE: ~/.cache/video_mcp/Youtube_jNQXAC9IVRw/sheets_8/sheet_01.jpg
|
|
24
|
+
|
|
25
|
+
Claude: He stands in front of the elephants (00:01) and says the cool thing about them is
|
|
26
|
+
their really long trunks (00:05). That is the whole video (00:16).
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Why this one
|
|
30
|
+
|
|
31
|
+
| | watch-video | Cloud video APIs (e.g. Gemini) | Transcript-only tools |
|
|
32
|
+
|---|---|---|---|
|
|
33
|
+
| Sees the screen (code, slides, charts, UI) | Yes: keyframes, hi-res frames, OCR | Yes | No |
|
|
34
|
+
| Runs locally, nothing uploaded | Yes | No: the video goes to the provider | Varies |
|
|
35
|
+
| API key or account | None | Required, billed per use | Varies |
|
|
36
|
+
| Videos without captions | Local Parakeet / Whisper | Yes | Often fails |
|
|
37
|
+
| Token cost of the visual pass | ~20 frames per contact sheet (cost of ~1.5 images) | Billed by video length | None (no visuals) |
|
|
38
|
+
| Private recordings (meetings, bug repros) | Safe: never leave the machine | Uploaded | Varies |
|
|
39
|
+
|
|
40
|
+
It ships as two parts:
|
|
41
|
+
|
|
42
|
+
- **`video` MCP server** with six tools (below).
|
|
43
|
+
- **`watch-video` skill** that teaches Claude the cheap-first workflow: overview, then frames, then ranges.
|
|
44
|
+
|
|
45
|
+
## How it works
|
|
46
|
+
|
|
47
|
+
| Step | What happens |
|
|
48
|
+
|---|---|
|
|
49
|
+
| Transcript | Platform subtitles if the video has them (seconds), else **Parakeet TDT 0.6B v3** (int8, CPU, 25 European languages), else **faster-whisper large-v3-turbo** (other languages, GPU or `language="auto"`). |
|
|
50
|
+
| Keyframes | Decodes *only* keyframes at thumbnail size, drops repeats and static slides, then fills the largest time gaps. The selection adapts to how much the video changes (screencast vs. action). |
|
|
51
|
+
| Contact sheets | Labelled frames tiled into grids: about 20 frames for the token cost of 1.5 full frames. Black bars are cropped automatically. |
|
|
52
|
+
| Drill-down | `get_frames` seeks to exact timestamps at 1280 px (readable code and charts). For URLs it seeks into the stream without downloading the whole video. |
|
|
53
|
+
|
|
54
|
+
Everything is cached under `~/.cache/video_mcp`, so repeat calls are instant.
|
|
55
|
+
|
|
56
|
+
Measured on a mid-range CPU (i5-11400, no GPU): 35.7-minute local video overview in about 2.6 minutes;
|
|
57
|
+
YouTube video with subtitles in about 7 seconds; a drill-down frame in about 0.5 seconds.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
Requirements: Python 3.10+ and [ffmpeg](https://ffmpeg.org/download.html) (including `ffprobe`) on your PATH.
|
|
62
|
+
|
|
63
|
+
### Claude Code: one command (plugin)
|
|
64
|
+
|
|
65
|
+
Installs the MCP server and the skill together. Needs [uv](https://docs.astral.sh/uv/) for `uvx`.
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
/plugin marketplace add marwanm7moud/watch-video
|
|
69
|
+
/plugin install watch-video@watch-video
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Any agent: package, server, skill
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pipx install watch-video-mcp # or: uv tool install watch-video-mcp / pip install watch-video-mcp
|
|
76
|
+
# optional extras: Whisper (non-European languages / GPU) and OCR (on-screen text as text)
|
|
77
|
+
pipx install "watch-video-mcp[whisper,ocr]"
|
|
78
|
+
# latest from GitHub instead of PyPI:
|
|
79
|
+
pip install git+https://github.com/marwanm7moud/watch-video
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
No install needed to try the CLI: `uvx --from watch-video-mcp video-cli hw`.
|
|
83
|
+
|
|
84
|
+
Install ffmpeg with `winget install Gyan.FFmpeg` (Windows), `brew install ffmpeg` (macOS) or
|
|
85
|
+
`sudo apt install ffmpeg` (Linux). The first transcription downloads the Parakeet model (about 650 MB).
|
|
86
|
+
|
|
87
|
+
### Register the MCP server
|
|
88
|
+
|
|
89
|
+
Claude Code:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
claude mcp add video --scope user -- video-mcp
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Claude Desktop (`claude_desktop_config.json`) or any other MCP client:
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{ "mcpServers": { "video": { "command": "video-mcp" } } }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
If `video-mcp` is not on your PATH, use the full path to the executable or `python -m video_mcp.server`.
|
|
102
|
+
|
|
103
|
+
### Install the skill
|
|
104
|
+
|
|
105
|
+
The skill works with or without the MCP server: it falls back to `video-cli` and installs it on first use.
|
|
106
|
+
Copy the skill folder so your agent knows when and how to use the tools:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# macOS / Linux
|
|
110
|
+
mkdir -p ~/.claude/skills && cp -r skills/watch-video ~/.claude/skills/
|
|
111
|
+
# Windows (PowerShell)
|
|
112
|
+
Copy-Item -Recurse skills\watch-video $HOME\.claude\skills\
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Then ask Claude: *"Watch https://youtu.be/... and summarize it"*, or *"At what point do they explain the stop-loss, and what does the chart show then?"*
|
|
116
|
+
|
|
117
|
+
## Tools
|
|
118
|
+
|
|
119
|
+
| Tool | Use |
|
|
120
|
+
|---|---|
|
|
121
|
+
| `video_overview(source, max_frames=40, ocr=False)` | Start here: metadata, chapters, full transcript, contact sheets. `ocr=True` adds on-screen text. |
|
|
122
|
+
| `get_frames(source, timestamps, hi_res=True)` | Exact frames (`"12:30"`, `"1:02:05"` or seconds), up to 20. |
|
|
123
|
+
| `scan_range(source, start, end, count=12)` | Evenly spaced frames in one sheet, to follow motion or a demo. |
|
|
124
|
+
| `get_transcript(source, start, end)` | Transcript slice. |
|
|
125
|
+
| `search_transcript(source, query)` | Timestamped hits for any of the words, exact phrase first; plus on-screen text after an `ocr=True` overview. |
|
|
126
|
+
| `check_hardware()` | Detected tools, GPU and the transcription profile in use. |
|
|
127
|
+
|
|
128
|
+
## Command line
|
|
129
|
+
|
|
130
|
+
The same operations work without MCP:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
video-cli overview video.mp4 --max-frames 16
|
|
134
|
+
video-cli overview demo.mp4 --ocr # also read code/slides on screen
|
|
135
|
+
video-cli frames https://youtu.be/... 12:30 13:05
|
|
136
|
+
video-cli range video.mp4 10:00 10:30 --count 12
|
|
137
|
+
video-cli search video.mp4 "stop loss"
|
|
138
|
+
video-cli hw
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Configuration
|
|
142
|
+
|
|
143
|
+
| Variable | Default | Effect |
|
|
144
|
+
|---|---|---|
|
|
145
|
+
| `VIDEO_MCP_CACHE` | `~/.cache/video_mcp` | Cache directory (delete it to reclaim space). |
|
|
146
|
+
| `VIDEO_MCP_LANG` | `en` | Default transcription language; `auto` detects it with Whisper. |
|
|
147
|
+
| `VIDEO_MCP_THREADS` | `4` (CPU) | Threads for CPU transcription. |
|
|
148
|
+
| `VIDEO_MCP_AVX512` | unset | Set to `1` to let Whisper use AVX-512 (faster, but a very heavy sustained load). |
|
|
149
|
+
|
|
150
|
+
On CPU the server runs below normal priority, one heavy job at a time, with Whisper held to AVX2, so a
|
|
151
|
+
long transcription does not freeze your desktop.
|
|
152
|
+
|
|
153
|
+
## Limitations
|
|
154
|
+
|
|
155
|
+
- English is assumed unless you pass `language`. Parakeet covers 25 European languages on CPU (`language="de"`,
|
|
156
|
+
`"fr"`, `"es"`, ...). Other languages (Arabic, Chinese, Japanese, Hindi, ...) need Whisper, which is slow on CPU.
|
|
157
|
+
- Frames are sampled, not every frame is analysed; the transcript can mishear names and terms. The skill tells
|
|
158
|
+
Claude to cite timestamps and confirm exact on-screen text with a hi-res frame.
|
|
159
|
+
- Use it only on videos you are allowed to access and process; respect each site's terms and copyright.
|
|
160
|
+
|
|
161
|
+
## Development
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
pip install -e ".[dev]"
|
|
165
|
+
ruff check .
|
|
166
|
+
pytest
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "watch-video-mcp"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "MCP server and Claude skill that let an AI watch videos: timestamped transcript plus scene-selected keyframes, from local files or any yt-dlp URL."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "Marwan Mahmoud" }]
|
|
14
|
+
keywords = ["mcp", "claude", "video", "transcription", "keyframes", "yt-dlp", "parakeet", "whisper"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Multimedia :: Video",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"mcp>=1.2",
|
|
22
|
+
"yt-dlp",
|
|
23
|
+
"numpy",
|
|
24
|
+
"pillow",
|
|
25
|
+
"onnx-asr[cpu,hub]>=0.10",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
whisper = ["faster-whisper>=1.0"]
|
|
30
|
+
ocr = ["rapidocr-onnxruntime>=1.3"]
|
|
31
|
+
dev = ["pytest>=8", "ruff"]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/marwanm7moud/watch-video"
|
|
35
|
+
Issues = "https://github.com/marwanm7moud/watch-video/issues"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
video-mcp = "video_mcp.server:main"
|
|
39
|
+
video-cli = "video_mcp.cli:main"
|
|
40
|
+
|
|
41
|
+
[tool.setuptools]
|
|
42
|
+
packages = ["video_mcp"]
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
line-length = 120
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint]
|
|
48
|
+
select = ["E", "F", "I", "W"]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
from PIL import Image
|
|
6
|
+
|
|
7
|
+
from video_mcp import frames as fr
|
|
8
|
+
from video_mcp.hw import find_bin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_select_picks_changes_and_respects_budget():
|
|
12
|
+
# 30 keyframes: three visually distinct "slides", each repeated 10 times
|
|
13
|
+
thumbs = [np.full((fr.THUMB, fr.THUMB), v, np.int16) for v in (0, 120, 240) for _ in range(10)]
|
|
14
|
+
frames = [(float(i * 10), t) for i, t in enumerate(thumbs)]
|
|
15
|
+
picked = fr.select(frames, 300, max_frames=6)
|
|
16
|
+
assert picked[0] == 0
|
|
17
|
+
assert {0.0, 100.0, 200.0} <= set(picked)
|
|
18
|
+
assert len(picked) <= 6
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_select_empty():
|
|
22
|
+
assert fr.select([], 10) == []
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(scope="module")
|
|
26
|
+
def clip(tmp_path_factory):
|
|
27
|
+
try:
|
|
28
|
+
ffmpeg = find_bin("ffmpeg")
|
|
29
|
+
except FileNotFoundError:
|
|
30
|
+
pytest.skip("ffmpeg not installed")
|
|
31
|
+
path = tmp_path_factory.mktemp("clip") / "clip.mp4"
|
|
32
|
+
subprocess.run([ffmpeg, "-v", "error", "-y", "-f", "lavfi", "-i", "testsrc=size=320x240:rate=10:duration=6",
|
|
33
|
+
"-g", "10", "-pix_fmt", "yuv420p", str(path)], check=True)
|
|
34
|
+
return str(path)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_scan_extract_and_contact_sheet(clip, tmp_path):
|
|
38
|
+
kf = fr.scan_keyframes(clip)
|
|
39
|
+
assert len(kf) >= 5
|
|
40
|
+
out = fr.extract(clip, [0, 2.5, 5], str(tmp_path / "f"), long_side=256)
|
|
41
|
+
assert len(out) == 3
|
|
42
|
+
assert max(Image.open(out[0][1]).size) == 256
|
|
43
|
+
sheets = fr.contact_sheets(out, str(tmp_path / "s"))
|
|
44
|
+
assert len(sheets) == 1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_is_sparse():
|
|
48
|
+
assert fr.is_sparse(2, 600, 40) # 10-min screencast with 2 keyframes
|
|
49
|
+
assert not fr.is_sparse(3, 19, 8) # short clip: few keyframes are fine
|
|
50
|
+
assert not fr.is_sparse(60, 600, 40)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_sparse_keyframes_fall_back_to_timed_samples(tmp_path):
|
|
54
|
+
try:
|
|
55
|
+
ffmpeg = find_bin("ffmpeg")
|
|
56
|
+
except FileNotFoundError:
|
|
57
|
+
pytest.skip("ffmpeg not installed")
|
|
58
|
+
path = str(tmp_path / "long_gop.mp4")
|
|
59
|
+
# one keyframe for the whole 60 s clip, like a screen recording
|
|
60
|
+
subprocess.run([ffmpeg, "-v", "error", "-y", "-f", "lavfi", "-i", "testsrc=size=160x120:rate=5:duration=60",
|
|
61
|
+
"-g", "9999", "-keyint_min", "9999", "-sc_threshold", "0", "-pix_fmt", "yuv420p", path],
|
|
62
|
+
check=True)
|
|
63
|
+
assert len(fr.scan_keyframes(path)) <= 2
|
|
64
|
+
frames = fr.scan(path, 60, 40)
|
|
65
|
+
assert len(frames) >= 20
|
|
66
|
+
assert len(fr.select(frames, 60, max_frames=10)) >= 5
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from PIL import Image, ImageDraw
|
|
3
|
+
|
|
4
|
+
from video_mcp import ocr
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_new_lines_drops_repeats_and_empty_frames():
|
|
8
|
+
per = [(0, ["Title", "intro"]), (5, ["Title", "intro"]), (10, ["Title", "step 1"]), (15, [])]
|
|
9
|
+
assert ocr.new_lines(per) == [(0, ["Title", "intro"]), (10, ["step 1"])]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_format_ocr_truncates():
|
|
13
|
+
out = ocr.format_ocr([(0, ["x" * 50]), (60, ["y" * 50])], limit=60)
|
|
14
|
+
assert out.startswith("[00:00] ")
|
|
15
|
+
assert "truncated" in out
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.mark.skipif(not ocr.available(), reason="rapidocr not installed")
|
|
19
|
+
def test_reads_text_from_image(tmp_path):
|
|
20
|
+
p = tmp_path / "s.png"
|
|
21
|
+
im = Image.new("RGB", (900, 200), "white")
|
|
22
|
+
ImageDraw.Draw(im).text((20, 40), "def hello(name):", fill="black")
|
|
23
|
+
im.save(p)
|
|
24
|
+
assert any("hello" in ln.replace(" ", "") for ln in ocr._read(str(p)))
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from types import SimpleNamespace
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from video_mcp.core import parse_ts, search_transcript
|
|
7
|
+
from video_mcp.source import is_url
|
|
8
|
+
from video_mcp.transcribe import as_text, fmt_ts
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.parametrize("raw,expected", [("90", 90), ("1:30", 90), ("1:02:05", 3725), (12.5, 12.5), (" 0:10 ", 10)])
|
|
12
|
+
def test_parse_ts(raw, expected):
|
|
13
|
+
assert parse_ts(raw) == expected
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.mark.parametrize("secs,expected", [(0, "00:00"), (65, "01:05"), (3725, "1:02:05")])
|
|
17
|
+
def test_fmt_ts(secs, expected):
|
|
18
|
+
assert fmt_ts(secs) == expected
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_is_url():
|
|
22
|
+
assert is_url("https://youtu.be/abc")
|
|
23
|
+
assert is_url("HTTP://example.com/v.mp4")
|
|
24
|
+
assert not is_url(r"C:\videos\clip.mp4")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_as_text_merges_close_fragments_only():
|
|
28
|
+
segs = [{"start": 0, "end": 1, "text": "hello"}, {"start": 1.2, "end": 2, "text": "world"},
|
|
29
|
+
{"start": 10, "end": 11, "text": "later"}]
|
|
30
|
+
assert as_text(segs) == "[00:00] hello world\n[00:10] later"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _fake_video(monkeypatch, tmp_path, texts):
|
|
34
|
+
segs = [{"start": i * 5, "end": i * 5 + 4, "text": t} for i, t in enumerate(texts)]
|
|
35
|
+
monkeypatch.setattr("video_mcp.core.get_video", lambda src: SimpleNamespace(dir=str(tmp_path)))
|
|
36
|
+
monkeypatch.setattr("video_mcp.core.transcribe", lambda v: {"segments": segs})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_search_transcript_matches_any_word(monkeypatch, tmp_path):
|
|
40
|
+
_fake_video(monkeypatch, tmp_path, ["intro", "buy gold", "outro"])
|
|
41
|
+
out = search_transcript("x", "GOLD silver")["text"]
|
|
42
|
+
assert out.startswith("1 hits")
|
|
43
|
+
assert "[00:05] buy gold" in out
|
|
44
|
+
assert search_transcript("x", "nothing")["text"] == "no hits for 'nothing'"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_search_transcript_ranks_exact_phrase_first(monkeypatch, tmp_path):
|
|
48
|
+
texts = ["stop here", "a", "b", "loss only", "c", "d", "set the stop loss now", "e", "f", "stop and loss"]
|
|
49
|
+
_fake_video(monkeypatch, tmp_path, texts)
|
|
50
|
+
out = search_transcript("x", "Stop Loss", context=0)["text"]
|
|
51
|
+
order = [out.index(t) for t in ["stop loss now", "stop and loss", "stop here", "loss only"]]
|
|
52
|
+
assert order == sorted(order)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_search_transcript_includes_cached_ocr(monkeypatch, tmp_path):
|
|
56
|
+
_fake_video(monkeypatch, tmp_path, ["intro"])
|
|
57
|
+
(tmp_path / "ocr.json").write_text(json.dumps({"65.00": ["Error: disk full"], "3.00": ["Welcome"]}))
|
|
58
|
+
out = search_transcript("x", "error")["text"]
|
|
59
|
+
assert "no hits for 'error'" in out
|
|
60
|
+
assert "## On screen (OCR keyframes)\n[01:05] Error: disk full" in out
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""video_mcp: let Claude "watch" videos via transcripts + smart keyframes."""
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
__version__ = "0.2.0"
|
|
5
|
+
|
|
6
|
+
# HF's Xet transfer can stall model downloads on some networks; plain HTTPS is reliable.
|
|
7
|
+
os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
|
|
8
|
+
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
|
|
9
|
+
|
|
10
|
+
# AVX-512 int8 on all cores is the heaviest load a desktop CPU can run (big power/heat spikes,
|
|
11
|
+
# and it destabilised at least one machine), so CTranslate2 is held to AVX2 unless
|
|
12
|
+
# VIDEO_MCP_AVX512=1. Must be set before ctranslate2 is imported.
|
|
13
|
+
if os.environ.get("VIDEO_MCP_AVX512") != "1":
|
|
14
|
+
os.environ.setdefault("CT2_FORCE_CPU_ISA", "AVX2")
|