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.
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .tapetum-cache/
7
+ *.pyc
8
+ docs/demo/result.json
9
+ docs/demo/transcript.srt
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.
@@ -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,3 @@
1
+ """tapetum: give your agent eyes."""
2
+
3
+ __version__ = "0.2.0"
@@ -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}