yt-tools-cli 0.24.2__tar.gz → 0.25.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.
Files changed (61) hide show
  1. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/.claude-plugin/plugin.json +1 -1
  2. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/.github/workflows/ci.yml +32 -0
  3. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/CHANGELOG.md +30 -0
  4. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/PKG-INFO +17 -15
  5. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/README.md +15 -13
  6. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/pyproject.toml +12 -13
  7. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/skills/using-yt-tools/SKILL.md +11 -9
  8. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_missing_extra.py +1 -1
  9. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_ocr.py +85 -3
  10. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_ocr_engine.py +19 -0
  11. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_packaging.py +36 -27
  12. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/yt_tools/extras.py +2 -2
  13. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/yt_tools/ocr.py +73 -15
  14. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/.github/workflows/release.yml +0 -0
  15. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/.gitignore +0 -0
  16. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/CONTRIBUTING.md +0 -0
  17. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/LICENSE +0 -0
  18. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/hooks/hooks.json +0 -0
  19. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/scripts/check-core-install.sh +0 -0
  20. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/scripts/check-metadata.py +0 -0
  21. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/scripts/check-pypi-name.py +0 -0
  22. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/scripts/ensure-install.ps1 +0 -0
  23. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/scripts/ensure-install.py +0 -0
  24. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/scripts/ensure-install.sh +0 -0
  25. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/scripts/publish-check.py +0 -0
  26. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/scripts/sync-version.py +0 -0
  27. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/__init__.py +0 -0
  28. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_cache.py +0 -0
  29. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_check_metadata.py +0 -0
  30. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_cli_smoke.py +0 -0
  31. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_comments.py +0 -0
  32. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_core.py +0 -0
  33. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_distribution_name.py +0 -0
  34. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_doctor.py +0 -0
  35. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_failure_modes.py +0 -0
  36. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_frames.py +0 -0
  37. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_listen.py +0 -0
  38. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_markdown.py +0 -0
  39. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_meta.py +0 -0
  40. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_parse_interval.py +0 -0
  41. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_publish_check.py +0 -0
  42. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_pypi_name.py +0 -0
  43. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_search.py +0 -0
  44. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_skill_portable.py +0 -0
  45. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_transcript.py +0 -0
  46. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_version.py +0 -0
  47. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/tests/test_watch.py +0 -0
  48. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/yt_tools/__init__.py +0 -0
  49. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/yt_tools/_metadata.py +0 -0
  50. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/yt_tools/cache.py +0 -0
  51. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/yt_tools/cli.py +0 -0
  52. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/yt_tools/comments.py +0 -0
  53. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/yt_tools/core.py +0 -0
  54. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/yt_tools/doctor.py +0 -0
  55. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/yt_tools/frames.py +0 -0
  56. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/yt_tools/listen.py +0 -0
  57. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/yt_tools/markdown.py +0 -0
  58. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/yt_tools/meta.py +0 -0
  59. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/yt_tools/search.py +0 -0
  60. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/yt_tools/transcript.py +0 -0
  61. {yt_tools_cli-0.24.2 → yt_tools_cli-0.25.0}/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.2",
4
+ "version": "0.25.0",
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",
@@ -31,6 +31,38 @@ jobs:
31
31
  - name: Unit + CLI smoke tests
32
32
  run: pytest -q
33
33
 
34
+ ocr-engine-matrix:
35
+ name: ocr engine (rapidocr ${{ matrix.rapidocr }})
36
+ runs-on: ubuntu-latest
37
+ strategy:
38
+ # Both legs are the point: `[ocr]` is a pin on a third-party params API,
39
+ # and release 0.24.0/0.24.1 shipped broken because the *newer* minor was
40
+ # never exercised — the engine factory was mocked in every test, so the
41
+ # suite was green while a clean install could not construct the engine at
42
+ # all ([[issue:77]], [[task:2831]]). 0.25.0 names every model version and
43
+ # model type explicitly ([[task:2833]]); the verified minors live here and
44
+ # in the `[ocr]` bound, and tests/test_packaging.py asserts the two agree.
45
+ fail-fast: false
46
+ matrix:
47
+ rapidocr: ["3.8.4", "3.9.2"]
48
+ steps:
49
+ - uses: actions/checkout@v4
50
+ - uses: actions/setup-python@v5
51
+ with:
52
+ python-version: "3.12"
53
+ # `[ocr]` alone is enough: the engine tests need rapidocr, onnxruntime and
54
+ # cv2 (a rapidocr dependency) — nothing from [full] or [audio]. The second
55
+ # install pins the leg, since the extra's bound spans both.
56
+ - name: Install ([ocr] at ${{ matrix.rapidocr }})
57
+ run: |
58
+ python -m pip install -e ".[ocr,test]"
59
+ python -m pip install "rapidocr==${{ matrix.rapidocr }}"
60
+ # First construction downloads the ~8 MB model, so this leg needs network —
61
+ # the same prerequisite the `tests` job already carries for the model calls
62
+ # it makes.
63
+ - name: Engine tests
64
+ run: pytest -q tests/test_ocr_engine.py tests/test_ocr.py
65
+
34
66
  build:
35
67
  name: build (sdist + wheel, publishable metadata)
36
68
  runs-on: ubuntu-latest
@@ -11,6 +11,36 @@ Releases before 0.8.0 are summarized only in the git log.
11
11
 
12
12
  _(nothing yet)_
13
13
 
14
+ ## [0.25.0] — 2026-09-13
15
+
16
+ ### Added
17
+
18
+ - **`yt-ocr` works on rapidocr 3.9** ([[task:2833]]). The 0.24.2 cap (`<3.9`) is
19
+ gone: `yt_tools/ocr.py` now names the model type alongside the model version
20
+ for Det/Cls/Rec, so the params dict means the same thing on every 3.x release
21
+ instead of inheriting a version-dependent default (3.8 → PP-OCRv4+`mobile`,
22
+ 3.9 → PP-OCRv6+`small`, and PP-OCRv5 has no `small` — that inheritance was
23
+ [[issue:77]]). The bound is now `>=3.8,<4`: a major is where a params-API
24
+ change would land.
25
+ - **A CI matrix over the verified minors** (`ocr-engine-matrix`: rapidocr 3.8.4
26
+ and 3.9.2, engine tests only). The old pin asked users on the *newer* minor to
27
+ be the acceptance test; now both legs run on every push.
28
+ - **A per-language engine test.** Every `--language` value the CLI advertises is
29
+ built against the real model registry — the guard for the bug below.
30
+
31
+ ### Fixed
32
+
33
+ - **`--language ja` never worked.** PP-OCRv5 ships twelve recognizers and
34
+ Japanese is not one of them: asking for `PP-OCRv5 + japan` resolved to nothing,
35
+ and the engine raised `error: Invalid OCR configuration` — on 3.8.4 as much as
36
+ 3.9.2, i.e. in every release that ever offered the flag. It used to "work" only
37
+ because the library's lenient fallback picked the PP-OCRv4 Japanese model, and
38
+ once the model type was pinned explicitly (above) that fallback was skipped.
39
+ Japanese now pins the v4 recognizer on purpose, and `ocr.md`'s header says so
40
+ instead of claiming PP-OCRv5: `RapidOCR (PP-OCRv5 det/cls + PP-OCRv4 ja rec)`.
41
+ Found by running the language matrix while adapting to 3.9 — the same mocked
42
+ seam that hid [[issue:77]].
43
+
14
44
  ## [0.24.2] — 2026-09-13
15
45
 
16
46
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: yt-tools-cli
3
- Version: 0.24.2
3
+ Version: 0.25.0
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<3.9,>=3.8; extra == 'ocr'
38
+ Requires-Dist: rapidocr<4,>=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
@@ -155,7 +155,7 @@ pipx install "yt-tools-cli[full,ocr]"
155
155
  # redirections in bash, cmd.exe and PowerShell. `rapidocr` is capped below 3.9
156
156
  # because 3.9 moved the bundled defaults to PP-OCRv6, where the PP-OCRv5 params
157
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"
158
+ pipx inject yt-tools-cli "rapidocr>=3.8,<4" "onnxruntime>=1.18"
159
159
  ```
160
160
 
161
161
  `uv` users: `uv tool install "yt-tools-cli[full]"`, or for a one-off run
@@ -192,7 +192,7 @@ matplotlib. Heavier stacks are opt-in, per flow.
192
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) |
193
193
  | `[frames]` | `scenedetect[opencv]` (OpenCV) | `yt-frames --mode scene`, `yt-watch`, and dedup in the other frame modes |
194
194
  | `[audio]` | `librosa`, `matplotlib` | `yt-listen` (spectral features, spectrogram, BPM/key) |
195
- | `[ocr]` | `rapidocr>=3.8,<3.9`, `onnxruntime` | `yt-ocr` (PP-OCRv5; ONNX model downloaded on first run) |
195
+ | `[ocr]` | `rapidocr>=3.8,<4`, `onnxruntime` | `yt-ocr` (PP-OCRv5; ONNX model downloaded on first run) |
196
196
  | `[full]` | core + `[frames]` + `[audio]` | the plugin default: every flow above **except `yt-ocr`** |
197
197
 
198
198
  `[full]` is a *flow* default, not "everything". Two things stay outside it on
@@ -462,8 +462,9 @@ When `yt-transcript` returns 0 bytes (or near-nothing) and the content
462
462
  lives entirely in burned-in overlay text — tutorial channels with
463
463
  schematic labels, chord matrices over dimmed B-roll, parameter
464
464
  walkthroughs without voice-over — fall back to OCR over cached frames.
465
- Engine is [RapidOCR](https://github.com/RapidAI/RapidOCR) (PP-OCRv5
466
- models via `onnxruntime`).
465
+ Engine is [RapidOCR](https://github.com/RapidAI/RapidOCR) (PP-OCRv5 models via
466
+ `onnxruntime`; Japanese is read by the PP-OCRv4 recognizer, the only one that
467
+ ships for it — the header line of `ocr.md` names whichever ran).
467
468
 
468
469
  ```bash
469
470
  # 1) extract frames first (lower scene threshold catches overlay fades
@@ -487,7 +488,7 @@ yt-ocr URL --timestamps 1:30,2:45,5:10
487
488
 
488
489
  # Non-English overlays:
489
490
  yt-ocr URL --language ru # Cyrillic
490
- yt-ocr URL --language ja # Japanese
491
+ yt-ocr URL --language ja # Japanese (PP-OCRv4 recognizer — no v5 model exists)
491
492
  yt-ocr URL --language zh # Chinese (simplified)
492
493
  yt-ocr URL --language multi # PP-OCR multilingual (Chinese+English)
493
494
  ```
@@ -501,7 +502,7 @@ was checked vs. silently omitted).
501
502
  > core install — and **not** in `[full]` either. If `yt-ocr` exits with the
502
503
  > missing-extra hint, run:
503
504
  > ```bash
504
- > pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"
505
+ > pipx inject yt-tools-cli "rapidocr>=3.8,<4" "onnxruntime>=1.18"
505
506
  > # or for non-pipx setups:
506
507
  > pip install 'yt-tools-cli[ocr]'
507
508
  > ```
@@ -510,13 +511,14 @@ was checked vs. silently omitted).
510
511
  > they land in the installed package's own `rapidocr/models/` directory, not in
511
512
  > `~/.cache/` — verified on a clean venv.
512
513
  >
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.
514
+ > The `rapidocr` bound is `>=3.8,<4`: the CLI names every model version *and*
515
+ > model type explicitly, so the config means the same on each 3.x release — but a
516
+ > major could change the params API, which is what bit 0.24.x (3.9 defaulted to
517
+ > PP-OCRv6 + `small`, where the PP-OCRv5 detector the CLI asks for does not
518
+ > exist). Both verified minors (3.8.4, 3.9.2) are exercised by the engine test in
519
+ > CI. The `[ocr]` extra carries the bound, and so does the `pipx inject` line
520
+ > above: an inject resolves from PyPI directly and never inherits the extra's
521
+ > metadata.
520
522
 
521
523
  ### Cache hygiene
522
524
 
@@ -113,7 +113,7 @@ pipx install "yt-tools-cli[full,ocr]"
113
113
  # redirections in bash, cmd.exe and PowerShell. `rapidocr` is capped below 3.9
114
114
  # because 3.9 moved the bundled defaults to PP-OCRv6, where the PP-OCRv5 params
115
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"
116
+ pipx inject yt-tools-cli "rapidocr>=3.8,<4" "onnxruntime>=1.18"
117
117
  ```
118
118
 
119
119
  `uv` users: `uv tool install "yt-tools-cli[full]"`, or for a one-off run
@@ -150,7 +150,7 @@ matplotlib. Heavier stacks are opt-in, per flow.
150
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) |
151
151
  | `[frames]` | `scenedetect[opencv]` (OpenCV) | `yt-frames --mode scene`, `yt-watch`, and dedup in the other frame modes |
152
152
  | `[audio]` | `librosa`, `matplotlib` | `yt-listen` (spectral features, spectrogram, BPM/key) |
153
- | `[ocr]` | `rapidocr>=3.8,<3.9`, `onnxruntime` | `yt-ocr` (PP-OCRv5; ONNX model downloaded on first run) |
153
+ | `[ocr]` | `rapidocr>=3.8,<4`, `onnxruntime` | `yt-ocr` (PP-OCRv5; ONNX model downloaded on first run) |
154
154
  | `[full]` | core + `[frames]` + `[audio]` | the plugin default: every flow above **except `yt-ocr`** |
155
155
 
156
156
  `[full]` is a *flow* default, not "everything". Two things stay outside it on
@@ -420,8 +420,9 @@ When `yt-transcript` returns 0 bytes (or near-nothing) and the content
420
420
  lives entirely in burned-in overlay text — tutorial channels with
421
421
  schematic labels, chord matrices over dimmed B-roll, parameter
422
422
  walkthroughs without voice-over — fall back to OCR over cached frames.
423
- Engine is [RapidOCR](https://github.com/RapidAI/RapidOCR) (PP-OCRv5
424
- models via `onnxruntime`).
423
+ Engine is [RapidOCR](https://github.com/RapidAI/RapidOCR) (PP-OCRv5 models via
424
+ `onnxruntime`; Japanese is read by the PP-OCRv4 recognizer, the only one that
425
+ ships for it — the header line of `ocr.md` names whichever ran).
425
426
 
426
427
  ```bash
427
428
  # 1) extract frames first (lower scene threshold catches overlay fades
@@ -445,7 +446,7 @@ yt-ocr URL --timestamps 1:30,2:45,5:10
445
446
 
446
447
  # Non-English overlays:
447
448
  yt-ocr URL --language ru # Cyrillic
448
- yt-ocr URL --language ja # Japanese
449
+ yt-ocr URL --language ja # Japanese (PP-OCRv4 recognizer — no v5 model exists)
449
450
  yt-ocr URL --language zh # Chinese (simplified)
450
451
  yt-ocr URL --language multi # PP-OCR multilingual (Chinese+English)
451
452
  ```
@@ -459,7 +460,7 @@ was checked vs. silently omitted).
459
460
  > core install — and **not** in `[full]` either. If `yt-ocr` exits with the
460
461
  > missing-extra hint, run:
461
462
  > ```bash
462
- > pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"
463
+ > pipx inject yt-tools-cli "rapidocr>=3.8,<4" "onnxruntime>=1.18"
463
464
  > # or for non-pipx setups:
464
465
  > pip install 'yt-tools-cli[ocr]'
465
466
  > ```
@@ -468,13 +469,14 @@ was checked vs. silently omitted).
468
469
  > they land in the installed package's own `rapidocr/models/` directory, not in
469
470
  > `~/.cache/` — verified on a clean venv.
470
471
  >
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.
472
+ > The `rapidocr` bound is `>=3.8,<4`: the CLI names every model version *and*
473
+ > model type explicitly, so the config means the same on each 3.x release — but a
474
+ > major could change the params API, which is what bit 0.24.x (3.9 defaulted to
475
+ > PP-OCRv6 + `small`, where the PP-OCRv5 detector the CLI asks for does not
476
+ > exist). Both verified minors (3.8.4, 3.9.2) are exercised by the engine test in
477
+ > CI. The `[ocr]` extra carries the bound, and so does the `pipx inject` line
478
+ > above: an inject resolves from PyPI directly and never inherits the extra's
479
+ > metadata.
478
480
 
479
481
  ### Cache hygiene
480
482
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "yt-tools-cli"
7
- version = "0.24.2"
7
+ version = "0.25.0"
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,18 +79,17 @@ full = [
79
79
  "matplotlib>=3.8",
80
80
  ]
81
81
  ocr = [
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",
82
+ # Pinned to the 3.x params API ([[task:2833]]). Within it, `ocr.py` names
83
+ # *everything* explicitly — `Det/Cls/Rec.ocr_version` and the matching
84
+ # `model_type`, plus the recognizer version per language — so the config no
85
+ # longer depends on whatever defaults the installed release ships. That was
86
+ # the 0.24.2 bug ([[issue:77]]): 3.8 defaulted to PP-OCRv4+mobile, 3.9 moved
87
+ # Det/Rec to PP-OCRv6+small, and PP-OCRv5 has no `small`, so a version-only
88
+ # pin asked for a model that does not exist and construction raised
89
+ # `error: Invalid OCR configuration`. Verified against 3.8.4 and 3.9.2 for
90
+ # every `--language` (CI matrix: .github/workflows/ci.yml); the major bound
91
+ # is what a params-API change would break, so it stays.
92
+ "rapidocr>=3.8,<4",
94
93
  "onnxruntime>=1.18",
95
94
  ]
96
95
  test = ["pytest>=7.0"]
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: using-yt-tools
3
- version: 0.24.2
3
+ version: 0.25.0
4
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
 
@@ -108,7 +108,7 @@ Seven distinct flows, picked by user intent:
108
108
  interesting timestamps if you need the original frames.
109
109
  - **Requires the `[ocr]` extra** — `rapidocr` + `onnxruntime`, **not** part of
110
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"
111
+ with both install hints (`pipx inject yt-tools-cli "rapidocr>=3.8,<4"
112
112
  "onnxruntime>=1.18"` / `pip install 'yt-tools-cli[ocr]'`). The plugin's
113
113
  SessionStart hook installs `[full]` (core + `[frames]` + `[audio]`, plus a
114
114
  best-effort `bpm-detector` inject) only, **not** `[ocr]` — first run of Flow F
@@ -152,10 +152,10 @@ pipx install "yt-tools-cli[full]==0.24.2"
152
152
  # OCR (explicit extra, ~10 MB model fetched on first run). Naming it in the
153
153
  # install lets pip resolve it from the metadata, bounds included:
154
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"
155
+ # already installed? widen in place — the bound is `>=3.8,<4` (a major is where a
156
+ # params-API change would land; quote the specs — `>`/`<` are redirections in
157
+ # bash, cmd.exe and PowerShell):
158
+ pipx inject yt-tools-cli "rapidocr>=3.8,<4" "onnxruntime>=1.18"
159
159
  ```
160
160
 
161
161
  From a checkout instead of PyPI (the plugin path, or tracking `master`):
@@ -305,7 +305,7 @@ prepend is needed — invoke normally.
305
305
  | C — audio-analysis | YouTube URL + timestamps (same formats as B) | `--duration 30s` (default 30s — the lower bound for beat-tracking); `--mode interval --interval 60s` (bulk sampling); `--no-wav` / `--no-spectrogram` (both default ON); `--linear` (STFT instead of mel); `--chroma` (bonus chromagram PNG); `--sample-rate 22050`; `--no-cache-source`; `--out DIR` |
306
306
  | D — metadata | YouTube URL or bare 11-char video id | `--out PATH` |
307
307
  | E — comments | YouTube URL or bare 11-char video id | `--max N` (default 50; higher = slower); `--sort {top,new}` (default top); `--out PATH` |
308
- | F — OCR | YouTube URL or bare 11-char video id (frames in cache, or pass `--timestamps`) | `--timestamps T1,T2,…` (extract via internal yt-frames then OCR); `--language {en,ru,ja,zh,multi}` (default `en`); `--out PATH` |
308
+ | F — OCR | YouTube URL or bare 11-char video id (frames in cache, or pass `--timestamps`) | `--timestamps T1,T2,…` (extract via internal yt-frames then OCR); `--language {en,ru,ja,zh,multi}` (default `en`; `ja` is read by the PP-OCRv4 recognizer — no PP-OCRv5 Japanese model exists); `--out PATH` |
309
309
 
310
310
  **Frame budget.** Auto-selected frames (`--mode scene`, `--mode interval`, and
311
311
  `yt-watch`) are capped at **100 per call** and evenly thinned with the first and
@@ -532,7 +532,7 @@ All failures abort cleanly; never leave a half-finished state.
532
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. |
533
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). |
534
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. |
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. |
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,<4" "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 never inherits the extra's metadata (`>`/`<` need the quotes). The plugin's SessionStart hook installs `[full]`, not `[ocr]`, so first run typically needs this. |
536
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. |
537
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. |
538
538
  | User passed a non-YouTube URL (Vimeo / Twitch / local mp4) | Out of scope | Stop; say the skill is YouTube-only. |
@@ -647,7 +647,9 @@ instead of printing a traceback.
647
647
  `zh` / `multi`. If the user doesn't name a language, default to `en`;
648
648
  if they say "русский / Japanese / Chinese", pass the matching code
649
649
  explicitly. (`multi` is the PP-OCR multilingual model — slower, wider
650
- CJK + Latin coverage; use it when the video mixes scripts.)
650
+ CJK + Latin coverage; use it when the video mixes scripts. `en`/`ru`/
651
+ `zh`/`multi` read with a PP-OCRv5 recognizer and `ja` with the v4 one —
652
+ the header line of `ocr.md` names whichever ran.)
651
653
  - **Don't try to OCR arbitrary `.jpg` files with `yt-ocr`.** This skill
652
654
  and CLI live in the YouTube domain: `yt-ocr` only accepts a YouTube
653
655
  URL / video-id and reads frames from `./yt-cache/<vid>/frames/`. For
@@ -82,7 +82,7 @@ def test_audio_refusal_wording_is_exact():
82
82
  def test_ocr_refusal_wording_is_exact():
83
83
  assert extras.format_missing_extra("yt-ocr", "ocr") == (
84
84
  "yt-ocr requires the [ocr] extra:\n"
85
- ' pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"\n'
85
+ ' pipx inject yt-tools-cli "rapidocr>=3.8,<4" "onnxruntime>=1.18"\n'
86
86
  " # or\n"
87
87
  " pip install 'yt-tools-cli[ocr]'"
88
88
  )
@@ -88,6 +88,11 @@ class _FakeLangRec:
88
88
 
89
89
  class _FakeOCRVersion:
90
90
  PPOCRV5 = "ocrver::v5"
91
+ PPOCRV4 = "ocrver::v4"
92
+
93
+
94
+ class _FakeModelType:
95
+ MOBILE = "model::mobile"
91
96
 
92
97
 
93
98
  def test_build_params_en_maps_to_LangRec_EN():
@@ -133,6 +138,68 @@ def test_build_params_multi_still_forces_v5():
133
138
  assert "Rec.lang_type" not in p
134
139
 
135
140
 
141
+ # task:2833 — the model_type half of the PP-OCRv5 pin. Pinning only the version
142
+ # left model_type at rapidocr's default, which is a *version-dependent* value:
143
+ # 3.8 defaults every component to PP-OCRv4 + "mobile" (valid for v5), 3.9 moved
144
+ # Det/Rec to PP-OCRv6 + "small" — and PP-OCRv5 ships mobile/server only, so
145
+ # "PP-OCRv5 … small" resolves to nothing and construction raises
146
+ # "error: Invalid OCR configuration" ([[issue:77]]). Naming the model type makes
147
+ # the config independent of whatever default the installed release ships.
148
+ def test_build_params_pins_the_model_type_alongside_the_version():
149
+ p = _build_params("en", _FakeLangRec, _FakeOCRVersion, _FakeModelType)
150
+ assert p["Det.model_type"] == "model::mobile"
151
+ assert p["Cls.model_type"] == "model::mobile"
152
+ assert p["Rec.model_type"] == "model::mobile"
153
+
154
+
155
+ def test_build_params_multi_also_pins_the_model_type():
156
+ p = _build_params("multi", _FakeLangRec, _FakeOCRVersion, _FakeModelType)
157
+ assert p["Rec.model_type"] == "model::mobile"
158
+ assert "Rec.lang_type" not in p
159
+
160
+
161
+ def test_build_params_without_a_modeltype_enum_stays_silent_about_it():
162
+ """The version/model pins travel together: no enum, no keys.
163
+
164
+ ``_build_params`` is called with the rapidocr enums only from
165
+ ``_load_engine``; the older signature (no ``ModelType``) must not
166
+ silently emit a half pin — a config with ``model_type`` but no
167
+ ``ocr_version`` (or the reverse) is exactly the drift this pins against.
168
+ """
169
+ p = _build_params("en", _FakeLangRec, _FakeOCRVersion)
170
+ assert not [k for k in p if "model_type" in k] or p["Det.ocr_version"] == "ocrver::v5"
171
+
172
+
173
+ # task:2833 — PP-OCRv5 ships twelve recognizers and Japanese is not one of them
174
+ # (its registry entry is v4-only, identically in 3.8.x and 3.9.x). Asking for
175
+ # v5 + japan resolves to nothing; before this pin the v4 model was found by the
176
+ # library's lenient fallback, and once model_type is named — as it must be, so
177
+ # the config stops depending on the installed release's defaults — that fallback
178
+ # is skipped and construction raises. Pinning the recognizer per language makes
179
+ # `--language ja` work by design rather than by luck.
180
+ def test_build_params_ja_uses_the_v4_recognizer():
181
+ p = _build_params("ja", _FakeLangRec, _FakeOCRVersion, _FakeModelType)
182
+ assert p["Rec.lang_type"] == "lang::japan"
183
+ assert p["Rec.ocr_version"] == "ocrver::v4"
184
+ # ...while detection and classification stay on v5: they are
185
+ # language-agnostic (one `ch` model each) in the v5 family.
186
+ assert p["Det.ocr_version"] == "ocrver::v5"
187
+ assert p["Cls.ocr_version"] == "ocrver::v5"
188
+
189
+
190
+ def test_build_params_languages_with_a_v5_recognizer_stay_on_v5():
191
+ for lang in ("en", "ru", "zh"):
192
+ p = _build_params(lang, _FakeLangRec, _FakeOCRVersion, _FakeModelType)
193
+ assert p["Rec.ocr_version"] == "ocrver::v5", lang
194
+
195
+
196
+ def test_build_params_multi_keeps_the_v5_ch_recognizer():
197
+ """`multi` means RapidOCR's default recognizer — which for v5 is `ch`."""
198
+ p = _build_params("multi", _FakeLangRec, _FakeOCRVersion, _FakeModelType)
199
+ assert p["Rec.ocr_version"] == "ocrver::v5"
200
+ assert "Rec.lang_type" not in p
201
+
202
+
136
203
  # --- ocr_to_markdown ---------------------------------------------------------
137
204
 
138
205
 
@@ -180,6 +247,21 @@ def test_render_zero_frames_still_emits_header():
180
247
  assert "frames: 0" in md
181
248
 
182
249
 
250
+ # task:2833 — the header line is the only trace of *what* read the frames, so it
251
+ # must not claim PP-OCRv5 for a language that has no v5 recognizer (Japanese is
252
+ # read by the v4 recognizer, see _LANGUAGE_REC_VERSION).
253
+ def test_render_names_the_v4_recognizer_for_japanese():
254
+ md = ocr_to_markdown(_VID, [(23, ["こんにちは"])], language="ja")
255
+ assert "PP-OCRv4" in md, "the ja header must name the recognizer that actually ran"
256
+ assert "language: ja" in md
257
+
258
+
259
+ def test_render_keeps_the_plain_v5_label_for_languages_that_have_one():
260
+ md = ocr_to_markdown(_VID, [(23, ["hello"])], language="en")
261
+ assert "engine: RapidOCR (PP-OCRv5)" in md
262
+ assert "PP-OCRv4" not in md
263
+
264
+
183
265
  # --- _load_engine: friendly missing-extra hint -------------------------------
184
266
 
185
267
 
@@ -195,7 +277,7 @@ def test_load_engine_friendly_hint_when_rapidocr_missing(monkeypatch):
195
277
  with pytest.raises(OcrError) as exc_info:
196
278
  ocr_mod._load_engine("en")
197
279
  msg = str(exc_info.value)
198
- assert 'pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"' in msg
280
+ assert 'pipx inject yt-tools-cli "rapidocr>=3.8,<4" "onnxruntime>=1.18"' in msg
199
281
  assert "pip install 'yt-tools-cli[ocr]'" in msg
200
282
 
201
283
 
@@ -294,7 +376,7 @@ def test_cli_missing_extra_prints_friendly_hint(tmp_path, monkeypatch, capsys):
294
376
  def _raise(_lang):
295
377
  raise OcrError(
296
378
  "yt-ocr requires the [ocr] extra:\n"
297
- ' pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"\n'
379
+ ' pipx inject yt-tools-cli "rapidocr>=3.8,<4" "onnxruntime>=1.18"\n'
298
380
  " # or\n"
299
381
  " pip install 'yt-tools-cli[ocr]'"
300
382
  )
@@ -304,7 +386,7 @@ def test_cli_missing_extra_prints_friendly_hint(tmp_path, monkeypatch, capsys):
304
386
 
305
387
  assert rc == 1
306
388
  err = capsys.readouterr().err
307
- assert 'pipx inject yt-tools-cli "rapidocr>=3.8,<3.9" "onnxruntime>=1.18"' in err
389
+ assert 'pipx inject yt-tools-cli "rapidocr>=3.8,<4" "onnxruntime>=1.18"' in err
308
390
  assert "pip install 'yt-tools-cli[ocr]'" in err
309
391
 
310
392
 
@@ -85,6 +85,25 @@ def engine():
85
85
  return ocr_mod._load_engine("en")
86
86
 
87
87
 
88
+ @pytest.mark.parametrize("language", ocr_mod.SUPPORTED_LANGUAGES)
89
+ def test_every_supported_language_constructs_an_engine(language):
90
+ """Every `--language` value must resolve to a model that exists (task:2833).
91
+
92
+ Language → model is not a free mapping: the recognizer is per language, and
93
+ PP-OCRv5 ships twelve of them with **no Japanese** among them — `--language
94
+ ja` asked for a model that does not exist and died at construction, on 3.8.4
95
+ as well as 3.9.2, while every test here (and the whole suite) stayed green
96
+ because only `en` was ever built. This is the guard for that class: it
97
+ exercises the real registry for every value the CLI advertises.
98
+
99
+ Construction, not OCR: what breaks is model resolution, and a Japanese
100
+ recognizer reading Latin text proves nothing. It does download a recognizer
101
+ per language (~8-16 MB, once per runner) — the same lazy fetch the first real
102
+ run pays, which is why this module already needs the network.
103
+ """
104
+ assert ocr_mod._load_engine(language) is not None
105
+
106
+
88
107
  def test_load_engine_constructs_the_declared_params(engine):
89
108
  """The 3.9.x failure mode: RapidOCR refuses the 3.8.x-shaped ``params`` dict."""
90
109
  assert engine is not None
@@ -214,31 +214,32 @@ 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.
217
+ def test_ocr_extra_keeps_the_rapidocr_3x_params_bound():
218
+ """The bound encodes a params-API boundary, not a version grudge (task:2833).
219
+
220
+ 0.24.2 capped `rapidocr<3.9`: `ocr.py` pinned `ocr_version=PP-OCRv5` but left
221
+ `model_type` at the library default, and 3.9 moved that default to
222
+ `PP-OCRv6 + small` where the v5 family has no `small` — so a clean install
223
+ died with `error: Invalid OCR configuration` ([[issue:77]]). The fix names
224
+ every component's version *and* model type, which makes the config
225
+ release-independent and lets 3.9.x back in; the remaining cap is the major
226
+ version, because that is the boundary a params-API change would cross.
227
+
228
+ Both halves are cheap to assert and hard to notice by hand: the verified
229
+ minors must stay installable (CI installs exactly them — see the matrix job)
230
+ and the next major must not slide in unannounced.
232
231
  """
233
232
  specs = _pyproject()["project"]["optional-dependencies"]["ocr"]
234
233
  req = Requirement(next(spec for spec in specs if _dist_name(spec) == "rapidocr"))
235
234
 
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"
235
+ for verified in ("3.8.4", "3.9.2"):
236
+ assert req.specifier.contains(verified), (
237
+ f"rapidocr {verified} is verified by tests/test_ocr_engine.py in CI and "
238
+ "must stay installable"
239
+ )
240
+ assert not req.specifier.contains("4.0.0"), (
241
+ "a rapidocr major could change the params API again — widen only with a "
242
+ "green engine test against that version"
242
243
  )
243
244
 
244
245
 
@@ -246,11 +247,12 @@ def test_ocr_inject_hint_carries_the_same_bound_as_the_extra():
246
247
  """Two install routes, one bound — the `pipx inject` hint has no metadata to inherit.
247
248
 
248
249
  ``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.
250
+ the user to install the pair by name — an inject resolves from PyPI directly,
251
+ so the refusal message never inherited the extra's bound and, in the 0.24.x
252
+ window, would have reproduced [[issue:77]] on a machine that followed it to
253
+ the letter. ``doctor``'s ``next_step`` and the README print the same line, so
254
+ the assertion is on the constant all three read from, not on one caller's
255
+ wording.
254
256
  """
255
257
  from yt_tools import extras # noqa: PLC0415 — local import keeps the packaging tests import-light
256
258
 
@@ -268,7 +270,7 @@ def test_ocr_inject_hint_carries_the_same_bound_as_the_extra():
268
270
  "the [ocr] extra and the inject hint must name the same requirements — "
269
271
  "the hint is what a user pastes when the extra is missing"
270
272
  )
271
- assert '"rapidocr>=3.8,<3.9"' in extras.inject_command("ocr"), (
273
+ assert '"rapidocr>=3.8,<4"' in extras.inject_command("ocr"), (
272
274
  "a bounded spec must be quoted: `>` and `<` are redirections in bash, cmd.exe and PowerShell"
273
275
  )
274
276
 
@@ -286,6 +288,13 @@ def test_ci_installs_the_ocr_extra_so_the_engine_test_actually_runs():
286
288
  assert 'pip install -e ".[full,ocr,test]"' in workflow, (
287
289
  "the pytest job must install [ocr], or tests/test_ocr_engine.py skips there too"
288
290
  )
291
+ # ...and the matrix legs are where the *unselected* minor gets exercised: the
292
+ # pytest job resolves whichever rapidocr the bound allows today.
293
+ for verified in ("3.8.4", "3.9.2"):
294
+ assert f'"{verified}"' in workflow, (
295
+ f"the ocr-engine-matrix job must cover rapidocr {verified} — a bound "
296
+ "claiming a version nothing runs against is how issue:77 shipped"
297
+ )
289
298
 
290
299
 
291
300
  @pytest.mark.parametrize("extra", ["audio", "full"])
@@ -44,7 +44,7 @@ EXTRA_MODULES: dict[str, tuple[str, ...]] = {
44
44
  EXTRA_PACKAGES: dict[str, tuple[str, ...]] = {
45
45
  "frames": ("scenedetect", "opencv-python"),
46
46
  "audio": ("librosa", "matplotlib"),
47
- "ocr": ("rapidocr>=3.8,<3.9", "onnxruntime>=1.18"),
47
+ "ocr": ("rapidocr>=3.8,<4", "onnxruntime>=1.18"),
48
48
  }
49
49
 
50
50
  #: The enrichment that cannot be a package extra. ``bpm-detector`` gives
@@ -105,7 +105,7 @@ def inject_command_for(extra_names: list[str] | tuple[str, ...]) -> str:
105
105
  """One command adding several extras — what ``doctor``'s ``next_step`` prints.
106
106
 
107
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
108
+ (``rapidocr>=3.8,<4``), and an unquoted ``>``/``<`` is a redirection in
109
109
  bash, cmd.exe and PowerShell alike — the printed command has to be
110
110
  pasteable, since being pasteable is the whole point of it.
111
111
  """
@@ -30,12 +30,35 @@ from yt_tools.core import (
30
30
  from yt_tools.extras import format_missing_extra
31
31
 
32
32
  ENGINE_LABEL = "RapidOCR (PP-OCRv5)"
33
+ #: Languages whose recognizer is not PP-OCRv5 — the label has to name what ran.
34
+ _ENGINE_LABEL_JA = "RapidOCR (PP-OCRv5 det/cls + PP-OCRv4 ja rec)"
33
35
  DEFAULT_LANGUAGE = "en"
34
36
  SUPPORTED_LANGUAGES = ("en", "ru", "ja", "zh", "multi")
35
37
 
36
38
  _FRAME_RE = re.compile(r"^frame_(\d+)(?:_(\d+))?\.jpg$")
37
39
  _NO_TEXT_MARKER = "_(no text detected)_"
38
40
 
41
+ #: Language → (``LangRec`` member name, recognizer ``OCRVersion`` member name).
42
+ #:
43
+ #: Detection and classification are language-agnostic in the v5 family (one `ch`
44
+ #: model each), but the recognizer is per language — and PP-OCRv5 ships twelve of
45
+ #: them (arabic, ch, cyrillic, devanagari, el, en, eslav, korean, latin, ta, te,
46
+ #: th), **Japanese not among them**: `japan_PP-OCRv4_rec_mobile` is the only
47
+ #: Japanese recognizer in both 3.8.x and 3.9.x registries. So the version is
48
+ #: named per language instead of forced globally. Two failure modes meet here, and
49
+ #: both shipped: with `Rec.ocr_version=PP-OCRv5` + `japan` the library's lenient
50
+ #: fallback used to pick the v4 model (working by luck), and once `model_type` is
51
+ #: named — which it must be, or the config depends on the installed release's
52
+ #: defaults ([[issue:77]]) — the fallback is skipped and construction raises
53
+ #: `Invalid OCR configuration`. Verified by building the engine for every
54
+ #: ``SUPPORTED_LANGUAGES`` entry on rapidocr 3.8.4 and 3.9.2 ([[task:2833]]).
55
+ _LANGUAGE_REC_VERSION: dict[str, tuple[str, str]] = {
56
+ "en": ("EN", "PPOCRV5"),
57
+ "ru": ("CYRILLIC", "PPOCRV5"),
58
+ "ja": ("JAPAN", "PPOCRV4"),
59
+ "zh": ("CH", "PPOCRV5"),
60
+ }
61
+
39
62
 
40
63
  class OcrError(RuntimeError):
41
64
  """Any OCR-side failure: missing extra, missing cache, model issues, etc."""
@@ -82,36 +105,58 @@ def discover_frames(frames_dir: Path) -> list[tuple[int, Path]]:
82
105
  # --- engine factory (lazy import) -------------------------------------------
83
106
 
84
107
 
85
- def _build_params(language: str, langrec_enum, ocrversion_enum=None) -> dict:
108
+ def _build_params(
109
+ language: str,
110
+ langrec_enum,
111
+ ocrversion_enum=None,
112
+ modeltype_enum=None,
113
+ ) -> dict:
86
114
  """Map user-facing language → RapidOCR params dict.
87
115
 
88
116
  Forces ``PP-OCRv5`` across Det / Cls / Rec when ``ocrversion_enum`` is
89
117
  supplied (RapidOCR 3.8.x defaults the bundled-model dance to v4 — we want
90
- v5 per design spec). For ``multi``, returns params **without** a
91
- ``Rec.lang_type`` override so RapidOCR picks its default multilingual
92
- model (Chinese+English).
118
+ v5 per design spec) **and names the model type** (``mobile``) alongside it,
119
+ when ``modeltype_enum`` is supplied.
120
+
121
+ Both halves are needed, and that is the whole lesson of [[issue:77]]: the
122
+ model type is a *version-dependent default*, not a constant. 3.8.x defaults
123
+ every component to ``PP-OCRv4 + mobile`` — which happens to be valid for v5
124
+ too; 3.9.x moved Det/Rec to ``PP-OCRv6 + small``, and the v5 family ships
125
+ ``mobile``/``server`` only, so a version-only pin asked for "PP-OCRv5 … small"
126
+ and RapidOCR refused the config at construction. Pinning the pair makes the
127
+ params mean the same thing on every supported release.
128
+
129
+ For ``multi``, returns params **without** a ``Rec.lang_type`` override so
130
+ RapidOCR picks its default multilingual model (Chinese+English).
93
131
  """
94
132
  params: dict = {}
95
133
  if ocrversion_enum is not None:
96
134
  v5 = ocrversion_enum.PPOCRV5
97
135
  params["Det.ocr_version"] = v5
98
136
  params["Cls.ocr_version"] = v5
137
+ # Overridden per language below: not every supported language has a v5
138
+ # recognizer (see _LANGUAGE_REC_VERSION).
99
139
  params["Rec.ocr_version"] = v5
140
+ if modeltype_enum is not None:
141
+ # `mobile` (not `server`): the small/fast bundle, matching the CPU-only
142
+ # onnxruntime install the extra brings, and the one key the v5 family
143
+ # has for every supported language/component.
144
+ mobile = modeltype_enum.MOBILE
145
+ params["Det.model_type"] = mobile
146
+ params["Cls.model_type"] = mobile
147
+ params["Rec.model_type"] = mobile
100
148
  if language == "multi":
101
149
  return params
102
- mapping = {
103
- "en": getattr(langrec_enum, "EN", None),
104
- "ru": getattr(langrec_enum, "CYRILLIC", None),
105
- "ja": getattr(langrec_enum, "JAPAN", None),
106
- "zh": getattr(langrec_enum, "CH", None),
107
- }
108
- lang_type = mapping.get(language)
150
+ member, rec_version = _LANGUAGE_REC_VERSION.get(language, (None, None))
151
+ lang_type = getattr(langrec_enum, member, None) if member else None
109
152
  if lang_type is None:
110
153
  raise OcrError(
111
154
  f"unknown language: {language!r} "
112
155
  f"(supported: {', '.join(SUPPORTED_LANGUAGES)})"
113
156
  )
114
157
  params["Rec.lang_type"] = lang_type
158
+ if ocrversion_enum is not None:
159
+ params["Rec.ocr_version"] = getattr(ocrversion_enum, rec_version)
115
160
  return params
116
161
 
117
162
 
@@ -123,12 +168,12 @@ def _load_engine(language: str):
123
168
  without it.
124
169
  """
125
170
  try:
126
- from rapidocr import LangRec, OCRVersion, RapidOCR # noqa: PLC0415
171
+ from rapidocr import LangRec, ModelType, OCRVersion, RapidOCR # noqa: PLC0415
127
172
  import onnxruntime # noqa: F401, PLC0415 — the [ocr] extra ships both; a half-installed pair must refuse here, not crash later
128
173
  except ImportError as e:
129
174
  # Same wording as every other CLI (D4) — the message lives in one place.
130
175
  raise OcrError(format_missing_extra("yt-ocr", "ocr")) from e
131
- params = _build_params(language, LangRec, OCRVersion)
176
+ params = _build_params(language, LangRec, OCRVersion, ModelType)
132
177
  return RapidOCR(params=params)
133
178
 
134
179
 
@@ -153,6 +198,18 @@ def _ocr_one(engine, image_path: Path) -> list[str]:
153
198
  # --- pure render -------------------------------------------------------------
154
199
 
155
200
 
201
+ def engine_label(language: str) -> str:
202
+ """Header label naming the recognizer that actually runs for ``language``.
203
+
204
+ Japanese is the one supported language with no PP-OCRv5 recognizer, so a
205
+ blanket "PP-OCRv5" line would be a claim the artefact cannot back — and the
206
+ header is the only place a reader (or an agent comparing two runs) can see
207
+ which models produced the text.
208
+ """
209
+ _, rec_version = _LANGUAGE_REC_VERSION.get(language, (None, "PPOCRV5"))
210
+ return _ENGINE_LABEL_JA if rec_version == "PPOCRV4" else ENGINE_LABEL
211
+
212
+
156
213
  def ocr_to_markdown(
157
214
  video_id: str,
158
215
  frames: list[tuple[int, list[str]]],
@@ -166,7 +223,7 @@ def ocr_to_markdown(
166
223
  f"# OCR — {video_id}",
167
224
  "",
168
225
  (
169
- f"generated: {generated} · engine: {ENGINE_LABEL}"
226
+ f"generated: {generated} · engine: {engine_label(language)}"
170
227
  f" · language: {language} · frames: {len(frames)}"
171
228
  ),
172
229
  "",
@@ -259,7 +316,8 @@ def main(argv: list[str] | None = None) -> int:
259
316
  default=DEFAULT_LANGUAGE,
260
317
  help=(
261
318
  f"OCR language (default: {DEFAULT_LANGUAGE}). "
262
- "multi = PP-OCR multilingual (Chinese+English)."
319
+ "multi = PP-OCR multilingual (Chinese+English). "
320
+ "ja uses the PP-OCRv4 recognizer: no PP-OCRv5 Japanese model exists."
263
321
  ),
264
322
  )
265
323
  args = parser.parse_args(argv)
File without changes
File without changes