tapetum 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.
- tapetum-0.2.0/.gitignore +9 -0
- tapetum-0.2.0/LICENSE +21 -0
- tapetum-0.2.0/PKG-INFO +148 -0
- tapetum-0.2.0/README.md +117 -0
- tapetum-0.2.0/pyproject.toml +49 -0
- tapetum-0.2.0/src/tapetum/__init__.py +3 -0
- tapetum-0.2.0/src/tapetum/cli.py +92 -0
- tapetum-0.2.0/src/tapetum/common.py +129 -0
- tapetum-0.2.0/src/tapetum/doctor.py +67 -0
- tapetum-0.2.0/src/tapetum/frames.py +126 -0
- tapetum-0.2.0/src/tapetum/mcp_server.py +85 -0
- tapetum-0.2.0/src/tapetum/media.py +136 -0
- tapetum-0.2.0/src/tapetum/ocr.py +22 -0
- tapetum-0.2.0/src/tapetum/pipeline.py +179 -0
- tapetum-0.2.0/src/tapetum/timeline.py +72 -0
- tapetum-0.2.0/src/tapetum/transcript.py +285 -0
- tapetum-0.2.0/src/tapetum/view.py +41 -0
- tapetum-0.2.0/src/tapetum/viewer.html +207 -0
- tapetum-0.2.0/tests/test_core.py +188 -0
tapetum-0.2.0/.gitignore
ADDED
tapetum-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nadercr7
|
|
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.
|
tapetum-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tapetum
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Give your agent eyes. Paste a video link, get one timeline of what was said and what was on screen. No cookies, no API keys.
|
|
5
|
+
Project-URL: Homepage, https://nadercr7.github.io/tapetum/
|
|
6
|
+
Project-URL: Source, https://github.com/Nadercr7/tapetum
|
|
7
|
+
Project-URL: Issues, https://github.com/Nadercr7/tapetum/issues
|
|
8
|
+
Author: Nadercr7
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent,llm,mcp,transcript,video,whisper,youtube
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Multimedia :: Video
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: faster-whisper>=1.1
|
|
21
|
+
Requires-Dist: imageio-ffmpeg
|
|
22
|
+
Requires-Dist: numpy
|
|
23
|
+
Requires-Dist: pillow>=10.1
|
|
24
|
+
Requires-Dist: youtube-transcript-api>=1.2
|
|
25
|
+
Requires-Dist: yt-dlp[default]>=2026.8.1
|
|
26
|
+
Provides-Extra: mcp
|
|
27
|
+
Requires-Dist: mcp>=1.2; extra == 'mcp'
|
|
28
|
+
Provides-Extra: ocr
|
|
29
|
+
Requires-Dist: rapidocr-onnxruntime; extra == 'ocr'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
<p align="center">
|
|
33
|
+
<a href="https://nadercr7.github.io/tapetum/"><img src="https://raw.githubusercontent.com/Nadercr7/tapetum/main/docs/social.png" alt="tapetum: give your agent eyes" width="760"></a>
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
<p align="center">
|
|
37
|
+
<a href="https://github.com/Nadercr7/tapetum/actions/workflows/ci.yml"><img src="https://github.com/Nadercr7/tapetum/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
38
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-E8A317.svg" alt="MIT license"></a>
|
|
39
|
+
<img src="https://img.shields.io/badge/python-3.10%2B-0B0D12.svg" alt="Python 3.10+">
|
|
40
|
+
<a href="https://nadercr7.github.io/tapetum/"><img src="https://img.shields.io/badge/site-live-E8A317.svg" alt="Website"></a>
|
|
41
|
+
</p>
|
|
42
|
+
|
|
43
|
+
**tapetum lets an AI agent watch a video.** You give it a link. It hands back one file with what was said and what was on screen, in time order, plus the handful of frames worth looking at. No cookies, no API keys, no account. Everything runs on your machine.
|
|
44
|
+
|
|
45
|
+
<p align="center">
|
|
46
|
+
<img src="https://raw.githubusercontent.com/Nadercr7/tapetum/main/docs/media/demo.gif" alt="tapetum running on a YouTube link and printing the timeline" width="760">
|
|
47
|
+
</p>
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
You need [uv](https://docs.astral.sh/uv/) (or pipx).
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uv tool install git+https://github.com/Nadercr7/tapetum
|
|
55
|
+
tapetum "https://youtu.be/jNQXAC9IVRw"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
ffmpeg comes bundled. Check your setup any time with `tapetum doctor`.
|
|
59
|
+
|
|
60
|
+
## What you get back
|
|
61
|
+
|
|
62
|
+
One JSON object on stdout, and a folder:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
timeline.md speech and frame references in time order. Start here.
|
|
66
|
+
sheet_01.jpg nine frames per image, timestamps burned in
|
|
67
|
+
frames/ the frames it kept
|
|
68
|
+
transcript.srt plus transcript.json
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A slice of `timeline.md`:
|
|
72
|
+
|
|
73
|
+
```markdown
|
|
74
|
+
### 00:05 – 00:10
|
|
75
|
+
frame: `frames/f001_00-05.jpg`
|
|
76
|
+
> the cool thing about these guys is that they have really...
|
|
77
|
+
> really really long trunks
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Open any run in your browser with `tapetum view <folder>`: frames on one side, a searchable transcript on the other. [See a live one.](https://nadercr7.github.io/tapetum/demo/)
|
|
81
|
+
|
|
82
|
+
## How it works
|
|
83
|
+
|
|
84
|
+
**Words, cheapest first.** It reads the platform's captions when they exist: human ones over auto-generated, the original language over machine translations, your `--lang` over both. When there are none, it downloads a small audio file and runs Whisper locally. Every step it tried is listed in `steps`, failures included.
|
|
85
|
+
|
|
86
|
+
**Frames by change, not by clock.** It samples a frame every second, then keeps one only when the picture actually changed. A new line on a slide counts. A wobbling camera doesn't. Transitions collapse into one settled frame, and `--budget` caps the total, so a talking head costs you three images instead of thirty.
|
|
87
|
+
|
|
88
|
+
**Detail on demand.** `tapetum <url> --at 03:12` downloads just that moment at 720p and writes four sharp frames. No re-run of the whole video.
|
|
89
|
+
|
|
90
|
+
**Honest output.** Every error comes with a `code` and a `hint`. Shaky transcripts carry warnings. Failed runs aren't cached, so a network blip doesn't stick.
|
|
91
|
+
|
|
92
|
+
## Use it from an agent
|
|
93
|
+
|
|
94
|
+
| Where | How |
|
|
95
|
+
|---|---|
|
|
96
|
+
| Claude Code | `/plugin marketplace add Nadercr7/tapetum`, then `/plugin install tapetum@tapetum`. Or copy `skills/tapetum/` to `~/.claude/skills/`. |
|
|
97
|
+
| Any MCP client | Install with `uv tool install "tapetum[mcp] @ git+https://github.com/Nadercr7/tapetum"` and add `{"command": "tapetum", "args": ["mcp"]}`. Tools: `watch_video`, `zoom_video`. Frames come back as images. |
|
|
98
|
+
| Codex, Cursor, others | [AGENTS.md](AGENTS.md) tells them how to call it. |
|
|
99
|
+
|
|
100
|
+
The skill file is short on purpose. Long instructions cost tokens on every single run.
|
|
101
|
+
|
|
102
|
+
## Options
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
tapetum <url | file>
|
|
106
|
+
--lang ar,en languages you prefer, in order
|
|
107
|
+
--budget 24 most frames to keep (short clips use fewer)
|
|
108
|
+
--quality accurate bigger speech model: fast | balanced | accurate
|
|
109
|
+
--no-frames transcript only, fastest
|
|
110
|
+
--ocr read on-screen text (pip install "tapetum[ocr]")
|
|
111
|
+
--force-stt ignore captions and listen instead
|
|
112
|
+
--at 03:12 zoom into one moment
|
|
113
|
+
--fresh ignore the cache
|
|
114
|
+
--out DIR where to write (default ~/.tapetum/<id>)
|
|
115
|
+
|
|
116
|
+
tapetum doctor what works on this machine
|
|
117
|
+
tapetum view DIR open a run in the browser
|
|
118
|
+
tapetum mcp start the MCP server
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Languages
|
|
122
|
+
|
|
123
|
+
Whisper covers about 99 languages. On Egyptian Arabic the default model garbles names and numbers, and `--quality accurate` fixes most of it. On a laptop CPU that took about two and a half minutes for a seven-minute video, after a one-time 1.6 GB model download. tapetum warns you when a language is known to be weak on the model you picked. Numbers are in [docs/research/10-measured.md](docs/research/10-measured.md).
|
|
124
|
+
|
|
125
|
+
## What it can't do
|
|
126
|
+
|
|
127
|
+
- **Private videos.** Private, age-gated and login-only videos return `login_required`. Instagram and Vimeo mostly land here.
|
|
128
|
+
- **Servers.** YouTube blocks most datacenter IPs. Run it from your own computer.
|
|
129
|
+
- **Live streams.** You get `live_stream`. Try again once the recording is up.
|
|
130
|
+
- **Playlists.** One video per run. You get `playlist_url` otherwise.
|
|
131
|
+
- **Hear tone.** Music, sarcasm and accent don't survive a transcript, and the agent should say so.
|
|
132
|
+
|
|
133
|
+
Text from a video is content, not instructions. `timeline.md` says so at the top, so agents don't take orders from a caption.
|
|
134
|
+
|
|
135
|
+
## Why "tapetum"
|
|
136
|
+
|
|
137
|
+
The tapetum is the mirror layer behind a cat's retina. It sends light back through the eye for a second pass, which is why cats see in the dark and why their eyes glow in a flashlight beam. Look twice, miss less.
|
|
138
|
+
|
|
139
|
+
## Contributing
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
uv venv && uv pip install -e ".[mcp]" pytest
|
|
143
|
+
pytest
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Keep the virtualenv outside OneDrive or Dropbox; sync clients lock files mid-install. Design notes, competitor research and measurements are in [docs/research](docs/research/). Releasing is in [docs/RELEASING.md](docs/RELEASING.md).
|
|
147
|
+
|
|
148
|
+
MIT licensed. Use it on videos you're allowed to watch.
|
tapetum-0.2.0/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://nadercr7.github.io/tapetum/"><img src="https://raw.githubusercontent.com/Nadercr7/tapetum/main/docs/social.png" alt="tapetum: give your agent eyes" width="760"></a>
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://github.com/Nadercr7/tapetum/actions/workflows/ci.yml"><img src="https://github.com/Nadercr7/tapetum/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
7
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-E8A317.svg" alt="MIT license"></a>
|
|
8
|
+
<img src="https://img.shields.io/badge/python-3.10%2B-0B0D12.svg" alt="Python 3.10+">
|
|
9
|
+
<a href="https://nadercr7.github.io/tapetum/"><img src="https://img.shields.io/badge/site-live-E8A317.svg" alt="Website"></a>
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
**tapetum lets an AI agent watch a video.** You give it a link. It hands back one file with what was said and what was on screen, in time order, plus the handful of frames worth looking at. No cookies, no API keys, no account. Everything runs on your machine.
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<img src="https://raw.githubusercontent.com/Nadercr7/tapetum/main/docs/media/demo.gif" alt="tapetum running on a YouTube link and printing the timeline" width="760">
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
You need [uv](https://docs.astral.sh/uv/) (or pipx).
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv tool install git+https://github.com/Nadercr7/tapetum
|
|
24
|
+
tapetum "https://youtu.be/jNQXAC9IVRw"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
ffmpeg comes bundled. Check your setup any time with `tapetum doctor`.
|
|
28
|
+
|
|
29
|
+
## What you get back
|
|
30
|
+
|
|
31
|
+
One JSON object on stdout, and a folder:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
timeline.md speech and frame references in time order. Start here.
|
|
35
|
+
sheet_01.jpg nine frames per image, timestamps burned in
|
|
36
|
+
frames/ the frames it kept
|
|
37
|
+
transcript.srt plus transcript.json
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
A slice of `timeline.md`:
|
|
41
|
+
|
|
42
|
+
```markdown
|
|
43
|
+
### 00:05 – 00:10
|
|
44
|
+
frame: `frames/f001_00-05.jpg`
|
|
45
|
+
> the cool thing about these guys is that they have really...
|
|
46
|
+
> really really long trunks
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Open any run in your browser with `tapetum view <folder>`: frames on one side, a searchable transcript on the other. [See a live one.](https://nadercr7.github.io/tapetum/demo/)
|
|
50
|
+
|
|
51
|
+
## How it works
|
|
52
|
+
|
|
53
|
+
**Words, cheapest first.** It reads the platform's captions when they exist: human ones over auto-generated, the original language over machine translations, your `--lang` over both. When there are none, it downloads a small audio file and runs Whisper locally. Every step it tried is listed in `steps`, failures included.
|
|
54
|
+
|
|
55
|
+
**Frames by change, not by clock.** It samples a frame every second, then keeps one only when the picture actually changed. A new line on a slide counts. A wobbling camera doesn't. Transitions collapse into one settled frame, and `--budget` caps the total, so a talking head costs you three images instead of thirty.
|
|
56
|
+
|
|
57
|
+
**Detail on demand.** `tapetum <url> --at 03:12` downloads just that moment at 720p and writes four sharp frames. No re-run of the whole video.
|
|
58
|
+
|
|
59
|
+
**Honest output.** Every error comes with a `code` and a `hint`. Shaky transcripts carry warnings. Failed runs aren't cached, so a network blip doesn't stick.
|
|
60
|
+
|
|
61
|
+
## Use it from an agent
|
|
62
|
+
|
|
63
|
+
| Where | How |
|
|
64
|
+
|---|---|
|
|
65
|
+
| Claude Code | `/plugin marketplace add Nadercr7/tapetum`, then `/plugin install tapetum@tapetum`. Or copy `skills/tapetum/` to `~/.claude/skills/`. |
|
|
66
|
+
| Any MCP client | Install with `uv tool install "tapetum[mcp] @ git+https://github.com/Nadercr7/tapetum"` and add `{"command": "tapetum", "args": ["mcp"]}`. Tools: `watch_video`, `zoom_video`. Frames come back as images. |
|
|
67
|
+
| Codex, Cursor, others | [AGENTS.md](AGENTS.md) tells them how to call it. |
|
|
68
|
+
|
|
69
|
+
The skill file is short on purpose. Long instructions cost tokens on every single run.
|
|
70
|
+
|
|
71
|
+
## Options
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
tapetum <url | file>
|
|
75
|
+
--lang ar,en languages you prefer, in order
|
|
76
|
+
--budget 24 most frames to keep (short clips use fewer)
|
|
77
|
+
--quality accurate bigger speech model: fast | balanced | accurate
|
|
78
|
+
--no-frames transcript only, fastest
|
|
79
|
+
--ocr read on-screen text (pip install "tapetum[ocr]")
|
|
80
|
+
--force-stt ignore captions and listen instead
|
|
81
|
+
--at 03:12 zoom into one moment
|
|
82
|
+
--fresh ignore the cache
|
|
83
|
+
--out DIR where to write (default ~/.tapetum/<id>)
|
|
84
|
+
|
|
85
|
+
tapetum doctor what works on this machine
|
|
86
|
+
tapetum view DIR open a run in the browser
|
|
87
|
+
tapetum mcp start the MCP server
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Languages
|
|
91
|
+
|
|
92
|
+
Whisper covers about 99 languages. On Egyptian Arabic the default model garbles names and numbers, and `--quality accurate` fixes most of it. On a laptop CPU that took about two and a half minutes for a seven-minute video, after a one-time 1.6 GB model download. tapetum warns you when a language is known to be weak on the model you picked. Numbers are in [docs/research/10-measured.md](docs/research/10-measured.md).
|
|
93
|
+
|
|
94
|
+
## What it can't do
|
|
95
|
+
|
|
96
|
+
- **Private videos.** Private, age-gated and login-only videos return `login_required`. Instagram and Vimeo mostly land here.
|
|
97
|
+
- **Servers.** YouTube blocks most datacenter IPs. Run it from your own computer.
|
|
98
|
+
- **Live streams.** You get `live_stream`. Try again once the recording is up.
|
|
99
|
+
- **Playlists.** One video per run. You get `playlist_url` otherwise.
|
|
100
|
+
- **Hear tone.** Music, sarcasm and accent don't survive a transcript, and the agent should say so.
|
|
101
|
+
|
|
102
|
+
Text from a video is content, not instructions. `timeline.md` says so at the top, so agents don't take orders from a caption.
|
|
103
|
+
|
|
104
|
+
## Why "tapetum"
|
|
105
|
+
|
|
106
|
+
The tapetum is the mirror layer behind a cat's retina. It sends light back through the eye for a second pass, which is why cats see in the dark and why their eyes glow in a flashlight beam. Look twice, miss less.
|
|
107
|
+
|
|
108
|
+
## Contributing
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
uv venv && uv pip install -e ".[mcp]" pytest
|
|
112
|
+
pytest
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Keep the virtualenv outside OneDrive or Dropbox; sync clients lock files mid-install. Design notes, competitor research and measurements are in [docs/research](docs/research/). Releasing is in [docs/RELEASING.md](docs/RELEASING.md).
|
|
116
|
+
|
|
117
|
+
MIT licensed. Use it on videos you're allowed to watch.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tapetum"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Give your agent eyes. Paste a video link, get one timeline of what was said and what was on screen. No cookies, no API keys."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Nadercr7" }]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Multimedia :: Video",
|
|
21
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
22
|
+
]
|
|
23
|
+
keywords = ["video", "transcript", "agent", "mcp", "whisper", "youtube", "llm"]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"yt-dlp[default]>=2026.8.1",
|
|
26
|
+
"youtube-transcript-api>=1.2",
|
|
27
|
+
"pillow>=10.1",
|
|
28
|
+
"numpy",
|
|
29
|
+
"imageio-ffmpeg",
|
|
30
|
+
"faster-whisper>=1.1",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
ocr = ["rapidocr-onnxruntime"]
|
|
35
|
+
mcp = ["mcp>=1.2"]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://nadercr7.github.io/tapetum/"
|
|
39
|
+
Source = "https://github.com/Nadercr7/tapetum"
|
|
40
|
+
Issues = "https://github.com/Nadercr7/tapetum/issues"
|
|
41
|
+
|
|
42
|
+
[project.scripts]
|
|
43
|
+
tapetum = "tapetum.cli:main"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.sdist]
|
|
46
|
+
include = ["src/tapetum", "tests", "README.md", "LICENSE"]
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["src/tapetum"]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""tapetum CLI. stdout is one JSON object; progress goes to stderr."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _emit(obj: dict, as_json: bool) -> int:
|
|
12
|
+
if as_json or not sys.stdout.isatty():
|
|
13
|
+
print(json.dumps(obj, ensure_ascii=False))
|
|
14
|
+
else:
|
|
15
|
+
if obj.get("ok") is False and obj.get("error"):
|
|
16
|
+
e = obj["error"]
|
|
17
|
+
print(f"✗ {e['code']}: {e['message']}\n → {e['hint']}")
|
|
18
|
+
elif "checks" in obj:
|
|
19
|
+
for c in obj["checks"]:
|
|
20
|
+
print(f"{'✓' if c['ok'] else '✗'} {c['check']:<20} {c['detail']}")
|
|
21
|
+
else:
|
|
22
|
+
print(json.dumps(obj, ensure_ascii=False, indent=2))
|
|
23
|
+
return 0 if obj.get("ok") else 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main(argv: list[str] | None = None) -> int:
|
|
27
|
+
"""Every path ends in one JSON object on stdout, even a crash."""
|
|
28
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
29
|
+
as_json = "--json" in argv
|
|
30
|
+
try:
|
|
31
|
+
return _main(argv)
|
|
32
|
+
except SystemExit:
|
|
33
|
+
raise
|
|
34
|
+
except KeyboardInterrupt:
|
|
35
|
+
return _emit({"ok": False, "error": {"code": "interrupted", "message": "stopped by the user", "hint": ""}}, as_json)
|
|
36
|
+
except Exception as e:
|
|
37
|
+
from .common import TapetumError
|
|
38
|
+
if isinstance(e, TapetumError):
|
|
39
|
+
return _emit({"ok": False, "error": e.to_dict()}, as_json)
|
|
40
|
+
return _emit({"ok": False, "error": {"code": "internal", "message": f"{type(e).__name__}: {str(e)[:300]}",
|
|
41
|
+
"hint": "This is a bug. Please report it at https://github.com/Nadercr7/tapetum/issues"}}, as_json)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _main(argv: list[str]) -> int:
|
|
45
|
+
for stream in (sys.stdout, sys.stderr): # Windows consoles: never choke on non-ASCII
|
|
46
|
+
try:
|
|
47
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
if argv[:1] == ["doctor"]:
|
|
51
|
+
from .doctor import run
|
|
52
|
+
return _emit(run(), "--json" in argv)
|
|
53
|
+
if argv[:1] == ["view"]:
|
|
54
|
+
from .view import build
|
|
55
|
+
if len(argv) < 2:
|
|
56
|
+
print("usage: tapetum view <run-dir> [--no-open]", file=sys.stderr)
|
|
57
|
+
return 2
|
|
58
|
+
return _emit(build(argv[1], "--no-open" not in argv), "--json" in argv)
|
|
59
|
+
if argv[:1] == ["mcp"]:
|
|
60
|
+
from .mcp_server import serve
|
|
61
|
+
serve()
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
p = argparse.ArgumentParser(prog="tapetum", description="Give any AI agent eyes.")
|
|
65
|
+
p.add_argument("source", help="video URL, YouTube id, or local file")
|
|
66
|
+
p.add_argument("--version", action="version", version=__version__)
|
|
67
|
+
p.add_argument("--lang", help="preferred caption/speech languages, e.g. ar,en")
|
|
68
|
+
p.add_argument("--budget", type=int, default=24, help="max frames kept (default 24; short clips use fewer)")
|
|
69
|
+
p.add_argument("--quality", default="balanced", help="fast | balanced | accurate | any Whisper model name")
|
|
70
|
+
p.add_argument("--no-frames", action="store_true", help="transcript only")
|
|
71
|
+
p.add_argument("--ocr", action="store_true", help="read on-screen text (needs tapetum[ocr])")
|
|
72
|
+
p.add_argument("--force-stt", action="store_true", help="skip platform captions, use speech-to-text")
|
|
73
|
+
p.add_argument("--fresh", action="store_true", help="ignore the cache")
|
|
74
|
+
p.add_argument("--out", help="output directory (default: ~/.tapetum/<id>)")
|
|
75
|
+
p.add_argument("--at", help="zoom: full-resolution frames around MM:SS")
|
|
76
|
+
p.add_argument("--window", type=float, default=8.0, help="zoom window in seconds")
|
|
77
|
+
p.add_argument("--json", action="store_true", help="always print JSON")
|
|
78
|
+
a = p.parse_args(argv)
|
|
79
|
+
|
|
80
|
+
from . import pipeline
|
|
81
|
+
if a.at:
|
|
82
|
+
res = pipeline.zoom(a.source, a.at, a.window, out=a.out)
|
|
83
|
+
else:
|
|
84
|
+
res = pipeline.watch(
|
|
85
|
+
a.source, out=a.out, budget=a.budget, lang=a.lang, quality=a.quality,
|
|
86
|
+
no_frames=a.no_frames, fresh=a.fresh, force_stt=a.force_stt, ocr=a.ocr,
|
|
87
|
+
)
|
|
88
|
+
return _emit(res, a.json)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Shared helpers: errors, logging, ffmpeg, timestamps, source parsing."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.parse import parse_qs, urlparse
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TapetumError(Exception):
|
|
16
|
+
"""An error the agent can act on. `hint` says what to try next."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, code: str, message: str, hint: str = ""):
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.code, self.message, self.hint = code, message, hint
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict:
|
|
23
|
+
return {"code": self.code, "message": self.message, "hint": self.hint}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def log(msg: str) -> None:
|
|
27
|
+
print(f"[tapetum] {msg}", file=sys.stderr, flush=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fmt_ts(sec: float) -> str:
|
|
31
|
+
sec = max(0, int(sec))
|
|
32
|
+
h, rem = divmod(sec, 3600)
|
|
33
|
+
m, s = divmod(rem, 60)
|
|
34
|
+
return f"{h}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_ts(text: str) -> float:
|
|
38
|
+
"""'3:12', '01:03:12', '192' or '192s' -> seconds."""
|
|
39
|
+
t = text.strip().lower().rstrip("s")
|
|
40
|
+
parts = [float(p) for p in t.split(":")]
|
|
41
|
+
out = 0.0
|
|
42
|
+
for p in parts:
|
|
43
|
+
out = out * 60 + p
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def ffmpeg_exe() -> str:
|
|
48
|
+
found = shutil.which("ffmpeg")
|
|
49
|
+
if found:
|
|
50
|
+
return found
|
|
51
|
+
try:
|
|
52
|
+
import imageio_ffmpeg
|
|
53
|
+
|
|
54
|
+
return imageio_ffmpeg.get_ffmpeg_exe()
|
|
55
|
+
except Exception as exc: # pragma: no cover
|
|
56
|
+
raise TapetumError(
|
|
57
|
+
"no_ffmpeg", "ffmpeg not found", "Install ffmpeg or `pip install imageio-ffmpeg`."
|
|
58
|
+
) from exc
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_ffmpeg(args: list[str]) -> None:
|
|
62
|
+
cmd = [ffmpeg_exe(), "-hide_banner", "-loglevel", "error", "-y", *args]
|
|
63
|
+
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
|
64
|
+
if r.returncode != 0:
|
|
65
|
+
raise TapetumError("ffmpeg_failed", r.stderr.strip()[-400:] or "ffmpeg failed")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cache_root() -> Path:
|
|
69
|
+
base = os.environ.get("TAPETUM_HOME")
|
|
70
|
+
root = Path(base) if base else Path.home() / ".tapetum"
|
|
71
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
return root
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class Source:
|
|
77
|
+
kind: str # youtube | tiktok | instagram | x | vimeo | twitch | reddit | local | web
|
|
78
|
+
id: str
|
|
79
|
+
url: str
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def key(self) -> str:
|
|
83
|
+
return re.sub(r"[^A-Za-z0-9_-]+", "_", f"{self.kind}-{self.id}")[:80]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_YT_ID = re.compile(r"^[A-Za-z0-9_-]{11}$")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_source(raw: str) -> Source:
|
|
90
|
+
raw = raw.strip()
|
|
91
|
+
if os.path.exists(raw):
|
|
92
|
+
p = Path(raw).resolve()
|
|
93
|
+
if p.is_dir():
|
|
94
|
+
raise TapetumError("not_a_video", f"{p} is a folder", "Pass a video file or a URL.")
|
|
95
|
+
st = p.stat()
|
|
96
|
+
# same name in two folders, or a file edited in place, must not share a cache entry
|
|
97
|
+
digest = hashlib.sha1(f"{p}|{st.st_size}|{st.st_mtime_ns}".encode("utf-8")).hexdigest()[:10]
|
|
98
|
+
stem = re.sub(r"[^A-Za-z0-9_-]+", "_", p.stem).strip("_")[:40] or "file"
|
|
99
|
+
return Source("local", f"{stem}-{digest}", str(p))
|
|
100
|
+
if _YT_ID.match(raw):
|
|
101
|
+
return Source("youtube", raw, f"https://www.youtube.com/watch?v={raw}")
|
|
102
|
+
u = urlparse(raw if "://" in raw else "https://" + raw)
|
|
103
|
+
host = (u.hostname or "").lower().removeprefix("www.").removeprefix("m.")
|
|
104
|
+
path = u.path.strip("/")
|
|
105
|
+
q = parse_qs(u.query)
|
|
106
|
+
if host == "youtu.be":
|
|
107
|
+
return Source("youtube", path.split("/")[0], f"https://www.youtube.com/watch?v={path.split('/')[0]}")
|
|
108
|
+
if host.endswith("youtube.com") or host.endswith("youtube-nocookie.com"):
|
|
109
|
+
vid = (q.get("v") or [""])[0]
|
|
110
|
+
if not vid:
|
|
111
|
+
m = re.match(r"(?:shorts|embed|live|v)/([A-Za-z0-9_-]{11})", path)
|
|
112
|
+
vid = m.group(1) if m else ""
|
|
113
|
+
if vid:
|
|
114
|
+
return Source("youtube", vid, f"https://www.youtube.com/watch?v={vid}")
|
|
115
|
+
if "list" in q or path.startswith(("playlist", "@", "c/", "channel/", "user/")):
|
|
116
|
+
raise TapetumError("playlist_url", "this is a playlist or channel, not a single video",
|
|
117
|
+
"Pass one video link. For many videos, run tapetum once per link.")
|
|
118
|
+
for kind, domains in {
|
|
119
|
+
"tiktok": ("tiktok.com",),
|
|
120
|
+
"instagram": ("instagram.com",),
|
|
121
|
+
"x": ("x.com", "twitter.com"),
|
|
122
|
+
"vimeo": ("vimeo.com",),
|
|
123
|
+
"twitch": ("twitch.tv",),
|
|
124
|
+
"reddit": ("reddit.com", "v.redd.it"),
|
|
125
|
+
}.items():
|
|
126
|
+
if any(host == d or host.endswith("." + d) for d in domains):
|
|
127
|
+
m = re.findall(r"(\d{5,})", path) or [path.split("/")[-1] or host]
|
|
128
|
+
return Source(kind, m[-1], u.geturl())
|
|
129
|
+
return Source("web", re.sub(r"\W+", "_", host + "_" + path)[:50], u.geturl())
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""`tapetum doctor`: say what works on THIS machine, and what to do about what doesn't."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
from .common import ffmpeg_exe
|
|
9
|
+
|
|
10
|
+
TEST_VIDEO = "jNQXAC9IVRw" # the first YouTube video; short and stable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _check(name, fn):
|
|
14
|
+
try:
|
|
15
|
+
ok, detail = fn()
|
|
16
|
+
except Exception as exc:
|
|
17
|
+
ok, detail = False, f"{type(exc).__name__}: {str(exc)[:120]}"
|
|
18
|
+
return {"check": name, "ok": ok, "detail": detail}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run() -> dict:
|
|
22
|
+
checks = []
|
|
23
|
+
checks.append(_check("python>=3.10", lambda: (sys.version_info >= (3, 10), sys.version.split()[0])))
|
|
24
|
+
checks.append(_check("ffmpeg", lambda: (True, ffmpeg_exe())))
|
|
25
|
+
|
|
26
|
+
def ytdlp():
|
|
27
|
+
import yt_dlp
|
|
28
|
+
return True, yt_dlp.version.__version__
|
|
29
|
+
|
|
30
|
+
checks.append(_check("yt-dlp", ytdlp))
|
|
31
|
+
|
|
32
|
+
def js():
|
|
33
|
+
found = [n for n in ("deno", "node", "qjs", "bun") if shutil.which(n)]
|
|
34
|
+
return bool(found), ", ".join(found) or "none (some YouTube formats may fail; install Node 22+ or Deno)"
|
|
35
|
+
|
|
36
|
+
checks.append(_check("js-runtime", js))
|
|
37
|
+
|
|
38
|
+
def net():
|
|
39
|
+
urllib.request.urlopen("https://www.youtube.com/", timeout=8).read(200)
|
|
40
|
+
return True, "reachable"
|
|
41
|
+
|
|
42
|
+
checks.append(_check("network:youtube", net))
|
|
43
|
+
|
|
44
|
+
def captions():
|
|
45
|
+
from youtube_transcript_api import YouTubeTranscriptApi
|
|
46
|
+
n = len(list(YouTubeTranscriptApi().fetch(TEST_VIDEO)))
|
|
47
|
+
return n > 0, f"{n} caption lines"
|
|
48
|
+
|
|
49
|
+
checks.append(_check("youtube-captions", captions))
|
|
50
|
+
|
|
51
|
+
def stt():
|
|
52
|
+
import faster_whisper
|
|
53
|
+
return True, f"faster-whisper {faster_whisper.__version__}"
|
|
54
|
+
|
|
55
|
+
checks.append(_check("speech-to-text", stt))
|
|
56
|
+
|
|
57
|
+
def ocr():
|
|
58
|
+
import rapidocr_onnxruntime # noqa: F401
|
|
59
|
+
return True, "installed"
|
|
60
|
+
|
|
61
|
+
r = _check("ocr (optional)", ocr)
|
|
62
|
+
if not r["ok"]:
|
|
63
|
+
r["detail"] = "not installed: pip install 'tapetum[ocr]'"
|
|
64
|
+
r["optional"] = True
|
|
65
|
+
checks.append(r)
|
|
66
|
+
required = [c for c in checks if not c.get("optional")]
|
|
67
|
+
return {"ok": all(c["ok"] for c in required), "checks": checks}
|