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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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")