yt-tools-cli 0.24.1__tar.gz → 0.24.2__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.
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/.claude-plugin/plugin.json +1 -1
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/.github/workflows/ci.yml +7 -3
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/CHANGELOG.md +54 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/PKG-INFO +63 -21
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/README.md +61 -19
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/pyproject.toml +13 -2
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/skills/using-yt-tools/SKILL.md +31 -19
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_doctor.py +7 -1
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_missing_extra.py +29 -7
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_ocr.py +8 -3
- yt_tools_cli-0.24.2/tests/test_ocr_engine.py +121 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_packaging.py +74 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_skill_portable.py +31 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/yt_tools/doctor.py +4 -3
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/yt_tools/extras.py +26 -4
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/.github/workflows/release.yml +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/.gitignore +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/CONTRIBUTING.md +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/LICENSE +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/hooks/hooks.json +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/scripts/check-core-install.sh +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/scripts/check-metadata.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/scripts/check-pypi-name.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/scripts/ensure-install.ps1 +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/scripts/ensure-install.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/scripts/ensure-install.sh +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/scripts/publish-check.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/scripts/sync-version.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/__init__.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_cache.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_check_metadata.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_cli_smoke.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_comments.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_core.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_distribution_name.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_failure_modes.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_frames.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_listen.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_markdown.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_meta.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_parse_interval.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_publish_check.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_pypi_name.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_search.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_transcript.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_version.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/tests/test_watch.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/yt_tools/__init__.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/yt_tools/_metadata.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/yt_tools/cache.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/yt_tools/cli.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/yt_tools/comments.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/yt_tools/core.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/yt_tools/frames.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/yt_tools/listen.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/yt_tools/markdown.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/yt_tools/meta.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/yt_tools/ocr.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/yt_tools/search.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/yt_tools/transcript.py +0 -0
- {yt_tools_cli-0.24.1 → yt_tools_cli-0.24.2}/yt_tools/watch.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yt-tools",
|
|
3
3
|
"displayName": "yt-tools",
|
|
4
|
-
"version": "0.24.
|
|
4
|
+
"version": "0.24.2",
|
|
5
5
|
"description": "Iterative YouTube watching for agents: clean-markdown transcripts with [mm:ss] anchors, targeted frame extraction, FFT audio analysis (BPM, key, chord progression, spectral features), full-metadata extraction (description, chapters, most-replayed, counts, tags), comment scraping, and OCR over cached frames (RapidOCR PP-OCRv5) for silent-with-text videos. Light core (transcript / metadata / comments / search) with heavy stacks in [frames] and [audio] extras. Bundles the using-yt-tools skill and a SessionStart hook that auto-installs from the plugin's local clone via pipx (default [full] extras, fallback to [frames,audio]) and probes ffmpeg.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Victor Kuznetsov",
|
|
@@ -20,10 +20,14 @@ jobs:
|
|
|
20
20
|
python-version: "3.12"
|
|
21
21
|
# The heavy stacks are installed on purpose: a handful of tests exercise the
|
|
22
22
|
# real audio pipeline rather than a mock, and a suite that is red on a clean
|
|
23
|
-
# machine teaches everyone to ignore it.
|
|
24
|
-
#
|
|
23
|
+
# machine teaches everyone to ignore it. `[ocr]` is in the list for the same
|
|
24
|
+
# reason as the rest: the whole suite mocked the engine factory while the
|
|
25
|
+
# published extra resolved to a rapidocr whose params schema yt-ocr does not
|
|
26
|
+
# speak, so a clean `pipx install "yt-tools-cli[ocr]"` broke with the suite
|
|
27
|
+
# green ([[task:2831]]). tests/test_ocr_engine.py drives the real engine and
|
|
28
|
+
# needs the network once, for the ~10 MB PP-OCRv5 model.
|
|
25
29
|
- name: Install
|
|
26
|
-
run: pip install -e ".[full,test]"
|
|
30
|
+
run: pip install -e ".[full,ocr,test]"
|
|
27
31
|
- name: Unit + CLI smoke tests
|
|
28
32
|
run: pytest -q
|
|
29
33
|
|
|
@@ -11,6 +11,60 @@ Releases before 0.8.0 are summarized only in the git log.
|
|
|
11
11
|
|
|
12
12
|
_(nothing yet)_
|
|
13
13
|
|
|
14
|
+
## [0.24.2] — 2026-09-13
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **`yt-ocr` on a clean `[ocr]` install** ([[issue:77]], [[task:2831]]). The extra
|
|
19
|
+
declared `rapidocr>=3.8`, so the resolver picked 3.9.2 — where the bundled
|
|
20
|
+
defaults moved to PP-OCRv6 with `model_type=small`, while PP-OCRv5 (which
|
|
21
|
+
`yt_tools/ocr.py` pins) ships `mobile`/`server` only. Engine construction died
|
|
22
|
+
with `error: Invalid OCR configuration`, i.e. the install did exactly what the
|
|
23
|
+
metadata asked and the first `yt-ocr` run failed. `[ocr]` now reads
|
|
24
|
+
`rapidocr>=3.8,<3.9`, with the reason in the metadata, and the bound is lifted
|
|
25
|
+
only in the change that teaches `ocr.py` the 3.9 model matrix.
|
|
26
|
+
- **The `pipx inject` hint installed the version the extra excludes.** The
|
|
27
|
+
refusal, `doctor`'s `next_step` and the README all printed
|
|
28
|
+
`pipx inject yt-tools-cli rapidocr onnxruntime` — an inject resolves from PyPI
|
|
29
|
+
directly, so following the message reproduced the bug above. The hint now
|
|
30
|
+
carries the same bound as the extra (one constant, pinned equal by test) and
|
|
31
|
+
double-quotes every spec, because `>` and `<` are redirections in bash,
|
|
32
|
+
cmd.exe and PowerShell alike; `doctor` formats its multi-extra command through
|
|
33
|
+
the same helper instead of re-joining the list.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **A real-engine test for the `[ocr]` extra** (`tests/test_ocr_engine.py`).
|
|
38
|
+
Every yt-ocr integration test mocked `_load_engine`, so the whole suite stayed
|
|
39
|
+
green while the published extra was broken — the mocked seam was the broken
|
|
40
|
+
seam. The new test builds the engine through `_load_engine` and OCRs a frame
|
|
41
|
+
it renders itself (OpenCV's built-in Hershey font, no binary fixture), and the
|
|
42
|
+
CI `pytest` job installs `[full,ocr,test]` so it cannot pass by being skipped.
|
|
43
|
+
Verified against both ends: rapidocr 3.9.2 → the `Invalid OCR configuration`
|
|
44
|
+
failure above, 3.8.4 → text read back and a non-empty `ocr.md`.
|
|
45
|
+
- **A guard on the skill description length.** `using-yt-tools`'s description was
|
|
46
|
+
2511 characters as the YAML scalar (2261 as the host measured it) against a
|
|
47
|
+
1024 cap — the part the host matches triggers on, so a harness reported the
|
|
48
|
+
skill as conflicted. Trimmed to 987 with prose cut and every flow name and
|
|
49
|
+
trigger phrase kept; the new test fails on growth and on a trim that drops a
|
|
50
|
+
flow's command name.
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- **Docs corrected against a real machine install** ([[wiki:3595]]): `[full]` is
|
|
55
|
+
a flow default, **not** "everything" — it excludes `[ocr]` and carries no
|
|
56
|
+
`bpm-detector` VCS dependency, so chord progression / structural segments need
|
|
57
|
+
the inject and read `n/a` without it; the OCR section names the `rapidocr`
|
|
58
|
+
bound; the standalone install block offers
|
|
59
|
+
`pipx install "yt-tools-cli[full,ocr]"` (metadata-resolved) alongside the
|
|
60
|
+
bounded inject, and pins `0.24.2`; `yt-frames` is documented as requiring
|
|
61
|
+
`--timestamps` or `--mode {interval|scene}`, and `yt-ocr` as reading
|
|
62
|
+
`./yt-cache/<vid>/frames/` unless given `--timestamps`.
|
|
63
|
+
- **The OCR model path.** The README and skill claimed the PP-OCRv5 weights land
|
|
64
|
+
in `~/.cache/rapidocr/`. In RapidOCR 3.8.x they land in the installed package's
|
|
65
|
+
own `rapidocr/models/` directory — found while running the clean-venv
|
|
66
|
+
acceptance, so the claim is replaced by the measured one.
|
|
67
|
+
|
|
14
68
|
## [0.24.1] — 2026-09-13
|
|
15
69
|
|
|
16
70
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: yt-tools-cli
|
|
3
|
-
Version: 0.24.
|
|
3
|
+
Version: 0.24.2
|
|
4
4
|
Summary: CLI suite for iterative YouTube watching by LLM agents: transcript with [mm:ss] anchors, targeted frame extraction, FFT audio analysis (BPM, key, chord progression, spectral features), and OCR over cached frames for silent-with-text videos.
|
|
5
5
|
Project-URL: Homepage, https://github.com/kzntsv-dev/yt-tools
|
|
6
6
|
Project-URL: Repository, https://github.com/kzntsv-dev/yt-tools
|
|
@@ -35,7 +35,7 @@ Requires-Dist: matplotlib>=3.8; extra == 'full'
|
|
|
35
35
|
Requires-Dist: scenedetect[opencv]>=0.6.4; extra == 'full'
|
|
36
36
|
Provides-Extra: ocr
|
|
37
37
|
Requires-Dist: onnxruntime>=1.18; extra == 'ocr'
|
|
38
|
-
Requires-Dist: rapidocr
|
|
38
|
+
Requires-Dist: rapidocr<3.9,>=3.8; extra == 'ocr'
|
|
39
39
|
Provides-Extra: test
|
|
40
40
|
Requires-Dist: pytest>=7.0; extra == 'test'
|
|
41
41
|
Description-Content-Type: text/markdown
|
|
@@ -69,7 +69,9 @@ has to guess where the artefact landed:
|
|
|
69
69
|
text (schematic labels, chord matrices, parameter walkthroughs). Needs
|
|
70
70
|
the `[ocr]` extra.
|
|
71
71
|
- `yt-listen` — FFT audio analysis: per-timestamp clip + mel-spectrogram PNG +
|
|
72
|
-
features `.md` with BPM, key, chord progression
|
|
72
|
+
features `.md` with BPM, key, and spectral statistics (chord progression and
|
|
73
|
+
structural segments come from the optional `bpm-detector` enrichment —
|
|
74
|
+
[see below](#installing-bpm-detector); without it those rows read `n/a`).
|
|
73
75
|
- `yt-watch` — combined transcript + scene-frames in one `.md` with
|
|
74
76
|
`` sidecar embeds.
|
|
75
77
|
- `yt-tools cache list | prune` — manage the source-mp4 cache.
|
|
@@ -77,8 +79,8 @@ has to guess where the artefact landed:
|
|
|
77
79
|
## Installation
|
|
78
80
|
|
|
79
81
|
`yt-tools` v1 ships **via a Claude Code plugin** (recommended for agent
|
|
80
|
-
workflows)
|
|
81
|
-
|
|
82
|
+
workflows), **from PyPI** as the distribution `yt-tools-cli`, or **directly from
|
|
83
|
+
this Git repository** (a checkout, or anyone tracking `master`).
|
|
82
84
|
|
|
83
85
|
### As a Claude Code plugin (recommended for agent workflows)
|
|
84
86
|
|
|
@@ -137,14 +139,23 @@ every console command stay `yt-tools`, and the import name stays `yt_tools`.
|
|
|
137
139
|
python -m pip install --user pipx
|
|
138
140
|
python -m pipx ensurepath # adds ~/.local/bin to PATH; restart shell after
|
|
139
141
|
|
|
140
|
-
# 2. install from PyPI — core + [frames] + [audio]
|
|
142
|
+
# 2. install from PyPI — [full] = core + [frames] + [audio]
|
|
143
|
+
# NOTE: [full] does NOT include OCR (see the next command)
|
|
141
144
|
pipx install "yt-tools-cli[full]"
|
|
142
145
|
|
|
143
146
|
# reproducible: pin the exact version
|
|
144
|
-
pipx install "yt-tools-cli[full]==0.
|
|
145
|
-
|
|
146
|
-
#
|
|
147
|
-
|
|
147
|
+
pipx install "yt-tools-cli[full]==0.24.2"
|
|
148
|
+
|
|
149
|
+
# OCR is always an explicit extra (its ONNX model is ~10 MB, fetched lazily).
|
|
150
|
+
# Preferred: name it at install time, so pip resolves it from the metadata —
|
|
151
|
+
# bounds included — instead of from PyPI's "latest":
|
|
152
|
+
pipx install "yt-tools-cli[full,ocr]"
|
|
153
|
+
|
|
154
|
+
# Already installed? Widen the venv in place. Quote the specs: `>` and `<` are
|
|
155
|
+
# redirections in bash, cmd.exe and PowerShell. `rapidocr` is capped below 3.9
|
|
156
|
+
# because 3.9 moved the bundled defaults to PP-OCRv6, where the PP-OCRv5 params
|
|
157
|
+
# yt-ocr builds no longer resolve (see the OCR section).
|
|
158
|
+
pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"
|
|
148
159
|
```
|
|
149
160
|
|
|
150
161
|
`uv` users: `uv tool install "yt-tools-cli[full]"`, or for a one-off run
|
|
@@ -157,6 +168,11 @@ pipx inject yt-tools-cli rapidocr onnxruntime
|
|
|
157
168
|
pipx install 'git+https://github.com/kzntsv-dev/yt-tools.git#egg=yt-tools-cli[full]'
|
|
158
169
|
```
|
|
159
170
|
|
|
171
|
+
> **Which Python.** The ceiling is real: `requires-python` is `>=3.10,<3.13`, so
|
|
172
|
+
> on a machine whose only interpreter is 3.13 the install is refused by
|
|
173
|
+
> `pip`/`pipx` (`librosa` 3.13 friction holds the ceiling — see Requirements).
|
|
174
|
+
> Install a 3.12 interpreter, or point pipx at one: `pipx install --python 3.12 …`.
|
|
175
|
+
|
|
160
176
|
> **Changing the extras of an existing install.** `pipx uninstall yt-tools-cli`
|
|
161
177
|
> first, then install the new extras set. `pipx install --force` on an existing
|
|
162
178
|
> venv can leave the new extras half-installed and resolve against the old pin
|
|
@@ -176,12 +192,20 @@ matplotlib. Heavier stacks are opt-in, per flow.
|
|
|
176
192
|
| core (no extras) | `youtube-transcript-api`, `yt-dlp` | `yt-transcript`, `yt-meta`, `yt-comments`, `yt-search`, `yt-tools cache`; `yt-frames --timestamps` / `--mode interval` (without near-duplicate dedup) |
|
|
177
193
|
| `[frames]` | `scenedetect[opencv]` (OpenCV) | `yt-frames --mode scene`, `yt-watch`, and dedup in the other frame modes |
|
|
178
194
|
| `[audio]` | `librosa`, `matplotlib` | `yt-listen` (spectral features, spectrogram, BPM/key) |
|
|
179
|
-
| `[ocr]` | `rapidocr`, `onnxruntime` | `yt-ocr` (PP-OCRv5; ONNX model downloaded on first run) |
|
|
180
|
-
| `[full]` | core + `[frames]` + `[audio]` |
|
|
195
|
+
| `[ocr]` | `rapidocr>=3.8,<3.9`, `onnxruntime` | `yt-ocr` (PP-OCRv5; ONNX model downloaded on first run) |
|
|
196
|
+
| `[full]` | core + `[frames]` + `[audio]` | the plugin default: every flow above **except `yt-ocr`** |
|
|
197
|
+
|
|
198
|
+
`[full]` is a *flow* default, not "everything". Two things stay outside it on
|
|
199
|
+
purpose: `[ocr]` (the ~10 MB ONNX model is always an explicit opt-in) and
|
|
200
|
+
`bpm-detector` (not on PyPI — see below). If you want one install that runs every
|
|
201
|
+
CLI in this README, name both: `pipx install "yt-tools-cli[full,ocr]"` plus the
|
|
202
|
+
inject below.
|
|
181
203
|
|
|
182
204
|
#### Installing bpm-detector
|
|
183
205
|
|
|
184
|
-
`[full]` deliberately stops at `[frames]` + `[audio]
|
|
206
|
+
`[full]` deliberately stops at `[frames]` + `[audio]`: it carries **no VCS
|
|
207
|
+
dependency**, so nothing a user installs from PyPI hits a Git remote (the plugin
|
|
208
|
+
hook injects this one separately, best-effort). The optional
|
|
185
209
|
[`bpm-detector`](https://github.com/libraz/bpm-detector) enrichment (chord
|
|
186
210
|
progression, structural segments, refined BPM, a confidence-scored key per
|
|
187
211
|
timestamp) is not on PyPI, so it can only be a PEP 508 *direct reference* — and
|
|
@@ -444,11 +468,17 @@ models via `onnxruntime`).
|
|
|
444
468
|
```bash
|
|
445
469
|
# 1) extract frames first (lower scene threshold catches overlay fades
|
|
446
470
|
# within the same shot; or use --mode interval for a denser sample)
|
|
471
|
+
# yt-frames needs a mode: it will not guess between interval and scene.
|
|
472
|
+
# (--mode scene needs the [frames] extra; --mode interval does not.)
|
|
447
473
|
yt-frames URL --mode scene --scene-threshold 12
|
|
448
474
|
# or:
|
|
449
475
|
yt-frames URL --mode interval --interval 15s
|
|
476
|
+
# or, for named moments — no mode flag, no [frames] extra:
|
|
477
|
+
yt-frames URL --timestamps 1:30,2:45
|
|
450
478
|
|
|
451
479
|
# 2) OCR every cached frame → markdown with [mm:ss] anchors
|
|
480
|
+
# yt-ocr reads ./yt-cache/<vid>/frames/ — it extracts nothing on its own
|
|
481
|
+
# unless you give it --timestamps (which calls the yt-frames helper for you).
|
|
452
482
|
yt-ocr URL
|
|
453
483
|
# → ./yt-cache/<vid>/ocr.md
|
|
454
484
|
|
|
@@ -467,16 +497,26 @@ per detected text region, an explicit `_(no text detected)_` marker for
|
|
|
467
497
|
frames where the engine returned nothing (so an agent can tell the frame
|
|
468
498
|
was checked vs. silently omitted).
|
|
469
499
|
|
|
470
|
-
> **Install the `[ocr]` extra.** RapidOCR and `onnxruntime` are **not**
|
|
471
|
-
> in
|
|
472
|
-
> run:
|
|
500
|
+
> **Install the `[ocr]` extra.** RapidOCR and `onnxruntime` are **not** in the
|
|
501
|
+
> core install — and **not** in `[full]` either. If `yt-ocr` exits with the
|
|
502
|
+
> missing-extra hint, run:
|
|
473
503
|
> ```bash
|
|
474
|
-
> pipx inject yt-tools-cli rapidocr onnxruntime
|
|
504
|
+
> pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"
|
|
475
505
|
> # or for non-pipx setups:
|
|
476
506
|
> pip install 'yt-tools-cli[ocr]'
|
|
477
507
|
> ```
|
|
478
|
-
> First run with a given `--language` lazy-downloads
|
|
479
|
-
>
|
|
508
|
+
> First run with a given `--language` lazy-downloads the PP-OCRv5 ONNX models
|
|
509
|
+
> (~8 MB for the english recognizer, plus the shared detector). In RapidOCR 3.8.x
|
|
510
|
+
> they land in the installed package's own `rapidocr/models/` directory, not in
|
|
511
|
+
> `~/.cache/` — verified on a clean venv.
|
|
512
|
+
>
|
|
513
|
+
> The `rapidocr` bound is not decoration: 3.9 moved the bundled defaults to
|
|
514
|
+
> PP-OCRv6 with `model_type=small`, and PP-OCRv5 ships `mobile`/`server` only —
|
|
515
|
+
> the v5 detector the CLI asks for resolves to nothing there, so a 3.9 install
|
|
516
|
+
> fails at engine construction with `error: Invalid OCR configuration`. The
|
|
517
|
+
> `[ocr]` extra carries the bound, and so does the `pipx inject` line above: an
|
|
518
|
+
> inject resolves from PyPI directly and would otherwise install the version the
|
|
519
|
+
> extra exists to exclude.
|
|
480
520
|
|
|
481
521
|
### Cache hygiene
|
|
482
522
|
|
|
@@ -552,8 +592,10 @@ scripts trivial.
|
|
|
552
592
|
- **Scene threshold:** 27 (PySceneDetect `ContentDetector` default; lower =
|
|
553
593
|
more sensitive).
|
|
554
594
|
- **`yt-listen` defaults:** 8-second clip per timestamp, mel-spectrogram at
|
|
555
|
-
default `librosa` settings
|
|
556
|
-
installed
|
|
595
|
+
default `librosa` settings; BPM/key/spectral features when the `[audio]` extra
|
|
596
|
+
is installed, chord progression and structural segments only when
|
|
597
|
+
`bpm-detector` has been injected as well — on a plain `[full]` install those
|
|
598
|
+
rows are `n/a`, not silently absent.
|
|
557
599
|
|
|
558
600
|
## Requirements
|
|
559
601
|
|
|
@@ -27,7 +27,9 @@ has to guess where the artefact landed:
|
|
|
27
27
|
text (schematic labels, chord matrices, parameter walkthroughs). Needs
|
|
28
28
|
the `[ocr]` extra.
|
|
29
29
|
- `yt-listen` — FFT audio analysis: per-timestamp clip + mel-spectrogram PNG +
|
|
30
|
-
features `.md` with BPM, key, chord progression
|
|
30
|
+
features `.md` with BPM, key, and spectral statistics (chord progression and
|
|
31
|
+
structural segments come from the optional `bpm-detector` enrichment —
|
|
32
|
+
[see below](#installing-bpm-detector); without it those rows read `n/a`).
|
|
31
33
|
- `yt-watch` — combined transcript + scene-frames in one `.md` with
|
|
32
34
|
`` sidecar embeds.
|
|
33
35
|
- `yt-tools cache list | prune` — manage the source-mp4 cache.
|
|
@@ -35,8 +37,8 @@ has to guess where the artefact landed:
|
|
|
35
37
|
## Installation
|
|
36
38
|
|
|
37
39
|
`yt-tools` v1 ships **via a Claude Code plugin** (recommended for agent
|
|
38
|
-
workflows)
|
|
39
|
-
|
|
40
|
+
workflows), **from PyPI** as the distribution `yt-tools-cli`, or **directly from
|
|
41
|
+
this Git repository** (a checkout, or anyone tracking `master`).
|
|
40
42
|
|
|
41
43
|
### As a Claude Code plugin (recommended for agent workflows)
|
|
42
44
|
|
|
@@ -95,14 +97,23 @@ every console command stay `yt-tools`, and the import name stays `yt_tools`.
|
|
|
95
97
|
python -m pip install --user pipx
|
|
96
98
|
python -m pipx ensurepath # adds ~/.local/bin to PATH; restart shell after
|
|
97
99
|
|
|
98
|
-
# 2. install from PyPI — core + [frames] + [audio]
|
|
100
|
+
# 2. install from PyPI — [full] = core + [frames] + [audio]
|
|
101
|
+
# NOTE: [full] does NOT include OCR (see the next command)
|
|
99
102
|
pipx install "yt-tools-cli[full]"
|
|
100
103
|
|
|
101
104
|
# reproducible: pin the exact version
|
|
102
|
-
pipx install "yt-tools-cli[full]==0.
|
|
103
|
-
|
|
104
|
-
#
|
|
105
|
-
|
|
105
|
+
pipx install "yt-tools-cli[full]==0.24.2"
|
|
106
|
+
|
|
107
|
+
# OCR is always an explicit extra (its ONNX model is ~10 MB, fetched lazily).
|
|
108
|
+
# Preferred: name it at install time, so pip resolves it from the metadata —
|
|
109
|
+
# bounds included — instead of from PyPI's "latest":
|
|
110
|
+
pipx install "yt-tools-cli[full,ocr]"
|
|
111
|
+
|
|
112
|
+
# Already installed? Widen the venv in place. Quote the specs: `>` and `<` are
|
|
113
|
+
# redirections in bash, cmd.exe and PowerShell. `rapidocr` is capped below 3.9
|
|
114
|
+
# because 3.9 moved the bundled defaults to PP-OCRv6, where the PP-OCRv5 params
|
|
115
|
+
# yt-ocr builds no longer resolve (see the OCR section).
|
|
116
|
+
pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"
|
|
106
117
|
```
|
|
107
118
|
|
|
108
119
|
`uv` users: `uv tool install "yt-tools-cli[full]"`, or for a one-off run
|
|
@@ -115,6 +126,11 @@ pipx inject yt-tools-cli rapidocr onnxruntime
|
|
|
115
126
|
pipx install 'git+https://github.com/kzntsv-dev/yt-tools.git#egg=yt-tools-cli[full]'
|
|
116
127
|
```
|
|
117
128
|
|
|
129
|
+
> **Which Python.** The ceiling is real: `requires-python` is `>=3.10,<3.13`, so
|
|
130
|
+
> on a machine whose only interpreter is 3.13 the install is refused by
|
|
131
|
+
> `pip`/`pipx` (`librosa` 3.13 friction holds the ceiling — see Requirements).
|
|
132
|
+
> Install a 3.12 interpreter, or point pipx at one: `pipx install --python 3.12 …`.
|
|
133
|
+
|
|
118
134
|
> **Changing the extras of an existing install.** `pipx uninstall yt-tools-cli`
|
|
119
135
|
> first, then install the new extras set. `pipx install --force` on an existing
|
|
120
136
|
> venv can leave the new extras half-installed and resolve against the old pin
|
|
@@ -134,12 +150,20 @@ matplotlib. Heavier stacks are opt-in, per flow.
|
|
|
134
150
|
| core (no extras) | `youtube-transcript-api`, `yt-dlp` | `yt-transcript`, `yt-meta`, `yt-comments`, `yt-search`, `yt-tools cache`; `yt-frames --timestamps` / `--mode interval` (without near-duplicate dedup) |
|
|
135
151
|
| `[frames]` | `scenedetect[opencv]` (OpenCV) | `yt-frames --mode scene`, `yt-watch`, and dedup in the other frame modes |
|
|
136
152
|
| `[audio]` | `librosa`, `matplotlib` | `yt-listen` (spectral features, spectrogram, BPM/key) |
|
|
137
|
-
| `[ocr]` | `rapidocr`, `onnxruntime` | `yt-ocr` (PP-OCRv5; ONNX model downloaded on first run) |
|
|
138
|
-
| `[full]` | core + `[frames]` + `[audio]` |
|
|
153
|
+
| `[ocr]` | `rapidocr>=3.8,<3.9`, `onnxruntime` | `yt-ocr` (PP-OCRv5; ONNX model downloaded on first run) |
|
|
154
|
+
| `[full]` | core + `[frames]` + `[audio]` | the plugin default: every flow above **except `yt-ocr`** |
|
|
155
|
+
|
|
156
|
+
`[full]` is a *flow* default, not "everything". Two things stay outside it on
|
|
157
|
+
purpose: `[ocr]` (the ~10 MB ONNX model is always an explicit opt-in) and
|
|
158
|
+
`bpm-detector` (not on PyPI — see below). If you want one install that runs every
|
|
159
|
+
CLI in this README, name both: `pipx install "yt-tools-cli[full,ocr]"` plus the
|
|
160
|
+
inject below.
|
|
139
161
|
|
|
140
162
|
#### Installing bpm-detector
|
|
141
163
|
|
|
142
|
-
`[full]` deliberately stops at `[frames]` + `[audio]
|
|
164
|
+
`[full]` deliberately stops at `[frames]` + `[audio]`: it carries **no VCS
|
|
165
|
+
dependency**, so nothing a user installs from PyPI hits a Git remote (the plugin
|
|
166
|
+
hook injects this one separately, best-effort). The optional
|
|
143
167
|
[`bpm-detector`](https://github.com/libraz/bpm-detector) enrichment (chord
|
|
144
168
|
progression, structural segments, refined BPM, a confidence-scored key per
|
|
145
169
|
timestamp) is not on PyPI, so it can only be a PEP 508 *direct reference* — and
|
|
@@ -402,11 +426,17 @@ models via `onnxruntime`).
|
|
|
402
426
|
```bash
|
|
403
427
|
# 1) extract frames first (lower scene threshold catches overlay fades
|
|
404
428
|
# within the same shot; or use --mode interval for a denser sample)
|
|
429
|
+
# yt-frames needs a mode: it will not guess between interval and scene.
|
|
430
|
+
# (--mode scene needs the [frames] extra; --mode interval does not.)
|
|
405
431
|
yt-frames URL --mode scene --scene-threshold 12
|
|
406
432
|
# or:
|
|
407
433
|
yt-frames URL --mode interval --interval 15s
|
|
434
|
+
# or, for named moments — no mode flag, no [frames] extra:
|
|
435
|
+
yt-frames URL --timestamps 1:30,2:45
|
|
408
436
|
|
|
409
437
|
# 2) OCR every cached frame → markdown with [mm:ss] anchors
|
|
438
|
+
# yt-ocr reads ./yt-cache/<vid>/frames/ — it extracts nothing on its own
|
|
439
|
+
# unless you give it --timestamps (which calls the yt-frames helper for you).
|
|
410
440
|
yt-ocr URL
|
|
411
441
|
# → ./yt-cache/<vid>/ocr.md
|
|
412
442
|
|
|
@@ -425,16 +455,26 @@ per detected text region, an explicit `_(no text detected)_` marker for
|
|
|
425
455
|
frames where the engine returned nothing (so an agent can tell the frame
|
|
426
456
|
was checked vs. silently omitted).
|
|
427
457
|
|
|
428
|
-
> **Install the `[ocr]` extra.** RapidOCR and `onnxruntime` are **not**
|
|
429
|
-
> in
|
|
430
|
-
> run:
|
|
458
|
+
> **Install the `[ocr]` extra.** RapidOCR and `onnxruntime` are **not** in the
|
|
459
|
+
> core install — and **not** in `[full]` either. If `yt-ocr` exits with the
|
|
460
|
+
> missing-extra hint, run:
|
|
431
461
|
> ```bash
|
|
432
|
-
> pipx inject yt-tools-cli rapidocr onnxruntime
|
|
462
|
+
> pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"
|
|
433
463
|
> # or for non-pipx setups:
|
|
434
464
|
> pip install 'yt-tools-cli[ocr]'
|
|
435
465
|
> ```
|
|
436
|
-
> First run with a given `--language` lazy-downloads
|
|
437
|
-
>
|
|
466
|
+
> First run with a given `--language` lazy-downloads the PP-OCRv5 ONNX models
|
|
467
|
+
> (~8 MB for the english recognizer, plus the shared detector). In RapidOCR 3.8.x
|
|
468
|
+
> they land in the installed package's own `rapidocr/models/` directory, not in
|
|
469
|
+
> `~/.cache/` — verified on a clean venv.
|
|
470
|
+
>
|
|
471
|
+
> The `rapidocr` bound is not decoration: 3.9 moved the bundled defaults to
|
|
472
|
+
> PP-OCRv6 with `model_type=small`, and PP-OCRv5 ships `mobile`/`server` only —
|
|
473
|
+
> the v5 detector the CLI asks for resolves to nothing there, so a 3.9 install
|
|
474
|
+
> fails at engine construction with `error: Invalid OCR configuration`. The
|
|
475
|
+
> `[ocr]` extra carries the bound, and so does the `pipx inject` line above: an
|
|
476
|
+
> inject resolves from PyPI directly and would otherwise install the version the
|
|
477
|
+
> extra exists to exclude.
|
|
438
478
|
|
|
439
479
|
### Cache hygiene
|
|
440
480
|
|
|
@@ -510,8 +550,10 @@ scripts trivial.
|
|
|
510
550
|
- **Scene threshold:** 27 (PySceneDetect `ContentDetector` default; lower =
|
|
511
551
|
more sensitive).
|
|
512
552
|
- **`yt-listen` defaults:** 8-second clip per timestamp, mel-spectrogram at
|
|
513
|
-
default `librosa` settings
|
|
514
|
-
installed
|
|
553
|
+
default `librosa` settings; BPM/key/spectral features when the `[audio]` extra
|
|
554
|
+
is installed, chord progression and structural segments only when
|
|
555
|
+
`bpm-detector` has been injected as well — on a plain `[full]` install those
|
|
556
|
+
rows are `n/a`, not silently absent.
|
|
515
557
|
|
|
516
558
|
## Requirements
|
|
517
559
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "yt-tools-cli"
|
|
7
|
-
version = "0.24.
|
|
7
|
+
version = "0.24.2"
|
|
8
8
|
description = "CLI suite for iterative YouTube watching by LLM agents: transcript with [mm:ss] anchors, targeted frame extraction, FFT audio analysis (BPM, key, chord progression, spectral features), and OCR over cached frames for silent-with-text videos."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10,<3.13"
|
|
@@ -79,7 +79,18 @@ full = [
|
|
|
79
79
|
"matplotlib>=3.8",
|
|
80
80
|
]
|
|
81
81
|
ocr = [
|
|
82
|
-
|
|
82
|
+
# Capped below 3.9 on purpose ([[task:2831]], [[issue:77]]). 3.9 moved the
|
|
83
|
+
# bundled defaults to ``ocr_version=PP-OCRv6, model_type=small``; ocr.py
|
|
84
|
+
# overrides the ocr_version to PP-OCRv5 but leaves model_type at that
|
|
85
|
+
# default, and PP-OCRv5 ships ``mobile``/``server`` only — so "PP-OCRv5 small"
|
|
86
|
+
# resolves to nothing (the lenient prefix fallback finds no candidate either)
|
|
87
|
+
# and RapidOCR raises ``error: Invalid OCR configuration`` at construction.
|
|
88
|
+
# A clean ``pipx install "yt-tools-cli[ocr]"`` therefore installed 3.9.2 and
|
|
89
|
+
# every ``yt-ocr`` run died. Lift the cap only in the change that teaches
|
|
90
|
+
# ocr.py the 3.9 model matrix (pin ``model_type`` explicitly, then re-check
|
|
91
|
+
# every ``--language``) — tests/test_ocr_engine.py drives the real engine and
|
|
92
|
+
# fails if the pin comes off alone.
|
|
93
|
+
"rapidocr>=3.8,<3.9",
|
|
83
94
|
"onnxruntime>=1.18",
|
|
84
95
|
]
|
|
85
96
|
test = ["pytest>=7.0"]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: using-yt-tools
|
|
3
|
-
version: 0.24.
|
|
4
|
-
description: Seven flows
|
|
3
|
+
version: 0.24.2
|
|
4
|
+
description: Seven YouTube flows, one skill. Discovery `yt-search` ("find a video about X", «найди видео про X», «поищи туториал»). Summary / exploration `yt-transcript` — [mm:ss] anchors, then pick moments and run `yt-frames` ("what's in this video", «о чём видео»). Targeted frames directly by timestamp ("show frame at N", «покажи кадр на N»). Audio FFT `yt-listen` — BPM, key, chords, structure ("what's the BPM", «спектрограмма», «тональность видео»). Metadata `yt-meta` — description, chapters, most-replayed ("video description", «что в описании», «покажи главы»). Comments `yt-comments` ("top comments", «комменты под роликом»). OCR over cached frames `yt-ocr` — silent-with-text fallback ("read text from frames", "captions burned in", «прочти текст с кадров»). Any youtube.com URL. Install once per machine with pipx (`pipx install 'yt-tools-cli[full]'`); `yt-tools doctor` is the read-only preflight that names what is missing. YouTube-only — Vimeo / Twitch / local files need other tools.
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# using-yt-tools
|
|
@@ -106,13 +106,15 @@ Seven distinct flows, picked by user intent:
|
|
|
106
106
|
→ Read `<vid>/ocr.md` as a transcript substitute (per-timestamp
|
|
107
107
|
`[mm:ss]` blocks, same paradigm) → continue into Flow B at the
|
|
108
108
|
interesting timestamps if you need the original frames.
|
|
109
|
-
- **Requires the `[ocr]` extra** — `rapidocr` + `onnxruntime
|
|
110
|
-
missing on the running yt-tools install, `yt-ocr` exits nonzero
|
|
111
|
-
both install hints (`pipx inject yt-tools-cli rapidocr
|
|
112
|
-
`pip install 'yt-tools-cli[ocr]'`). The plugin's
|
|
113
|
-
installs `[full]` (core + `[frames]` + `[audio]`, plus a
|
|
114
|
-
best-effort `bpm-detector` inject) only, **not** `[ocr]` — first run
|
|
115
|
-
|
|
109
|
+
- **Requires the `[ocr]` extra** — `rapidocr` + `onnxruntime`, **not** part of
|
|
110
|
+
`[full]`. If missing on the running yt-tools install, `yt-ocr` exits nonzero
|
|
111
|
+
with both install hints (`pipx inject yt-tools-cli "rapidocr>=3.8,<3.9"
|
|
112
|
+
"onnxruntime>=1.18"` / `pip install 'yt-tools-cli[ocr]'`). The plugin's
|
|
113
|
+
SessionStart hook installs `[full]` (core + `[frames]` + `[audio]`, plus a
|
|
114
|
+
best-effort `bpm-detector` inject) only, **not** `[ocr]` — first run of Flow F
|
|
115
|
+
on a machine may need the inject step. `yt-ocr` reads
|
|
116
|
+
`./yt-cache/<vid>/frames/`, so either run `yt-frames` first or pass
|
|
117
|
+
`--timestamps` (which extracts for you).
|
|
116
118
|
- Trigger phrases: "read text from frames", "OCR this video", "what
|
|
117
119
|
does the caption say", "extract overlay text", "captions burned in",
|
|
118
120
|
"silent video with text on screen", «прочти текст с кадров», «OCR
|
|
@@ -138,14 +140,22 @@ Install once per machine — one command, the same in every harness. The
|
|
|
138
140
|
commands stay `yt-tools`; the import name is `yt_tools`):
|
|
139
141
|
|
|
140
142
|
```bash
|
|
141
|
-
# from PyPI — core + [frames] + [audio]
|
|
143
|
+
# from PyPI — [full] = core + [frames] + [audio]. NOT OCR (see below).
|
|
142
144
|
pipx install "yt-tools-cli[full]"
|
|
143
145
|
|
|
144
146
|
# core only (transcript / meta / comments / search / cache)
|
|
145
147
|
pipx install yt-tools-cli
|
|
146
148
|
|
|
147
149
|
# reproducible: pin the exact version
|
|
148
|
-
pipx install "yt-tools-cli[full]==0.
|
|
150
|
+
pipx install "yt-tools-cli[full]==0.24.2"
|
|
151
|
+
|
|
152
|
+
# OCR (explicit extra, ~10 MB model fetched on first run). Naming it in the
|
|
153
|
+
# install lets pip resolve it from the metadata, bounds included:
|
|
154
|
+
pipx install "yt-tools-cli[full,ocr]"
|
|
155
|
+
# already installed? widen in place — `rapidocr` is capped below 3.9, where the
|
|
156
|
+
# PP-OCRv5 params yt-ocr builds no longer resolve (quote the specs: `>`/`<` are
|
|
157
|
+
# redirections in bash, cmd.exe and PowerShell):
|
|
158
|
+
pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"
|
|
149
159
|
```
|
|
150
160
|
|
|
151
161
|
From a checkout instead of PyPI (the plugin path, or tracking `master`):
|
|
@@ -154,7 +164,8 @@ From a checkout instead of PyPI (the plugin path, or tracking `master`):
|
|
|
154
164
|
pipx install 'git+https://github.com/kzntsv-dev/yt-tools.git#egg=yt-tools-cli[full]'
|
|
155
165
|
```
|
|
156
166
|
|
|
157
|
-
The `[full]` extra is core + `[frames]` + `[audio]
|
|
167
|
+
The `[full]` extra is core + `[frames]` + `[audio]` — a flow default, not
|
|
168
|
+
"everything": OCR and `bpm-detector` are both outside it. Chord progression,
|
|
158
169
|
structural segments and the refined BPM/key in `yt-listen` come from
|
|
159
170
|
`bpm-detector`, which is **not on PyPI** and therefore cannot live in the
|
|
160
171
|
published metadata — the Python package index rejects direct VCS references.
|
|
@@ -165,8 +176,7 @@ pipx inject yt-tools-cli "bpm-detector @ git+https://github.com/libraz/bpm-detec
|
|
|
165
176
|
```
|
|
166
177
|
|
|
167
178
|
Without it `yt-listen` still runs (librosa-only BPM/key, unfilled sections marked
|
|
168
|
-
`n/a`).
|
|
169
|
-
onnxruntime`.
|
|
179
|
+
`n/a`).
|
|
170
180
|
|
|
171
181
|
Some harnesses do this for you: if your host ships the bundled yt-tools plugin
|
|
172
182
|
(Claude Code does), its `SessionStart` hook pipx-installs the `[full]` set from
|
|
@@ -522,9 +532,9 @@ All failures abort cleanly; never leave a half-finished state.
|
|
|
522
532
|
| `Subtitles disabled` / `no captions in the requested language(s)` / `the transcript API returned nothing usable (ParseError: …)` from `yt-transcript` | Captions are absent (channel-disabled, wrong `--lang`, or YouTube answered with an empty body — the last one used to surface as a bare `no element found: line 1, column 0`). The message names the reason and both fallbacks. | Fatal for Flow A — tell the user the reason, and switch: Flow C (`yt-listen`) for audio, Flow F (`yt-ocr`) for text burned into the frames. Try another `--lang` first only when the reason says the *requested language* is missing. `yt-watch` on the same video does **not** fail — it writes a frames-only `watch.md` with the reason in its header, so read the frames and say that the text layer is absent. |
|
|
523
533
|
| Flow B/D — `yt-frames --mode scene requires the [frames] extra` / `yt-watch requires the [frames] extra` | `scenedetect` / `opencv-python` not in the active pipx venv | Exit code 1, nothing was downloaded. Tell the user the command the CLI printed (`pipx inject yt-tools-cli scenedetect opencv-python` or `pip install 'yt-tools-cli[frames]'`). Fallbacks that need no install: Flow D with an explicit `--timestamps` list, or `--mode interval` (which still runs and announces on stderr that near-duplicate dedup was skipped). |
|
|
524
534
|
| Flow C — `yt-listen requires the [audio] extra` | `librosa` / `matplotlib` not in the active pipx venv | Exit code 1. Same shape: print the command the CLI named. Until it is installed, Flow A (transcript) and Flow F (`yt-ocr`) are the alternatives for this video. |
|
|
525
|
-
| Flow F — `yt-ocr requires the [ocr] extra` | `rapidocr` / `onnxruntime` not in the active pipx venv | Run the inject command the CLI printed: `pipx inject yt-tools-cli rapidocr onnxruntime` (or `pip install 'yt-tools-cli[ocr]'` in non-pipx setups). The plugin's SessionStart hook installs `[full]`, not `[ocr]`, so first run typically needs this. |
|
|
535
|
+
| Flow F — `yt-ocr requires the [ocr] extra` | `rapidocr` / `onnxruntime` not in the active pipx venv | Run the inject command the CLI printed: `pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"` (or `pip install 'yt-tools-cli[ocr]'` in non-pipx setups). The bound is load-bearing: an inject resolves from PyPI directly, and 3.9 fails at engine construction. The plugin's SessionStart hook installs `[full]`, not `[ocr]`, so first run typically needs this. |
|
|
526
536
|
| Flow F — `no cached frames in …/frames` | Batch-on-cache mode but `yt-frames` wasn't run yet (or wrote elsewhere) | Either run `yt-frames URL --mode scene --scene-threshold 12` (or `--mode interval --interval 15s`) first, or re-invoke `yt-ocr URL --timestamps T1,T2,…` to let it do extraction inline. |
|
|
527
|
-
| Flow F — RapidOCR model download fails (offline / firewall) | First-run lazy-download of PP-OCRv5 ONNX weights
|
|
537
|
+
| Flow F — RapidOCR model download fails (offline / firewall) | First-run lazy-download of PP-OCRv5 ONNX weights blocked | Print the captured error; let the user re-run with network access. Don't retry. |
|
|
528
538
|
| User passed a non-YouTube URL (Vimeo / Twitch / local mp4) | Out of scope | Stop; say the skill is YouTube-only. |
|
|
529
539
|
|
|
530
540
|
## Side effects
|
|
@@ -538,9 +548,11 @@ All failures abort cleanly; never leave a half-finished state.
|
|
|
538
548
|
- `<video-id>/source.mp4` (≤ 720p; produced by Flows A/B/C unless `--no-cache-source`; **not** by 0/D/E; produced by Flow F **only** when `--timestamps` triggers extraction)
|
|
539
549
|
- `<video-id>/frames/frame_<mmss>.jpg` per extracted frame (also re-read by Flow F); a sub-second moment carries its milliseconds (`frame_0130_250.jpg` = 90.25 s), so two frames in one second never overwrite each other and stdout's `Wrote:` count equals the files on disk
|
|
540
550
|
- `<video-id>/audio/{clip,spectrum,features}_<mmss>.{wav,png,md}` (Flow C)
|
|
541
|
-
- Flow F also lazy-downloads RapidOCR PP-OCRv5 ONNX weights
|
|
542
|
-
|
|
543
|
-
|
|
551
|
+
- Flow F also lazy-downloads RapidOCR PP-OCRv5 ONNX weights on first
|
|
552
|
+
invocation with a given `--language` (~8 MB for the english recognizer plus the
|
|
553
|
+
shared detector). In RapidOCR 3.8.x they land in the installed package's own
|
|
554
|
+
`rapidocr/models/` directory — not in `~/.cache/`, which is where this line
|
|
555
|
+
claimed they went until a clean-venv check (2026-09-13).
|
|
544
556
|
- Network: `yt-dlp` pulls metadata + optionally `source.mp4`;
|
|
545
557
|
`youtube-transcript-api` pulls subs; Flow E additionally paginates the
|
|
546
558
|
comment feed (the one heavy network path — minutes on viral videos).
|
|
@@ -85,11 +85,17 @@ def test_extras_that_are_installed_are_ok(monkeypatch, tmp_path):
|
|
|
85
85
|
assert report.can_proceed is True
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
# [test-modify: test_extra_check_reuses_the_shared_install_command: was
|
|
89
|
+
# `assert fix == "pipx inject yt-tools-cli scenedetect opencv-python"`; is the
|
|
90
|
+
# same via `extras.inject_command_for`; reason: task:2831 — doctor's one command
|
|
91
|
+
# is now formatted by the shared helper (specs double-quoted, bounded specs like
|
|
92
|
+
# the OCR pin safe to paste), so the literal here must move with it.]
|
|
88
93
|
def test_extra_check_reuses_the_shared_install_command(monkeypatch, tmp_path):
|
|
89
94
|
_only_importable(monkeypatch)
|
|
90
95
|
fix = _by_name(doctor.collect(tmp_path, which=_which(BOTH_BINS)))["extra:frames"].fix
|
|
91
96
|
assert fix == extras.inject_command("frames")
|
|
92
|
-
assert fix == "
|
|
97
|
+
assert fix == extras.inject_command_for(["frames"])
|
|
98
|
+
assert fix == 'pipx inject yt-tools-cli "scenedetect" "opencv-python"'
|
|
93
99
|
|
|
94
100
|
|
|
95
101
|
# ---- AC4: a missing binary is the blocker -----------------------------------
|
|
@@ -49,10 +49,16 @@ def _explode(*args, **kwargs):
|
|
|
49
49
|
# ---- one wording, one source of truth ---------------------------------------
|
|
50
50
|
|
|
51
51
|
|
|
52
|
+
# [test-modify: test_frames_refusal_wording_is_exact: was
|
|
53
|
+
# ' pipx inject yt-tools-cli scenedetect opencv-python'; is the same line with
|
|
54
|
+
# each spec double-quoted; reason: task:2831 — the hint now formats specs
|
|
55
|
+
# through one helper (extras.inject_command_for) so a bounded spec like the OCR
|
|
56
|
+
# pin cannot be re-joined unquoted by a caller; `"` is required for those
|
|
57
|
+
# bounds, and mixed quoting per extra would be worse than quoting all.]
|
|
52
58
|
def test_frames_refusal_wording_is_exact():
|
|
53
59
|
assert extras.format_missing_extra("yt-frames --mode scene", "frames") == (
|
|
54
60
|
"yt-frames --mode scene requires the [frames] extra:\n"
|
|
55
|
-
|
|
61
|
+
' pipx inject yt-tools-cli "scenedetect" "opencv-python"\n'
|
|
56
62
|
" # or\n"
|
|
57
63
|
" pip install 'yt-tools-cli[frames]'"
|
|
58
64
|
)
|
|
@@ -61,16 +67,22 @@ def test_frames_refusal_wording_is_exact():
|
|
|
61
67
|
def test_audio_refusal_wording_is_exact():
|
|
62
68
|
assert extras.format_missing_extra("yt-listen", "audio") == (
|
|
63
69
|
"yt-listen requires the [audio] extra:\n"
|
|
64
|
-
|
|
70
|
+
' pipx inject yt-tools-cli "librosa" "matplotlib"\n'
|
|
65
71
|
" # or\n"
|
|
66
72
|
" pip install 'yt-tools-cli[audio]'"
|
|
67
73
|
)
|
|
68
74
|
|
|
69
75
|
|
|
76
|
+
# [test-modify: test_ocr_refusal_wording_is_exact: was
|
|
77
|
+
# ' pipx inject yt-tools-cli rapidocr onnxruntime'; is the same line with the
|
|
78
|
+
# bound specs, each double-quoted; reason: task:2831 — the injected spec now
|
|
79
|
+
# carries the `>=3.8,<3.9` pin (an inject bypasses the extra's metadata, so an
|
|
80
|
+
# unbounded `rapidocr` there installed the broken 3.9.x), and quotes are
|
|
81
|
+
# required because `>`/`<` are redirections in bash, cmd.exe and PowerShell.]
|
|
70
82
|
def test_ocr_refusal_wording_is_exact():
|
|
71
83
|
assert extras.format_missing_extra("yt-ocr", "ocr") == (
|
|
72
84
|
"yt-ocr requires the [ocr] extra:\n"
|
|
73
|
-
|
|
85
|
+
' pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"\n'
|
|
74
86
|
" # or\n"
|
|
75
87
|
" pip install 'yt-tools-cli[ocr]'"
|
|
76
88
|
)
|
|
@@ -91,9 +103,19 @@ def test_ocr_error_message_comes_from_the_shared_formatter(monkeypatch):
|
|
|
91
103
|
assert str(exc_info.value) == extras.format_missing_extra("yt-ocr", "ocr")
|
|
92
104
|
|
|
93
105
|
|
|
106
|
+
# [test-modify: test_ocr_refuses_when_only_onnxruntime_is_missing: was
|
|
107
|
+
# `_only_importable(monkeypatch, "rapidocr")`; is `_poison(monkeypatch,
|
|
108
|
+
# "onnxruntime")`; reason: task:2831 — `_only_importable` patches
|
|
109
|
+
# `extras.import_module`, which `_load_engine` never calls, so the old form only
|
|
110
|
+
# passed because rapidocr itself was absent from the venv and the *first*
|
|
111
|
+
# import raised. With `[ocr]` installed (now the case in CI, and the only way
|
|
112
|
+
# tests/test_ocr_engine.py can run) the real engine got built here — a model
|
|
113
|
+
# download inside a unit test, and on rapidocr 3.9 a ValueError instead of the
|
|
114
|
+
# named refusal. Poisoning the second half of the pair tests what the test
|
|
115
|
+
# says it tests.]
|
|
94
116
|
def test_ocr_refuses_when_only_onnxruntime_is_missing(monkeypatch):
|
|
95
117
|
"""The [ocr] extra is a pair: a half-installed one must not fail later, unnamed."""
|
|
96
|
-
|
|
118
|
+
_poison(monkeypatch, "onnxruntime")
|
|
97
119
|
with pytest.raises(ocr_mod.OcrError) as exc_info:
|
|
98
120
|
ocr_mod._load_engine("en")
|
|
99
121
|
assert str(exc_info.value) == extras.format_missing_extra("yt-ocr", "ocr")
|
|
@@ -159,7 +181,7 @@ def test_degradation_note_for_audio_keeps_the_shared_wording(monkeypatch, capsys
|
|
|
159
181
|
err = capsys.readouterr().err
|
|
160
182
|
assert err.startswith("note: harmonic analysis skipped - the [audio] extra is not installed")
|
|
161
183
|
assert "(continuing without it)." in err
|
|
162
|
-
assert
|
|
184
|
+
assert 'pipx inject yt-tools-cli "librosa" "matplotlib"' in err
|
|
163
185
|
assert "pip install 'yt-tools-cli[audio]'" in err
|
|
164
186
|
assert "requires the [audio] extra" not in err, "a degradation must not read as a blocker"
|
|
165
187
|
|
|
@@ -187,7 +209,7 @@ def test_yt_frames_scene_refuses_without_frames_extra(tmp_path, monkeypatch, cap
|
|
|
187
209
|
err = capsys.readouterr().err
|
|
188
210
|
assert rc != 0, "a missing extra must be a non-zero exit"
|
|
189
211
|
assert "requires the [frames] extra" in err
|
|
190
|
-
assert
|
|
212
|
+
assert 'pipx inject yt-tools-cli "scenedetect" "opencv-python"' in err
|
|
191
213
|
assert "pip install 'yt-tools-cli[frames]'" in err
|
|
192
214
|
assert "Traceback" not in err
|
|
193
215
|
|
|
@@ -229,7 +251,7 @@ def test_yt_listen_refuses_without_audio_extra(tmp_path, monkeypatch, capsys):
|
|
|
229
251
|
err = capsys.readouterr().err
|
|
230
252
|
assert rc != 0
|
|
231
253
|
assert "requires the [audio] extra" in err
|
|
232
|
-
assert
|
|
254
|
+
assert 'pipx inject yt-tools-cli "librosa" "matplotlib"' in err
|
|
233
255
|
assert "pip install 'yt-tools-cli[audio]'" in err
|
|
234
256
|
assert "Traceback" not in err
|
|
235
257
|
|
|
@@ -183,6 +183,11 @@ def test_render_zero_frames_still_emits_header():
|
|
|
183
183
|
# --- _load_engine: friendly missing-extra hint -------------------------------
|
|
184
184
|
|
|
185
185
|
|
|
186
|
+
# [test-modify: test_load_engine_friendly_hint_when_rapidocr_missing: was
|
|
187
|
+
# `assert "pipx inject yt-tools-cli rapidocr onnxruntime" in msg`; is the same
|
|
188
|
+
# with the quoted, bounded specs; reason: task:2831 — the hint carries the
|
|
189
|
+
# rapidocr pin (an inject bypasses the extra metadata) and quotes every spec so
|
|
190
|
+
# the pasted command is safe in bash/cmd/PowerShell.]
|
|
186
191
|
def test_load_engine_friendly_hint_when_rapidocr_missing(monkeypatch):
|
|
187
192
|
"""Stick None into sys.modules['rapidocr'] so `from rapidocr import ...`
|
|
188
193
|
raises ImportError; verify the friendly hint is built into the error."""
|
|
@@ -190,7 +195,7 @@ def test_load_engine_friendly_hint_when_rapidocr_missing(monkeypatch):
|
|
|
190
195
|
with pytest.raises(OcrError) as exc_info:
|
|
191
196
|
ocr_mod._load_engine("en")
|
|
192
197
|
msg = str(exc_info.value)
|
|
193
|
-
assert
|
|
198
|
+
assert 'pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"' in msg
|
|
194
199
|
assert "pip install 'yt-tools-cli[ocr]'" in msg
|
|
195
200
|
|
|
196
201
|
|
|
@@ -289,7 +294,7 @@ def test_cli_missing_extra_prints_friendly_hint(tmp_path, monkeypatch, capsys):
|
|
|
289
294
|
def _raise(_lang):
|
|
290
295
|
raise OcrError(
|
|
291
296
|
"yt-ocr requires the [ocr] extra:\n"
|
|
292
|
-
|
|
297
|
+
' pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"\n'
|
|
293
298
|
" # or\n"
|
|
294
299
|
" pip install 'yt-tools-cli[ocr]'"
|
|
295
300
|
)
|
|
@@ -299,7 +304,7 @@ def test_cli_missing_extra_prints_friendly_hint(tmp_path, monkeypatch, capsys):
|
|
|
299
304
|
|
|
300
305
|
assert rc == 1
|
|
301
306
|
err = capsys.readouterr().err
|
|
302
|
-
assert
|
|
307
|
+
assert 'pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"' in err
|
|
303
308
|
assert "pip install 'yt-tools-cli[ocr]'" in err
|
|
304
309
|
|
|
305
310
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Real RapidOCR engine guard — the seam every other yt-ocr test mocks (task:2831).
|
|
2
|
+
|
|
3
|
+
``tests/test_ocr.py`` patches ``_load_engine`` in every integration case, so the
|
|
4
|
+
whole suite stayed green while the *published* ``[ocr]`` extra resolved to
|
|
5
|
+
rapidocr 3.9.x, whose ``params`` schema ``yt_tools/ocr.py`` does not speak. On a
|
|
6
|
+
clean ``pipx install "yt-tools-cli[ocr]"`` (0.24.0/0.24.1) the first
|
|
7
|
+
``yt-ocr`` run died with::
|
|
8
|
+
|
|
9
|
+
error: Invalid OCR configuration.
|
|
10
|
+
Example valid usage:
|
|
11
|
+
from rapidocr import LangRec, OCRVersion, RapidOCR
|
|
12
|
+
engine = RapidOCR(params={'Rec.ocr_version': OCRVersion.PPOCRV5, ...})
|
|
13
|
+
|
|
14
|
+
The mocked seam is exactly the seam that broke, so this module drives it for
|
|
15
|
+
real: build the engine through ``_load_engine``, OCR a frame the test renders
|
|
16
|
+
itself, and require text back. Detection is what makes it the guard it is —
|
|
17
|
+
``_load_engine`` is where rapidocr 3.9 rejects the config, so the failure lands
|
|
18
|
+
here as an ``OcrError``/``rapidocr`` error rather than in a consumer session.
|
|
19
|
+
|
|
20
|
+
Skipped — never failed — when rapidocr is absent: the light-core jobs install no
|
|
21
|
+
extras, and the ``pytest (full extras)`` CI job installs ``[ocr]`` (see
|
|
22
|
+
``.github/workflows/ci.yml``), which is where this contract is enforced. The
|
|
23
|
+
first construction downloads the ~10 MB PP-OCRv5 ONNX model, so the network is a
|
|
24
|
+
prerequisite of that job, not of a plain ``pytest`` run.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
import pytest
|
|
32
|
+
|
|
33
|
+
from yt_tools import ocr as ocr_mod
|
|
34
|
+
|
|
35
|
+
pytest.importorskip(
|
|
36
|
+
"rapidocr",
|
|
37
|
+
reason="[ocr] extra not installed (run: pip install 'yt-tools-cli[ocr]')",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_VID = "abcDEF12345"
|
|
41
|
+
_URL = f"https://youtu.be/{_VID}"
|
|
42
|
+
|
|
43
|
+
# Rendered big and blocky on purpose: the guard must fail on a *configuration*
|
|
44
|
+
# mismatch, never because a serif glyph was misread at 12 px.
|
|
45
|
+
_RENDERED_TEXT = "OCR TEST"
|
|
46
|
+
_COLS = 720
|
|
47
|
+
_ROWS = 220
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _render_text_frame(path: Path, text: str = _RENDERED_TEXT) -> Path:
|
|
51
|
+
"""Draw ``text`` on a white canvas — a frame real OCR can read.
|
|
52
|
+
|
|
53
|
+
``cv2`` is not an optional nicety here: rapidocr depends on it (PP-OCRv5
|
|
54
|
+
preprocessing), so using it adds no dependency the extra does not already
|
|
55
|
+
carry. The Hershey font is built into OpenCV, so no font file is needed and
|
|
56
|
+
the fixture stays a file the test writes, not a binary in git.
|
|
57
|
+
"""
|
|
58
|
+
import cv2 # noqa: PLC0415 — heavy import, only reachable behind the [ocr] skip
|
|
59
|
+
import numpy as np # noqa: PLC0415
|
|
60
|
+
|
|
61
|
+
canvas = np.full((_ROWS, _COLS, 3), 255, dtype=np.uint8)
|
|
62
|
+
cv2.putText(
|
|
63
|
+
canvas,
|
|
64
|
+
text,
|
|
65
|
+
(40, 150),
|
|
66
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
67
|
+
3.0,
|
|
68
|
+
(0, 0, 0),
|
|
69
|
+
8,
|
|
70
|
+
cv2.LINE_AA,
|
|
71
|
+
)
|
|
72
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
assert cv2.imwrite(str(path), canvas), f"could not write the rendered frame to {path}"
|
|
74
|
+
return path
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.fixture(scope="module")
|
|
78
|
+
def text_frame(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|
79
|
+
return _render_text_frame(tmp_path_factory.mktemp("frames") / "frame_0023.jpg")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.fixture(scope="module")
|
|
83
|
+
def engine():
|
|
84
|
+
"""One engine per module: construction downloads the model, so it is not free."""
|
|
85
|
+
return ocr_mod._load_engine("en")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_load_engine_constructs_the_declared_params(engine):
|
|
89
|
+
"""The 3.9.x failure mode: RapidOCR refuses the 3.8.x-shaped ``params`` dict."""
|
|
90
|
+
assert engine is not None
|
|
91
|
+
# Not a mock assertion — `_load_engine` returns whatever RapidOCR built. If
|
|
92
|
+
# the params schema drifts again, construction raises before we get here.
|
|
93
|
+
assert hasattr(engine, "__call__"), "the engine handle must stay callable (engine(path))"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_engine_reads_text_from_a_rendered_frame(engine, text_frame):
|
|
97
|
+
texts = ocr_mod._ocr_one(engine, text_frame)
|
|
98
|
+
assert texts, (
|
|
99
|
+
"a frame with rendered text produced no detections — the engine is not "
|
|
100
|
+
"actually OCR-ing (model missing, or params silently ignored)"
|
|
101
|
+
)
|
|
102
|
+
joined = " ".join(texts).upper()
|
|
103
|
+
assert "OCR" in joined, f"expected {_RENDERED_TEXT!r} to be read back, got {texts!r}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_cli_real_engine_writes_nonempty_markdown(tmp_path, monkeypatch, text_frame):
|
|
107
|
+
"""The acceptance path end to end, minus YouTube: cached frame → ocr.md with text."""
|
|
108
|
+
monkeypatch.chdir(tmp_path)
|
|
109
|
+
cache_root = tmp_path / "yt-cache" / _VID
|
|
110
|
+
frames_dir = cache_root / "frames"
|
|
111
|
+
frames_dir.mkdir(parents=True)
|
|
112
|
+
(frames_dir / text_frame.name).write_bytes(text_frame.read_bytes())
|
|
113
|
+
|
|
114
|
+
out = tmp_path / "ocr.md"
|
|
115
|
+
rc = ocr_mod.main([_URL, "--out", str(out)])
|
|
116
|
+
|
|
117
|
+
assert rc == 0
|
|
118
|
+
body = out.read_text(encoding="utf-8")
|
|
119
|
+
assert body.strip(), "ocr.md must not be empty"
|
|
120
|
+
assert "## [0:23]" in body
|
|
121
|
+
assert "OCR" in body.upper()
|
|
@@ -214,6 +214,80 @@ def test_ocr_extra_is_unchanged():
|
|
|
214
214
|
assert ocr == {"rapidocr", "onnxruntime"}
|
|
215
215
|
|
|
216
216
|
|
|
217
|
+
def test_ocr_extra_pins_rapidocr_below_3_9():
|
|
218
|
+
"""`yt_tools/ocr.py` builds the RapidOCR **3.8.x** params schema (task:2831).
|
|
219
|
+
|
|
220
|
+
``params["Det.ocr_version"] = OCRVersion.PPOCRV5`` (+ Cls/Rec) and the
|
|
221
|
+
``LangRec`` overrides are 3.8.x-shaped. 3.9.x changed the schema and rejects
|
|
222
|
+
the dict at construction — ``error: Invalid OCR configuration`` — which is
|
|
223
|
+
what a clean ``pipx install "yt-tools-cli[ocr]"`` hit: 0.24.0/0.24.1 declared
|
|
224
|
+
``rapidocr>=3.8``, the resolver picked 3.9.2, and every ``yt-ocr`` run died
|
|
225
|
+
on a machine that had installed exactly what the metadata asked for.
|
|
226
|
+
|
|
227
|
+
Same shape as the librosa cap above: an upper bound that encodes a
|
|
228
|
+
compatibility fact, removed only together with the code that depends on it.
|
|
229
|
+
The behavioural half is ``tests/test_ocr_engine.py``, which drives the real
|
|
230
|
+
engine — this one keeps the boundary from drifting back out of the metadata
|
|
231
|
+
where nothing local would notice.
|
|
232
|
+
"""
|
|
233
|
+
specs = _pyproject()["project"]["optional-dependencies"]["ocr"]
|
|
234
|
+
req = Requirement(next(spec for spec in specs if _dist_name(spec) == "rapidocr"))
|
|
235
|
+
|
|
236
|
+
assert req.specifier.contains("3.8.4"), (
|
|
237
|
+
"the RapidOCR release yt-ocr is written against must stay installable"
|
|
238
|
+
)
|
|
239
|
+
assert not req.specifier.contains("3.9.0"), (
|
|
240
|
+
"rapidocr 3.9 changed the params schema yt_tools/ocr.py builds — lift this "
|
|
241
|
+
"cap only in the same change that adapts the code"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_ocr_inject_hint_carries_the_same_bound_as_the_extra():
|
|
246
|
+
"""Two install routes, one bound — the `pipx inject` hint has no metadata to inherit.
|
|
247
|
+
|
|
248
|
+
``yt-ocr`` refusing with `pipx inject yt-tools-cli rapidocr onnxruntime` told
|
|
249
|
+
the user to install exactly the pair the extra caps below 3.9 — an inject
|
|
250
|
+
resolves from PyPI directly, so the refusal message would have reproduced
|
|
251
|
+
[[issue:77]] on a machine that followed it to the letter. ``doctor``'s
|
|
252
|
+
``next_step`` and the README print the same line, so the assertion is on the
|
|
253
|
+
constant all three read from, not on one caller's wording.
|
|
254
|
+
"""
|
|
255
|
+
from yt_tools import extras # noqa: PLC0415 — local import keeps the packaging tests import-light
|
|
256
|
+
|
|
257
|
+
specs = dict(
|
|
258
|
+
zip(
|
|
259
|
+
(Requirement(spec).name for spec in extras.EXTRA_PACKAGES["ocr"]),
|
|
260
|
+
extras.EXTRA_PACKAGES["ocr"],
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
declared = {
|
|
264
|
+
Requirement(spec).name: spec
|
|
265
|
+
for spec in _pyproject()["project"]["optional-dependencies"]["ocr"]
|
|
266
|
+
}
|
|
267
|
+
assert specs == declared, (
|
|
268
|
+
"the [ocr] extra and the inject hint must name the same requirements — "
|
|
269
|
+
"the hint is what a user pastes when the extra is missing"
|
|
270
|
+
)
|
|
271
|
+
assert '"rapidocr>=3.8,<3.9"' in extras.inject_command("ocr"), (
|
|
272
|
+
"a bounded spec must be quoted: `>` and `<` are redirections in bash, cmd.exe and PowerShell"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_ci_installs_the_ocr_extra_so_the_engine_test_actually_runs():
|
|
277
|
+
"""A guard test that is skipped in CI is worse than no guard.
|
|
278
|
+
|
|
279
|
+
``tests/test_ocr_engine.py`` skips when rapidocr is absent (the light-core
|
|
280
|
+
jobs install nothing), which is the right shape for a local `pytest` run and
|
|
281
|
+
a silent hole in the pipeline: the suite would stay green while nothing
|
|
282
|
+
exercised the engine — the exact state that let [[issue:77]] ship. So the CI
|
|
283
|
+
install line is part of the contract.
|
|
284
|
+
"""
|
|
285
|
+
workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
|
|
286
|
+
assert 'pip install -e ".[full,ocr,test]"' in workflow, (
|
|
287
|
+
"the pytest job must install [ocr], or tests/test_ocr_engine.py skips there too"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
217
291
|
@pytest.mark.parametrize("extra", ["audio", "full"])
|
|
218
292
|
def test_librosa_1x_is_excluded(extra):
|
|
219
293
|
"""`bpm-detector` 1.1.0 calls ``librosa.beat.tempo``, removed in librosa 1.0.
|
|
@@ -73,6 +73,37 @@ def test_frontmatter_description_is_install_neutral():
|
|
|
73
73
|
assert "pipx install" in description, "the trigger text should name the neutral install path"
|
|
74
74
|
|
|
75
75
|
|
|
76
|
+
#: The host's cap on a skill description. Measured, not chosen: a harness that
|
|
77
|
+
#: loads this skill reported `description exceeds 1024 characters (2261)` — and
|
|
78
|
+
#: nothing in this repo objected, because every other test here reads the
|
|
79
|
+
#: frontmatter as free-form text. This test measures the raw YAML scalar, which
|
|
80
|
+
#: reads a little longer (2511 before the trim); every measure of the old text
|
|
81
|
+
#: was over the cap, which is the only part that matters.
|
|
82
|
+
DESCRIPTION_MAX_CHARS = 1024
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_frontmatter_description_fits_the_host_limit():
|
|
86
|
+
"""A description that does not fit is not installed — it is reported.
|
|
87
|
+
|
|
88
|
+
The description is the *only* part of the skill the host matches triggers
|
|
89
|
+
against, so overflowing it does not degrade the skill gracefully: the host
|
|
90
|
+
either refuses it or truncates the trigger list, and `yt-ocr` / `yt-listen`
|
|
91
|
+
become unreachable flows with a green suite ([[task:2831]] run, 2026-09-13).
|
|
92
|
+
Keep the trim honest by cutting prose, never trigger phrases — a shorter
|
|
93
|
+
description that no longer names a flow is a different bug.
|
|
94
|
+
"""
|
|
95
|
+
description = re.search(r"^description:\s*(.+)$", _frontmatter(_text()), re.MULTILINE)
|
|
96
|
+
assert description, "the frontmatter has no description: line"
|
|
97
|
+
description = description.group(1).strip()
|
|
98
|
+
assert len(description) <= DESCRIPTION_MAX_CHARS, (
|
|
99
|
+
f"the description is {len(description)} chars, over the {DESCRIPTION_MAX_CHARS} "
|
|
100
|
+
"the host allows — trim prose, keep the trigger phrases"
|
|
101
|
+
)
|
|
102
|
+
# ...and the trim must not have traded triggers away for room.
|
|
103
|
+
for command in ("yt-search", "yt-transcript", "yt-frames", "yt-listen", "yt-meta", "yt-comments", "yt-ocr"):
|
|
104
|
+
assert command in description, f"the description stopped naming {command}"
|
|
105
|
+
|
|
106
|
+
|
|
76
107
|
# ---- a named harness is always an option, never the flow --------------------
|
|
77
108
|
|
|
78
109
|
|
|
@@ -127,11 +127,12 @@ class Report:
|
|
|
127
127
|
if self.blockers:
|
|
128
128
|
return None
|
|
129
129
|
if self.missing_extras:
|
|
130
|
-
packages = " ".join(p for e in self.missing_extras for p in extras.EXTRA_PACKAGES[e])
|
|
131
130
|
# Built from the same constant the refusals use: a rename that missed
|
|
132
131
|
# this copy would make the report recommend a package that does not
|
|
133
|
-
# exist ([[wiki:3589]]).
|
|
134
|
-
|
|
132
|
+
# exist ([[wiki:3589]]). Bound specs (the OCR pin) mean the one
|
|
133
|
+
# command must be formatted by the same helper too, not re-joined
|
|
134
|
+
# here — an unquoted ``>=`` is a redirect ([[task:2831]]).
|
|
135
|
+
return extras.inject_command_for(self.missing_extras)
|
|
135
136
|
for check in self.checks:
|
|
136
137
|
if check.status == WARN and check.fix:
|
|
137
138
|
return check.fix
|
|
@@ -34,11 +34,17 @@ EXTRA_MODULES: dict[str, tuple[str, ...]] = {
|
|
|
34
34
|
"ocr": ("rapidocr", "onnxruntime"),
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
# extra →
|
|
37
|
+
# extra → install specs for the ``pipx inject`` hint. These are what the user
|
|
38
|
+
# actually pastes, and an inject resolves them *outside* the extra's metadata —
|
|
39
|
+
# so a bound that matters has to be repeated here or the hint contradicts the
|
|
40
|
+
# extra it is advertising: `pipx inject yt-tools-cli rapidocr` used to resolve
|
|
41
|
+
# 3.9.x while `[ocr]` said <3.9, i.e. the refusal message itself installed the
|
|
42
|
+
# broken pair ([[task:2831]]). tests/test_packaging.py pins the OCR entries to
|
|
43
|
+
# the requirement strings in pyproject.toml so the two routes cannot drift.
|
|
38
44
|
EXTRA_PACKAGES: dict[str, tuple[str, ...]] = {
|
|
39
45
|
"frames": ("scenedetect", "opencv-python"),
|
|
40
46
|
"audio": ("librosa", "matplotlib"),
|
|
41
|
-
"ocr": ("rapidocr", "onnxruntime"),
|
|
47
|
+
"ocr": ("rapidocr>=3.8,<3.9", "onnxruntime>=1.18"),
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
#: The enrichment that cannot be a package extra. ``bpm-detector`` gives
|
|
@@ -92,8 +98,24 @@ def inject_command(extra: str) -> str:
|
|
|
92
98
|
refusal and the recommendation must not drift apart.
|
|
93
99
|
"""
|
|
94
100
|
_check_extra(extra)
|
|
95
|
-
|
|
96
|
-
|
|
101
|
+
return inject_command_for([extra])
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def inject_command_for(extra_names: list[str] | tuple[str, ...]) -> str:
|
|
105
|
+
"""One command adding several extras — what ``doctor``'s ``next_step`` prints.
|
|
106
|
+
|
|
107
|
+
Every spec is double-quoted: an injected spec can carry version bounds
|
|
108
|
+
(``rapidocr>=3.8,<3.9``), and an unquoted ``>``/``<`` is a redirection in
|
|
109
|
+
bash, cmd.exe and PowerShell alike — the printed command has to be
|
|
110
|
+
pasteable, since being pasteable is the whole point of it.
|
|
111
|
+
"""
|
|
112
|
+
specs = [spec for name in extra_names for spec in _specs(name)]
|
|
113
|
+
return f"pipx inject {DISTRIBUTION} " + " ".join(f'"{spec}"' for spec in specs)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _specs(extra: str) -> tuple[str, ...]:
|
|
117
|
+
_check_extra(extra)
|
|
118
|
+
return EXTRA_PACKAGES[extra]
|
|
97
119
|
|
|
98
120
|
|
|
99
121
|
def module_missing(name: str) -> bool:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|