saitenka 0.9.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 (264) hide show
  1. saitenka-0.9.0/.gitignore +9 -0
  2. saitenka-0.9.0/.importlinter +72 -0
  3. saitenka-0.9.0/.python-version +1 -0
  4. saitenka-0.9.0/.vulture_whitelist.py +15 -0
  5. saitenka-0.9.0/ARCHITECTURE.md +80 -0
  6. saitenka-0.9.0/BENCHMARKS.md +226 -0
  7. saitenka-0.9.0/PKG-INFO +231 -0
  8. saitenka-0.9.0/README.md +190 -0
  9. saitenka-0.9.0/RUNNING.md +250 -0
  10. saitenka-0.9.0/compare/.gitignore +2 -0
  11. saitenka-0.9.0/compare/README.md +54 -0
  12. saitenka-0.9.0/compare/cases.py +27 -0
  13. saitenka-0.9.0/compare/dump_transforms.mjs +18 -0
  14. saitenka-0.9.0/compare/generate.py +127 -0
  15. saitenka-0.9.0/compare/refs/azukeru_subminer.jpeg +0 -0
  16. saitenka-0.9.0/compare/refs/honmei_subminer.jpeg +0 -0
  17. saitenka-0.9.0/compare/refs/kikoeru_subminer.jpeg +0 -0
  18. saitenka-0.9.0/compare/yomitan_capture.py +89 -0
  19. saitenka-0.9.0/complexipy-snapshot.json +106 -0
  20. saitenka-0.9.0/examples/bench_parallelism.py +166 -0
  21. saitenka-0.9.0/examples/bench_responsiveness.py +1357 -0
  22. saitenka-0.9.0/examples/mpv_overlay.py +128 -0
  23. saitenka-0.9.0/examples/mpv_reader.py +13 -0
  24. saitenka-0.9.0/examples/render_png.py +43 -0
  25. saitenka-0.9.0/examples/subinterpreter_crash_repro.py +94 -0
  26. saitenka-0.9.0/examples/vocab.json +3650 -0
  27. saitenka-0.9.0/overlay.example.toml +140 -0
  28. saitenka-0.9.0/pyproject.toml +486 -0
  29. saitenka-0.9.0/rust/README.md +19 -0
  30. saitenka-0.9.0/sgconfig/rule-tests/no-model-derived-reading-test.yml +15 -0
  31. saitenka-0.9.0/sgconfig/rule-tests/no-print-in-lib-test.yml +14 -0
  32. saitenka-0.9.0/sgconfig/rule-tests/reader-thread-no-sleep-test.yml +24 -0
  33. saitenka-0.9.0/sgconfig/rule-tests/single-writer-pipe-test.yml +20 -0
  34. saitenka-0.9.0/sgconfig/rules/no-model-derived-reading.yml +18 -0
  35. saitenka-0.9.0/sgconfig/rules/no-print-in-lib.yml +20 -0
  36. saitenka-0.9.0/sgconfig/rules/reader-thread-no-sleep.yml +17 -0
  37. saitenka-0.9.0/sgconfig/rules/single-writer-pipe.yml +18 -0
  38. saitenka-0.9.0/sgconfig.yml +7 -0
  39. saitenka-0.9.0/src/overlay/__init__.py +12 -0
  40. saitenka-0.9.0/src/overlay/app/__init__.py +1 -0
  41. saitenka-0.9.0/src/overlay/app/anki.py +255 -0
  42. saitenka-0.9.0/src/overlay/app/card_preview.py +189 -0
  43. saitenka-0.9.0/src/overlay/app/cli.py +1081 -0
  44. saitenka-0.9.0/src/overlay/app/cli_run.py +732 -0
  45. saitenka-0.9.0/src/overlay/app/config.py +277 -0
  46. saitenka-0.9.0/src/overlay/app/conflicts.py +53 -0
  47. saitenka-0.9.0/src/overlay/app/controller.py +1093 -0
  48. saitenka-0.9.0/src/overlay/app/crashlog.py +176 -0
  49. saitenka-0.9.0/src/overlay/app/dictdb.py +588 -0
  50. saitenka-0.9.0/src/overlay/app/dictionary.py +603 -0
  51. saitenka-0.9.0/src/overlay/app/doctor.py +732 -0
  52. saitenka-0.9.0/src/overlay/app/embedded_subs.py +97 -0
  53. saitenka-0.9.0/src/overlay/app/fsrs.py +448 -0
  54. saitenka-0.9.0/src/overlay/app/init_wizard.py +183 -0
  55. saitenka-0.9.0/src/overlay/app/jimaku.py +347 -0
  56. saitenka-0.9.0/src/overlay/app/lifecycle.py +130 -0
  57. saitenka-0.9.0/src/overlay/app/loading.py +24 -0
  58. saitenka-0.9.0/src/overlay/app/logsetup.py +97 -0
  59. saitenka-0.9.0/src/overlay/app/lookup.py +196 -0
  60. saitenka-0.9.0/src/overlay/app/media.py +262 -0
  61. saitenka-0.9.0/src/overlay/app/miner.py +274 -0
  62. saitenka-0.9.0/src/overlay/app/miner_ui.py +209 -0
  63. saitenka-0.9.0/src/overlay/app/nested_popup.py +256 -0
  64. saitenka-0.9.0/src/overlay/app/otel_export.py +200 -0
  65. saitenka-0.9.0/src/overlay/app/overlay_ids.py +25 -0
  66. saitenka-0.9.0/src/overlay/app/paths.py +199 -0
  67. saitenka-0.9.0/src/overlay/app/perf.py +94 -0
  68. saitenka-0.9.0/src/overlay/app/plugin.py +115 -0
  69. saitenka-0.9.0/src/overlay/app/popups.py +123 -0
  70. saitenka-0.9.0/src/overlay/app/prefetch.py +372 -0
  71. saitenka-0.9.0/src/overlay/app/procutil.py +52 -0
  72. saitenka-0.9.0/src/overlay/app/progress.py +58 -0
  73. saitenka-0.9.0/src/overlay/app/reader_deps.py +355 -0
  74. saitenka-0.9.0/src/overlay/app/report.py +289 -0
  75. saitenka-0.9.0/src/overlay/app/resync.py +134 -0
  76. saitenka-0.9.0/src/overlay/app/scoring.py +223 -0
  77. saitenka-0.9.0/src/overlay/app/setup_wizard.py +442 -0
  78. saitenka-0.9.0/src/overlay/app/signals.py +33 -0
  79. saitenka-0.9.0/src/overlay/app/sub_index.py +276 -0
  80. saitenka-0.9.0/src/overlay/app/subnav.py +107 -0
  81. saitenka-0.9.0/src/overlay/app/subselect.py +127 -0
  82. saitenka-0.9.0/src/overlay/app/subtitles.py +191 -0
  83. saitenka-0.9.0/src/overlay/app/telemetry.py +237 -0
  84. saitenka-0.9.0/src/overlay/app/telemetry_toggle.py +95 -0
  85. saitenka-0.9.0/src/overlay/app/toast.py +35 -0
  86. saitenka-0.9.0/src/overlay/app/tokenize.py +212 -0
  87. saitenka-0.9.0/src/overlay/app/tooltip.py +875 -0
  88. saitenka-0.9.0/src/overlay/app/translation.py +84 -0
  89. saitenka-0.9.0/src/overlay/app/wordlists.py +536 -0
  90. saitenka-0.9.0/src/overlay/app/yomitan_db_import.py +240 -0
  91. saitenka-0.9.0/src/overlay/app/yomitan_import.py +307 -0
  92. saitenka-0.9.0/src/overlay/assets/__init__.py +20 -0
  93. saitenka-0.9.0/src/overlay/assets/fonts/NotoSans.ttf +0 -0
  94. saitenka-0.9.0/src/overlay/assets/fonts/NotoSansJP.ttf +0 -0
  95. saitenka-0.9.0/src/overlay/assets/saitenka.lua +55 -0
  96. saitenka-0.9.0/src/overlay/assets/wordlists/jlpt.zip +0 -0
  97. saitenka-0.9.0/src/overlay/body_block.py +73 -0
  98. saitenka-0.9.0/src/overlay/draw/__init__.py +1 -0
  99. saitenka-0.9.0/src/overlay/draw/chip.py +114 -0
  100. saitenka-0.9.0/src/overlay/draw/icons.py +84 -0
  101. saitenka-0.9.0/src/overlay/draw/pitch.py +77 -0
  102. saitenka-0.9.0/src/overlay/fonts.py +120 -0
  103. saitenka-0.9.0/src/overlay/model.py +110 -0
  104. saitenka-0.9.0/src/overlay/mpvio/__init__.py +7 -0
  105. saitenka-0.9.0/src/overlay/mpvio/compositor.py +44 -0
  106. saitenka-0.9.0/src/overlay/mpvio/discover.py +184 -0
  107. saitenka-0.9.0/src/overlay/mpvio/ipc.py +217 -0
  108. saitenka-0.9.0/src/overlay/mpvio/launch.py +78 -0
  109. saitenka-0.9.0/src/overlay/mpvio/osd.py +144 -0
  110. saitenka-0.9.0/src/overlay/mpvio/transport.py +80 -0
  111. saitenka-0.9.0/src/overlay/otel_metrics.py +401 -0
  112. saitenka-0.9.0/src/overlay/panel.py +673 -0
  113. saitenka-0.9.0/src/overlay/parallel.py +101 -0
  114. saitenka-0.9.0/src/overlay/raster/__init__.py +5 -0
  115. saitenka-0.9.0/src/overlay/raster/pillow_backend.py +24 -0
  116. saitenka-0.9.0/src/overlay/raster/protocol.py +43 -0
  117. saitenka-0.9.0/src/overlay/render/__init__.py +1 -0
  118. saitenka-0.9.0/src/overlay/render/banded.py +473 -0
  119. saitenka-0.9.0/src/overlay/render/document.py +162 -0
  120. saitenka-0.9.0/src/overlay/render/flow.py +422 -0
  121. saitenka-0.9.0/src/overlay/render/layout.py +247 -0
  122. saitenka-0.9.0/src/overlay/render/ruby.py +106 -0
  123. saitenka-0.9.0/src/overlay/render/text.py +60 -0
  124. saitenka-0.9.0/src/overlay/render/window.py +197 -0
  125. saitenka-0.9.0/src/overlay/resources.py +31 -0
  126. saitenka-0.9.0/src/overlay/sc/__init__.py +1 -0
  127. saitenka-0.9.0/src/overlay/sc/model.py +25 -0
  128. saitenka-0.9.0/src/overlay/sc/walk.py +338 -0
  129. saitenka-0.9.0/src/overlay/version.py +18 -0
  130. saitenka-0.9.0/tests/artifacts/mpv_live_screenshot.png +0 -0
  131. saitenka-0.9.0/tests/artifacts/mvp_reader_colored.png +0 -0
  132. saitenka-0.9.0/tests/artifacts/mvp_reader_hover.png +0 -0
  133. saitenka-0.9.0/tests/artifacts/pitch_graphs.png +0 -0
  134. saitenka-0.9.0/tests/artifacts/r2_deftags.png +0 -0
  135. saitenka-0.9.0/tests/artifacts/tooltip_honmei_real.png +0 -0
  136. saitenka-0.9.0/tests/artifacts/tooltip_jlpt_pill.png +0 -0
  137. saitenka-0.9.0/tests/artifacts/tooltip_kikoeru_chain.png +0 -0
  138. saitenka-0.9.0/tests/artifacts/tooltip_link_kinds.png +0 -0
  139. saitenka-0.9.0/tests/artifacts/tooltip_links.png +0 -0
  140. saitenka-0.9.0/tests/artifacts/tooltip_search_results.png +0 -0
  141. saitenka-0.9.0/tests/artifacts/translation_top.png +0 -0
  142. saitenka-0.9.0/tests/conftest.py +70 -0
  143. saitenka-0.9.0/tests/dicthelp.py +78 -0
  144. saitenka-0.9.0/tests/driver.py +80 -0
  145. saitenka-0.9.0/tests/fake_mpv.py +48 -0
  146. saitenka-0.9.0/tests/fake_mpv_server.py +73 -0
  147. saitenka-0.9.0/tests/fixtures/sc_list.json +25 -0
  148. saitenka-0.9.0/tests/fixtures/sc_ruby.json +14 -0
  149. saitenka-0.9.0/tests/fixtures/yomu.json +63 -0
  150. saitenka-0.9.0/tests/golden/cjk_mixed.png +0 -0
  151. saitenka-0.9.0/tests/golden/interaction_base_tooltip.png +0 -0
  152. saitenka-0.9.0/tests/golden/interaction_nested_popup.png +0 -0
  153. saitenka-0.9.0/tests/golden/kanji_panel.png +0 -0
  154. saitenka-0.9.0/tests/golden/mpv_composite.png +0 -0
  155. saitenka-0.9.0/tests/golden/panel_yomu.png +0 -0
  156. saitenka-0.9.0/tests/golden/pitch_graphs.png +0 -0
  157. saitenka-0.9.0/tests/golden/plain.png +0 -0
  158. saitenka-0.9.0/tests/golden/richtext.png +0 -0
  159. saitenka-0.9.0/tests/golden/ruby_flow.png +0 -0
  160. saitenka-0.9.0/tests/golden/ruby_narrow.png +0 -0
  161. saitenka-0.9.0/tests/golden/ruby_wide.png +0 -0
  162. saitenka-0.9.0/tests/golden/sample_trace.json +289 -0
  163. saitenka-0.9.0/tests/golden/sc_list.png +0 -0
  164. saitenka-0.9.0/tests/golden/sc_ruby.png +0 -0
  165. saitenka-0.9.0/tests/golden/subtitle_yomu.png +0 -0
  166. saitenka-0.9.0/tests/golden/wrap.png +0 -0
  167. saitenka-0.9.0/tests/test_anki_config.py +51 -0
  168. saitenka-0.9.0/tests/test_anki_launch.py +37 -0
  169. saitenka-0.9.0/tests/test_attach.py +156 -0
  170. saitenka-0.9.0/tests/test_banded_composite.py +128 -0
  171. saitenka-0.9.0/tests/test_banded_wiring.py +97 -0
  172. saitenka-0.9.0/tests/test_bench_pathological.py +62 -0
  173. saitenka-0.9.0/tests/test_block_cache.py +90 -0
  174. saitenka-0.9.0/tests/test_bundle_installers.py +103 -0
  175. saitenka-0.9.0/tests/test_card_preview.py +94 -0
  176. saitenka-0.9.0/tests/test_cli.py +245 -0
  177. saitenka-0.9.0/tests/test_cli_run_mpv_exit.py +34 -0
  178. saitenka-0.9.0/tests/test_cli_run_options.py +28 -0
  179. saitenka-0.9.0/tests/test_coloring.py +293 -0
  180. saitenka-0.9.0/tests/test_compare.py +64 -0
  181. saitenka-0.9.0/tests/test_config.py +61 -0
  182. saitenka-0.9.0/tests/test_conflicts.py +42 -0
  183. saitenka-0.9.0/tests/test_controller.py +1992 -0
  184. saitenka-0.9.0/tests/test_crashlog.py +102 -0
  185. saitenka-0.9.0/tests/test_dict_tabs.py +356 -0
  186. saitenka-0.9.0/tests/test_dictdb.py +352 -0
  187. saitenka-0.9.0/tests/test_dictionary.py +558 -0
  188. saitenka-0.9.0/tests/test_discover.py +69 -0
  189. saitenka-0.9.0/tests/test_doctor.py +437 -0
  190. saitenka-0.9.0/tests/test_embedded_subs.py +142 -0
  191. saitenka-0.9.0/tests/test_fakefs.py +47 -0
  192. saitenka-0.9.0/tests/test_fonts.py +41 -0
  193. saitenka-0.9.0/tests/test_fsrs.py +386 -0
  194. saitenka-0.9.0/tests/test_fsrs_properties.py +780 -0
  195. saitenka-0.9.0/tests/test_ft_gil.py +52 -0
  196. saitenka-0.9.0/tests/test_install_wheel.py +83 -0
  197. saitenka-0.9.0/tests/test_interaction.py +120 -0
  198. saitenka-0.9.0/tests/test_ipc_chaos.py +142 -0
  199. saitenka-0.9.0/tests/test_jimaku_client.py +277 -0
  200. saitenka-0.9.0/tests/test_jimaku_furigana.py +54 -0
  201. saitenka-0.9.0/tests/test_jimaku_key.py +183 -0
  202. saitenka-0.9.0/tests/test_kanji.py +162 -0
  203. saitenka-0.9.0/tests/test_launch.py +127 -0
  204. saitenka-0.9.0/tests/test_lazy_panel.py +399 -0
  205. saitenka-0.9.0/tests/test_lifecycle.py +112 -0
  206. saitenka-0.9.0/tests/test_live_mpv.py +144 -0
  207. saitenka-0.9.0/tests/test_loading.py +127 -0
  208. saitenka-0.9.0/tests/test_logsetup.py +72 -0
  209. saitenka-0.9.0/tests/test_media.py +86 -0
  210. saitenka-0.9.0/tests/test_mining.py +325 -0
  211. saitenka-0.9.0/tests/test_mvp_subtitle.py +60 -0
  212. saitenka-0.9.0/tests/test_mvp_tokenize_lookup.py +111 -0
  213. saitenka-0.9.0/tests/test_otel_export.py +283 -0
  214. saitenka-0.9.0/tests/test_otel_metrics.py +248 -0
  215. saitenka-0.9.0/tests/test_parallel.py +32 -0
  216. saitenka-0.9.0/tests/test_paths.py +143 -0
  217. saitenka-0.9.0/tests/test_perf.py +102 -0
  218. saitenka-0.9.0/tests/test_pitch_graph.py +122 -0
  219. saitenka-0.9.0/tests/test_plugin.py +84 -0
  220. saitenka-0.9.0/tests/test_prefetch_lookahead.py +135 -0
  221. saitenka-0.9.0/tests/test_procutil.py +22 -0
  222. saitenka-0.9.0/tests/test_progress.py +56 -0
  223. saitenka-0.9.0/tests/test_progressive.py +74 -0
  224. saitenka-0.9.0/tests/test_properties.py +147 -0
  225. saitenka-0.9.0/tests/test_property.py +69 -0
  226. saitenka-0.9.0/tests/test_raster_backend.py +80 -0
  227. saitenka-0.9.0/tests/test_reader_deps.py +121 -0
  228. saitenka-0.9.0/tests/test_report.py +131 -0
  229. saitenka-0.9.0/tests/test_resources.py +35 -0
  230. saitenka-0.9.0/tests/test_resync.py +281 -0
  231. saitenka-0.9.0/tests/test_sc_walk_parsing.py +100 -0
  232. saitenka-0.9.0/tests/test_scoring_properties.py +304 -0
  233. saitenka-0.9.0/tests/test_setup_wizard.py +203 -0
  234. saitenka-0.9.0/tests/test_signals.py +27 -0
  235. saitenka-0.9.0/tests/test_stage1_text.py +13 -0
  236. saitenka-0.9.0/tests/test_stage2_cjk.py +26 -0
  237. saitenka-0.9.0/tests/test_stage3_wrap.py +31 -0
  238. saitenka-0.9.0/tests/test_stage4_richtext.py +68 -0
  239. saitenka-0.9.0/tests/test_stage5_ruby.py +50 -0
  240. saitenka-0.9.0/tests/test_stage6_ruby_flow.py +44 -0
  241. saitenka-0.9.0/tests/test_stage7_sc.py +168 -0
  242. saitenka-0.9.0/tests/test_stage8_panel.py +50 -0
  243. saitenka-0.9.0/tests/test_stage9_compositor.py +35 -0
  244. saitenka-0.9.0/tests/test_stage9_ipc.py +177 -0
  245. saitenka-0.9.0/tests/test_stress.py +103 -0
  246. saitenka-0.9.0/tests/test_stress_memory.py +36 -0
  247. saitenka-0.9.0/tests/test_sub_index.py +156 -0
  248. saitenka-0.9.0/tests/test_sub_index_properties.py +184 -0
  249. saitenka-0.9.0/tests/test_subselect.py +132 -0
  250. saitenka-0.9.0/tests/test_telemetry.py +209 -0
  251. saitenka-0.9.0/tests/test_telemetry_toggle.py +96 -0
  252. saitenka-0.9.0/tests/test_transport_contract.py +239 -0
  253. saitenka-0.9.0/tests/test_window_geometry.py +174 -0
  254. saitenka-0.9.0/tests/test_windowed_hit.py +96 -0
  255. saitenka-0.9.0/tests/test_windowed_panel.py +177 -0
  256. saitenka-0.9.0/tests/test_windowed_prefetch.py +162 -0
  257. saitenka-0.9.0/tests/test_yomitan_db_import.py +168 -0
  258. saitenka-0.9.0/tests/test_yomitan_import.py +187 -0
  259. saitenka-0.9.0/tests/util.py +245 -0
  260. saitenka-0.9.0/tools/fuzz/fuzz_sub_index.py +39 -0
  261. saitenka-0.9.0/tools/mutate/run.py +67 -0
  262. saitenka-0.9.0/tools/semgrep/rules.yml +18 -0
  263. saitenka-0.9.0/tools/semgrep/uv.toml +15 -0
  264. saitenka-0.9.0/uv.lock +1927 -0
@@ -0,0 +1,9 @@
1
+ # build/test scratch
2
+ out.png
3
+ *.out.png
4
+ .pytest_cache/
5
+ dist/
6
+ *.egg-info/
7
+ .coverage
8
+ .pyscn/
9
+ .complexipy_cache/
@@ -0,0 +1,72 @@
1
+ [importlinter]
2
+ root_packages =
3
+ overlay
4
+ include_external_packages = True
5
+ exclude_type_checking_imports = True
6
+
7
+ [importlinter:contract:no-cycles]
8
+ name = No import cycles among overlay's top-level packages
9
+ type = acyclic_siblings
10
+ ancestors =
11
+ overlay
12
+ # Ratchet baseline (2026-07-23): overlay/ is NOT layer-pure today (sc/model.py:11 already imports
13
+ # overlay.render; sc/walk.py:19,230 too). These entries
14
+ # grandfather the pre-existing cycles found on adoption so the gate is green on day one; burn them
15
+ # down under the Stage 5 controller.py split, never add new ones. The mpvio<->app cycle (2 imports)
16
+ # was fixed outright, not ratcheted: overlay/app/otel_metrics.py moved to overlay/otel_metrics.py —
17
+ # it's a leaf instrumentation module with no app/ dependencies, so mpvio importing it from app/ was
18
+ # a pure layering accident, not a real coupling. A facade wasn't needed; relocating the module to its
19
+ # correct layer removed the backward edge entirely.
20
+ # Burned down 2026-07-25: render.document -> sc.model and otel_export -> telemetry were removed here
21
+ # when the ruff `TC` autofix moved those (typing-only) imports into TYPE_CHECKING blocks, so they are
22
+ # no longer runtime edges — the ratchet tightened for free. Never re-add; only remove as edges vanish.
23
+ ignore_imports =
24
+ overlay.draw.chip -> overlay.render.layout
25
+ overlay.app.dictdb -> overlay.app.yomitan_import
26
+ overlay.app.doctor -> overlay.app.crashlog
27
+ overlay.app.controller -> overlay.app.miner
28
+ overlay.app.report -> overlay.app.crashlog
29
+ overlay.app.dictdb -> overlay.app.wordlists
30
+
31
+ [importlinter:contract:pil-agnostic-core]
32
+ name = sc/ and model.py stay PIL-agnostic
33
+ type = forbidden
34
+ # Direct imports only (allow_indirect_imports): sc/ legitimately imports overlay.render for the
35
+ # Inline type (see the no-cycles ratchet above) and render/draw pull in PIL themselves — a
36
+ # transitive check would flag that pre-existing, accepted design. This preserves the original
37
+ # test_layering.py semantics (a literal `import PIL` line), just without the hand-parsed
38
+ # TYPE_CHECKING logic (exclude_type_checking_imports does that for free).
39
+ allow_indirect_imports = True
40
+ source_modules =
41
+ overlay.sc
42
+ overlay.model
43
+ forbidden_modules =
44
+ PIL
45
+
46
+ [importlinter:contract:pil-app-allowlist]
47
+ name = app/ imports PIL only via raster/ or the migration allowlist
48
+ type = forbidden
49
+ allow_indirect_imports = True
50
+ source_modules =
51
+ overlay.app
52
+ forbidden_modules =
53
+ PIL
54
+ # subtitles/toast/card_preview/controller migrate to the raster protocol opportunistically, later
55
+ # (see tests/test_layering.py docstring, pre-existing allowlist).
56
+ ignore_imports =
57
+ overlay.app.subtitles -> PIL
58
+ overlay.app.toast -> PIL
59
+ overlay.app.card_preview -> PIL
60
+ overlay.app.miner_ui -> PIL
61
+
62
+ [importlinter:contract:gpl-chokepoint]
63
+ name = only app.dictionary / app.doctor may import the GPL deinflect add-on
64
+ type = forbidden
65
+ source_modules =
66
+ overlay
67
+ forbidden_modules =
68
+ saitenka_deinflect
69
+ ignore_imports =
70
+ overlay.app.dictionary -> saitenka_deinflect
71
+ overlay.app.doctor -> saitenka_deinflect
72
+
@@ -0,0 +1 @@
1
+ 3.14+freethreaded
@@ -0,0 +1,15 @@
1
+ # Vulture whitelist — names that ARE used but look dead to static analysis because they're mandated by
2
+ # an external interface signature (the caller passes them positionally). Regenerate/extend after review:
3
+ # uvx vulture src --min-confidence 80 --make-whitelist >> .vulture_whitelist.py
4
+ # Advisory only (poe deadcode); not part of `all`. Keep this list tight — a genuine dead name hidden
5
+ # here defeats the point.
6
+
7
+ # structlog processor protocol: (logger, method_name, event_dict) — first two are required positionally.
8
+ logger
9
+ method_name
10
+
11
+ # OpenTelemetry SpanExporter override signatures require these params even when unused.
12
+ timeout_millis
13
+
14
+ # POSIX signal handler protocol: handler(signum, frame).
15
+ signum
@@ -0,0 +1,80 @@
1
+ # Architecture
2
+
3
+ ## What this is
4
+
5
+ `saitenka` renders Yomitan `structured-content` (styled CJK text with ruby/furigana) as an
6
+ image, composited directly into mpv's own OSD surface via `overlay-add` — one surface, no second
7
+ window, so it survives fullscreen and sidesteps the Windows airspace/MPO bugs a second window would
8
+ hit. Beyond the renderer, the codebase bolts a full reader onto mpv: subtitle draw with per-word
9
+ hitboxes, hover → dictionary lookup → tooltip, word coloring by known/frequency/JLPT state, and
10
+ one-key Anki mining.
11
+
12
+ Design strategy: "simplest tool first, escalate on limits" (see README's Escalation ladder) —
13
+ Pillow does the rendering today; Rust + cosmic-text + the libmpv render API is the fallback only if
14
+ Pillow hits a real wall (per-frame animation, huge panels, GPU scaling).
15
+
16
+ ## Module map
17
+
18
+ - **`sc/`** — Yomitan `structured-content` model and parsing (the input format). Kept PIL-agnostic
19
+ (enforced by `.importlinter`).
20
+ - **`render/`** — layout/flow: the text walker, ruby positioning, line wrapping, panel chrome.
21
+ `render.flow` is the core. `render/window.py` is the PIL-free geometry kernel (block offset table +
22
+ half-open visible-range) and `render/banded.py` the **windowed (banded) tooltip engine**
23
+ (`WindowedPanel`): render only the blocks in the viewport±overscan, retain heights/hit-geometry past
24
+ pixel eviction, composite O(viewport) — pixel-identical to a `render_panel` crop. Wired into the base
25
+ tooltip behind `[tooltip].banded` / `SAITENKA_BANDED=1` (off by default; blob-slice path is default).
26
+ - **`draw/`** — rasterization primitives that paint the laid-out content.
27
+ - **`raster/`** + top-level **`panel`** — compose the final RGBA panel image; `Definition`/`Entry`
28
+ (in `panel.py`) hold one dictionary's rendered entry for a word. Value types with no render deps —
29
+ `model.Theme`, `version.overlay_version` — live at the package root to keep `render`/`app` acyclic.
30
+ - **`parallel.py`** — the CPU-bound-render executor policy: free-threaded threads (FreeType releases
31
+ the GIL, faces are thread-local; ~78% of the render tail is `getmask2`/`getlength`), process-pool
32
+ fallback on a GIL build. Sub-interpreters are out (PIL's C extension segfaults across them).
33
+ - **`mpvio/`** — the mpv IPC bridge: JSON-IPC transport (`ipc.py`), mpv/ffmpeg discovery
34
+ (`discover.py`), pushing panels into mpv's OSD surface (`osd.py`).
35
+ - **`app/`** — the application layer. `controller.py`'s `Reader` is the main-loop orchestrator
36
+ (poll mpv → tokenize → hover hit-test → lookup → mine); `tokenize.py` (fugashi/unidic-lite word
37
+ segmentation); `dictionary.py`/`dictdb.py`/`lookup.py` (the consolidated SQLite dictionary DB);
38
+ `scoring.py`/`wordlists.py`/`fsrs.py` (word coloring); `anki.py`/`miner.py` (mining); `jimaku.py`
39
+ (subtitle fetching); `cli.py`/`cli_run.py` (the entry point — thin parser + real orchestration).
40
+
41
+ ## Data flow (the hover → lookup → render → mine chain)
42
+
43
+ 1. `Reader` polls mpv's `sub-text`/`mouse-pos` over IPC (event-driven `observe_property`).
44
+ 2. Each subtitle line is tokenized (`tokenize()`, fugashi + unidic-lite) into `Token`s with
45
+ per-word hitboxes, drawn as an OSD overlay.
46
+ 3. On hover, hit-testing maps screen coordinates to a token; the word's lemma is looked up against
47
+ the consolidated dictionary DB, producing an `Entry` (one `Definition` per configured
48
+ dictionary).
49
+ 4. The panel code (`panel.py`) walks the `Definition`s' structured content into a rendered tooltip
50
+ image via `render/` → `draw/`, composited over the mpv frame.
51
+ 5. Optionally, mining the hovered word builds an Anki note via AnkiConnect (`anki.py`, `miner.py`):
52
+ sentence, screenshot, audio clip, provenance.
53
+
54
+ ## Load-bearing decisions
55
+
56
+ - **Single-surface compositing** (`overlay-add`, not a second window) is the whole point — it's
57
+ what makes this airspace-safe on Windows fullscreen.
58
+ - **Dictionaries are imported once** into a consolidated SQLite DB (the Yomitan model); play-time
59
+ only opens it — nothing rebuilds during playback, RAM stays low.
60
+ - **GPL-3.0 `saitenka_deinflect` is chokepointed**: only `app/dictionary.py` and `app/doctor.py`
61
+ may import it (enforced by import-linter + ruff `TID251` + the license gate) — keeps the core
62
+ Apache-2.0-clean.
63
+ - **Free-threaded runtime** (Python ≥3.13, adopts 3.14t where available) — `assert`s across the
64
+ codebase double as GIL-off guardrails.
65
+
66
+ ## Test doubles (for the mpv boundary)
67
+
68
+ - **`FakeIPC`** (`tests/util.py`) — in-process double for the mpv IPC client; feeds
69
+ subtitle/mouse properties and property-change events so `Reader`'s full loop runs without a real
70
+ mpv.
71
+ - **`Driver`** (`tests/driver.py`) — wraps a `Reader` + `FakeIPC`, drives it through the *real*
72
+ input path (mouse moves, clicks, keys) so tests read as interaction scripts while still
73
+ exercising genuine hit-testing.
74
+ - **`FakeMpvServer`** (`tests/fake_mpv_server.py`) — a real unix-socket server double, one layer
75
+ lower than `FakeIPC`, for attach-mode/transport tests needing actual socket/connection behavior.
76
+
77
+ ---
78
+
79
+ Dependencies: `pyproject.toml`. Setup/run/test steps: README.md / RUNNING.md. Task-by-task dev
80
+ gate: the `dev-gate` skill.
@@ -0,0 +1,226 @@
1
+ # Responsiveness benchmark — in-mpv tooltip
2
+
3
+ The perceived snappiness of the overlay is gated by a handful of latencies. This is the saved baseline
4
+ so future changes can be compared against it. Regenerate with:
5
+
6
+ ```
7
+ uv run python examples/bench_responsiveness.py --reps 12
8
+ ```
9
+
10
+ It runs headless against the real dict set via a fake mpv IPC, so numbers **exclude mpv's own
11
+ compositing + the socket round-trip** (a small, ~constant add) but include the real dictionary lookups,
12
+ structured-content layout, BGRA conversion, and the temp-file upload write. "Cold" = OS/SQLite page
13
+ cache warm but our per-word panel cache cleared (a fresh word mid-session); the very first hover after
14
+ launch is slower because the 1.3 GB MonoB index is read from disk once.
15
+
16
+ ## KPIs and targets
17
+
18
+ Ranked by what the eye notices. These are the numbers to watch for regressions:
19
+
20
+ | KPI | Why it matters | Target |
21
+ |---|---|---|
22
+ | **Warm hover** (prefetched → shown) | the *common* case — prefetch warms the line while you read | **< 16 ms** |
23
+ | **Cold first paint** (hover → first pixels) | the headline; the viewport-first head | **p50 < 100 ms, p95 < 250 ms** |
24
+ | **Scroll frame** (one wheel step) | must stay under one display frame or scrolling stutters | **< 16 ms (60 fps)** |
25
+ | **Poll-tick hover hit-test** | per-tick cost must be tiny vs the 25 ms poll interval | **< 5 ms** |
26
+ | **Nested popup first paint** | first paint for an inner (scanned) word | **< 150 ms** |
27
+
28
+ Secondary / diagnostic only: time-to-complete (the tail streams in behind the head, so it isn't
29
+ blocking), cold sweep total (mitigated by prefetch), and the lookup / head-render / BGRA components
30
+ (for locating *where* a regression is). "Scroll speed" is not a latency — it's px/step
31
+ (`round(osd·0.12)` ≈ 130 px, coalesced per tick); what makes it feel good is the frame cost above.
32
+
33
+ ## Baseline — 2026-07-21
34
+
35
+ Env: Apple M3 Pro · macOS 25.5.0 (arm64) · Python 3.13.5 · overlay commit `9d1864e` · single-threaded
36
+ (prefetch off, so the head path is measured directly). Line `門前の小僧習わぬ経を読む`, 1080p,
37
+ `tip_width` 640, `cap` 648 px. Dict set: 6 dicts + 7 freq + 1 pitch (`~/.config/saitenka/overlay.toml`).
38
+
39
+ | metric | p50 | p95 | mean | min | (ms) |
40
+ |---|---|---|---|---|---|
41
+ | first paint (cold: head render + upload) | 49.0 | 510.7 | 116.9 | 26.3 | |
42
+ | time-to-complete (finish deferred tail) | 138.0 | 152.0 | 139.9 | 131.9 | |
43
+ | warm hover (prefetched → upload only) | 1.3 | 5.2 | 2.1 | 0.7 | |
44
+ | scroll frame (one 130 px step) | 1.9 | 8.6 | 3.0 | 0.7 | |
45
+ | nested popup first paint (inner word) | 121.1 | 129.0 | 121.6 | 115.4 | |
46
+ | poll tick hover hit-test (`_update_hover`) | 0.4 | 0.5 | 0.5 | 0.4 | |
47
+ | horizontal sweep: cold, 5 words (total) | 539.5 | 735.8 | 584.6 | 503.4 | |
48
+ | horizontal sweep: warm, 5 words (total) | 5.0 | 20.1 | 7.2 | 3.4 | |
49
+ | *component:* dict lookup, 5 words | 36.3 | 52.5 | 36.7 | 30.7 | |
50
+ | *component:* head render, 5 words | 330.6 | 350.0 | 330.6 | 312.7 | |
51
+ | *component:* BGRA convert, tallest head | 89.2 | 102.2 | 92.1 | 87.4 | |
52
+
53
+ Verdict: warm hover, scroll, and hit-test are all far inside budget; cold first paint p50 is instant.
54
+
55
+ ## Known weakness
56
+
57
+ Cold first-paint **p95 (~510 ms)** and BGRA-of-tallest (~90 ms): viewport-first renders **whole rows**
58
+ until it covers `cap`, so if a word's *first* definition body is very tall (a big MonoB entry), the
59
+ "head" overshoots to ~2000 px and costs nearly as much as the full panel. The ~860 ms → ~50 ms win
60
+ holds for typical words but not for a word whose first dict entry is enormous. Lever (future item):
61
+ **clip / stream the first def body itself**, not just defer later bodies.
62
+
63
+ ## Pathological corpus — baseline 2026-07-21 (before Stage 6/7 levers)
64
+
65
+ The worst first-lookup words: the 3 largest-glossary entries per dict (auto-discovered from the built
66
+ SQLite indexes) + hand-picked multi-sense words. Regenerate with:
67
+
68
+ ```
69
+ uv run python examples/bench_responsiveness.py --pathological --reps 8
70
+ ```
71
+
72
+ Env: Apple M3 Pro · macOS 25.5.0 · Python 3.14.6 (3.14t) · 1080p · tip_width 640 · cap 648 px ·
73
+ 6 dicts + 7 freq + 1 pitch. **Targets: cold p95 < 150 ms per word · first-hover-after-launch < 300 ms.**
74
+
75
+ First-hover-after-launch (fresh SQLite connections, 上げる): **290.8 ms** (target met, barely; OS file
76
+ cache warm — no sudo purge).
77
+
78
+ | word | source | p50 | p95 | max | (ms) |
79
+ |---|---|---|---|---|---|
80
+ | 上げる | Bilingual | 181.8 | 183.7 | 183.7 | |
81
+ | 挙げる | Bilingual | 178.5 | 187.5 | 187.5 | |
82
+ | 揚げる | Bilingual | 180.0 | 181.6 | 181.6 | |
83
+ | 気 | Bilingual2 | 234.7 | 244.0 | 244.0 | |
84
+ | 手 | Bilingual2 | 157.4 | 158.8 | 158.8 | |
85
+ | 目 | Bilingual2 | 168.5 | 202.0 | 202.0 | |
86
+ | に | MonoC | 94.5 | 96.9 | 96.9 | |
87
+ | の | MonoC | 82.4 | 84.3 | 84.3 | |
88
+ | 取る | MonoC | 323.9 | 326.5 | 326.5 | |
89
+ | 眼 | MonoD | 170.6 | 172.7 | 172.7 | |
90
+ | とる | MonoB | 328.2 | 329.2 | 329.2 | |
91
+ | 捕らぬ狸の皮算用 | MonoB | 124.6 | 125.6 | 125.6 | |
92
+ | 取るに足りない | MonoB | 125.2 | 128.0 | 128.0 | |
93
+ | 執る | MonoE | 321.7 | 329.0 | 329.0 | |
94
+ | 採る | MonoE | 326.4 | 338.8 | 338.8 | |
95
+ | 出る | hand-picked | 131.9 | 137.2 | 137.2 | |
96
+ | かける | hand-picked | 247.3 | 284.8 | 284.8 | |
97
+ | 見る | hand-picked | 89.4 | 91.6 | 91.6 | |
98
+ | 行く | hand-picked | 116.6 | 118.3 | 118.3 | |
99
+ | いい | hand-picked | 46.4 | 47.1 | 47.1 | |
100
+ | **WORST** | over all words | **328.2** | **338.8** | **338.8** | |
101
+
102
+ Verdict: 9 of 20 words MISS the 150 ms p95 target — the 取る family (~330 ms) and 気/かける (~250–285 ms)
103
+ are the words whose first def body is a single enormous block. This is the Stage 6 lever's job.
104
+
105
+ ## After Stage 6 — deferred walk + mid-def raster clip (2026-07-21)
106
+
107
+ Profiling showed the assumed culprit (raster overshoot) was only half the story: `panel_rows` walked
108
+ EVERY def's structured content eagerly at build time, and the SC-walk of one 取る-class def alone costs
109
+ ~230 ms. Stage 6 therefore (a) moved the walk inside the deferred row thunks (one row per def body —
110
+ the head only walks the defs the viewport shows), and (b) added mid-def raster clipping
111
+ (`render_document`/`render_flow` `max_height`): the boundary def paints only the covering strip and
112
+ finish() re-renders it fully, so the composed full panel stays byte-identical.
113
+
114
+ Pathological corpus after Stage 6 (same env/flags):
115
+
116
+ | KPI | baseline | after Stage 6 | target |
117
+ |---|---|---|---|
118
+ | WORST cold p50 (over 20 words) | 328.2 ms | **127.8 ms** | |
119
+ | WORST cold p95 | 338.8 ms | **132.1 ms** | < 150 ms ✅ (all 20 words) |
120
+ | WORST cold max | 338.8 ms | 132.1 ms | |
121
+ | first-hover-after-launch (上げる) | 290.8 ms | **118.7 ms** | < 300 ms ✅ |
122
+
123
+ Standard smoke-line benchmark also improved across the board (reps 8):
124
+
125
+ | metric | baseline (2026-07-21) | after Stage 6 |
126
+ |---|---|---|
127
+ | first paint cold p50 / p95 | 49.0 / 510.7 | **21.8 / 46.1** |
128
+ | nested popup first paint p50 | 121.1 | **34.1** |
129
+ | horizontal sweep cold (5 words) p50 | 539.5 | **150.0** |
130
+ | BGRA convert, tallest head | 89.2 | **6.3** (head is now a bounded strip) |
131
+ | warm hover p50 / scroll frame p50 | 1.3 / 1.9 | 0.5 / 0.6 |
132
+
133
+ ## After Stage 7 — BGRA LUT · SQLite mmap · observe_property (2026-07-21)
134
+
135
+ Three independent levers, all byte-identical / behavior-preserving:
136
+
137
+ 1. **BGRA LUT** (`osd.to_bgra_array`): the per-pixel uint16 widen×multiply÷255 premultiply replaced
138
+ by a flat `np.take` gather from a precomputed 256×256 table (64 KB, L2-resident). Property test
139
+ pins byte-identity vs the reference formula over random RGBA.
140
+ 2. **SQLite mmap** (`dictionary.Dictionary._conn`): `PRAGMA mmap_size=1073741824` +
141
+ `cache_size=-65536` (64 MiB) on every read-only per-thread connection — cold lookups hit mapped
142
+ memory instead of pread round-trips.
143
+ 3. **observe_property** (`controller`): `sub-text`/`mouse-pos`/`osd-dimensions`/`pause`/
144
+ `secondary-sub-text` are now event-driven — `run()` registers `observe_property` + one seeding
145
+ read each, and the poll loop consumes buffered `property-change` events. The 3–5 blocking
146
+ `get_property` round-trips per 25 ms tick are gone (this saves real-mpv socket latency that the
147
+ fake-IPC benchmark below cannot see). Dwell/hysteresis timers still tick on the loop.
148
+
149
+ Pathological corpus (same env/flags): WORST cold p95 **132.1 → 133.2 ms** (noise-level — the corpus is
150
+ CPU-bound and page-cache-warm, so levers 2–3 don't show here), first-hover-after-launch 118.7 →
151
+ 118.4 ms. Standard smoke line: first paint cold p50/p95 29.3/64.4, sweep cold 145.0, warm hover 0.5,
152
+ scroll 0.5 — all within noise of the post-Stage-6 numbers. The mmap + observe_property wins are in
153
+ disk-cold first hovers and live-mpv tick latency, both outside this harness's measurement envelope;
154
+ targets remain met with margin (cold p95 < 150 ms ✅ all words · first-hover < 300 ms ✅).
155
+
156
+ ## Harness upgrades — tail latency, GIL guardrail, upload isolation, stress (2026-07-22, v0.2.0)
157
+
158
+ Since this is a **real-time overlay** (it must not stall the poll loop or drop a video frame), the
159
+ harness now reports the jank tail and the runtime that produced it, not just means:
160
+
161
+ - **p99 + CV** on every metric. p99 is the jank tail (a p99 over the 16.7/33 ms frame budget drops a
162
+ frame even when p50 looks fine); CV (stdev/mean) is the run-to-run stability that decides whether a
163
+ metric is safe to regression-gate at all.
164
+ - **Runtime line + GIL guardrail.** Every run records `Py_GIL_DISABLED` and the *live* `sys._is_gil_enabled()`
165
+ read **after** the workload (fugashi re-enables the GIL on first use, not at import). `--require-ft`
166
+ fails the run if the GIL came back — catching the silent worker-scaling collapse.
167
+ - **Layer-isolated timing.** The cold path is split into `dict lookup` / `head render` / `BGRA convert`
168
+ / **`upload write` (warm reuse vs cold fresh+fsync)**. This settled the suspected ~55 ms temp-file
169
+ "floor": the write is **~1 ms** (warm and cold alike) — the number in older notes was the whole cold
170
+ first-paint, which is **render + lookup bound**, not IO. So mmap/shared-memory upload is not worth it.
171
+ - **`--json`** emits a diffable baseline (metrics + runtime).
172
+
173
+ ### `--stress` — sustained chained session
174
+
175
+ `bench_responsiveness.py --stress` chains cold hover → scroll → nested popup → scroll → dismiss over
176
+ 60+ distinct heavy entries for N rounds, surfacing what the isolated micro-benchmarks can't: panel-cache
177
+ eviction thrash (the 48-entry LRU cap), nested-state churn, and memory growth across a session. It
178
+ reports the per-op frame-latency tail (**MAX** = the jank signal) + peak RSS + growth, and can gate on
179
+ `--max-frame-ms` / `--max-rss-mb`. The robustness half is always-on in the gate (`tests/test_stress.py`:
180
+ no crash, cache stays ≤ 48, tooltip/nested overlays torn down with no ghost).
181
+
182
+ First run on the full 19-dict rig flagged **MAX ~950 ms / p99 ~920 ms** per op — cold pathological
183
+ monolingual entries blowing the frame budget under load (the known cold-p95 weakness, now visible in a
184
+ sustained scenario). Confirms the open lever: **clip/stream the first def body**, not just defer later ones.
185
+
186
+ ### `--timeline` — idle-paced session (the felt-experience ground truth)
187
+
188
+ `--stress` is deliberately worst-case: zero idle time, a shrunk 24-entry cache (vs. the real 128
189
+ default), back-to-back heavy words. Real usage is the opposite — idle dominates (video plays, mouse
190
+ doesn't move) punctuated by occasional hovers, with the background prefetch worker (`prefetch_lookahead`)
191
+ warming ahead during the idle gaps. `--timeline` (`vibe/hot-path-idle-spreading-plan.md` Stage 1) models
192
+ that: synthetic subtitle cues built from the real episode vocabulary (`examples/vocab.json` — 608 words
193
+ from one Nippon Sangoku episode), advanced on a real clock (`time.sleep` between cues, so the real
194
+ prefetch threads get real wall-clock idle time, not simulated time), with occasional injected hovers.
195
+ Reports hover latency split **idle-warm** (the word's dictionary entries were already decoded before the
196
+ hover) vs. **cold** (decoded synchronously, on hover), plus the worker's **lead time** (enqueued-as-upcoming
197
+ → decoded) against the idle budget it actually had (`lookahead × dwell`).
198
+
199
+ Baseline — 2026-07-26, defaults (`--timeline-cues 80 --timeline-dwell-s 0.3 --timeline-lookahead 3`,
200
+ 900ms idle budget), 9 dicts + 9 freq + 1 pitch:
201
+
202
+ | metric | p50 | p95 | max | n |
203
+ |---|---|---|---|---|
204
+ | hover latency — idle-warm | 36.8 | 75.7 | 75.7 | 20 |
205
+ | hover latency — cold | — | — | — | 0 |
206
+ | worker lead time (enqueued → decoded) | 175.5 | 410.6 | 410.6 | 19 |
207
+
208
+ **0/20 misses** — at this dwell/lookahead the worker never fell behind; every hover in the run landed
209
+ on an already-decoded word. **But "idle-warm" is not free**: idle-time warming (Stage 2) is decode-only
210
+ (`entry_for`, no layout) — it does *not* pre-render the panel (Stage 4, pre-compiling heads, is explicitly
211
+ deferred/opt-in because a pathological word's full render costs seconds, see `panel_mem.py` in the plan).
212
+ So a first hover on a passively-watched (never engaged/paused) line still pays real layout + BGRA + upload
213
+ cost even when fully idle-warm — this 36.8ms p50 is *that* cost, not the ~1-2ms "warm hover" KPI above,
214
+ which assumes a FULL panel prefetch (only triggered once the user is already engaged — paused or hovering
215
+ the video — which normal passive reading isn't, until the moment of the hover itself). Confirms the plan's
216
+ Stage 4 lever (opt-in current-line head pre-compile) is the next step if this 30-75ms first-hover cost on
217
+ a fresh line ever needs to shrink further; not yet built.
218
+
219
+ ### Profiling & continuous benchmarking (verified 2026-07)
220
+
221
+ To *explain* a regression (not just report it): **Scalene** is the one profiler verified to work on
222
+ free-threaded 3.13t/3.14t (full CPU+memory, with a GIL-activity timeline) — use it for "is this
223
+ GIL/native/alloc bound". `py-spy --native` and `viztracer` are great for GIL-on runs but their
224
+ free-threaded support is **unconfirmed** (both read/monitor interpreter internals that no-GIL changes) —
225
+ check their trackers before relying. `pytest-benchmark` auto-disables under `pytest-xdist`, so any
226
+ micro-suite must run serially, separate from the `-n auto` gate — the custom harness stays primary.
@@ -0,0 +1,231 @@
1
+ Metadata-Version: 2.4
2
+ Name: saitenka
3
+ Version: 0.9.0
4
+ Summary: Rich-text + ruby renderer for the native in-mpv Yomitan overlay (Saitenka)
5
+ Requires-Python: >=3.13
6
+ Requires-Dist: certifi>=2026.6.17
7
+ Requires-Dist: cyclopts>=4.22.1
8
+ Requires-Dist: filelock>=3.32.0
9
+ Requires-Dist: fonttools>=4.55
10
+ Requires-Dist: fugashi>=1.5.2
11
+ Requires-Dist: ijson>=3.5.1
12
+ Requires-Dist: keyring>=25.7.0
13
+ Requires-Dist: msgspec>=0.21.1
14
+ Requires-Dist: numpy>=2.0
15
+ Requires-Dist: pillow>=11.0
16
+ Requires-Dist: platformdirs>=4.11.0
17
+ Requires-Dist: psutil>=7.2.2
18
+ Requires-Dist: rich>=14.0
19
+ Requires-Dist: stamina>=26.1.0
20
+ Requires-Dist: structlog>=26.1.0
21
+ Requires-Dist: tomlkit>=0.15.1
22
+ Requires-Dist: unidic-lite>=1.0.8
23
+ Provides-Extra: deinflect
24
+ Requires-Dist: saitenka-deinflect; extra == 'deinflect'
25
+ Provides-Extra: full
26
+ Requires-Dist: jamdict-data-fix>=1.5.1a2; (platform_system == 'Windows') and extra == 'full'
27
+ Requires-Dist: jamdict-data>=1.5; (platform_system != 'Windows') and extra == 'full'
28
+ Requires-Dist: jamdict>=0.1a11.post2; extra == 'full'
29
+ Requires-Dist: opentelemetry-api>=1.44.0; extra == 'full'
30
+ Requires-Dist: opentelemetry-sdk>=1.44.0; extra == 'full'
31
+ Requires-Dist: saitenka-deinflect; extra == 'full'
32
+ Provides-Extra: jmdict
33
+ Requires-Dist: jamdict-data-fix>=1.5.1a2; (platform_system == 'Windows') and extra == 'jmdict'
34
+ Requires-Dist: jamdict-data>=1.5; (platform_system != 'Windows') and extra == 'jmdict'
35
+ Requires-Dist: jamdict>=0.1a11.post2; extra == 'jmdict'
36
+ Provides-Extra: minimal
37
+ Provides-Extra: telemetry
38
+ Requires-Dist: opentelemetry-api>=1.44.0; extra == 'telemetry'
39
+ Requires-Dist: opentelemetry-sdk>=1.44.0; extra == 'telemetry'
40
+ Description-Content-Type: text/markdown
41
+
42
+ # overlay — rich-text + ruby renderer for the in-mpv Yomitan panel
43
+
44
+ Renders Yomitan `structured-content` (styled, wrapping CJK text with **ruby/furigana**) into a
45
+ panel **image**, so it can be composited over mpv video in a **single surface** (no second
46
+ top-level window → no Windows airspace/MPO/fullscreen bugs).
47
+
48
+ ## Why Python + Pillow first
49
+
50
+ Per the "simplest tool first, escalate on limits" rule: Pillow is the simplest thing that can
51
+ rasterize styled CJK text + custom ruby positioning, and mpv's `overlay-add` IPC command can push
52
+ a rendered RGBA image straight into mpv's own OSD surface — airspace-safe, no GL/FFI. We nail the
53
+ **visual design** here (matching the real 読む popup) before escalating to Rust + cosmic-text +
54
+ the libmpv render API if/when Pillow hits a wall.
55
+
56
+ ## Layout
57
+
58
+ - `src/overlay/` — the library (`fonts`, `render/`, `model`, `sc/`, `panel`, `draw/`).
59
+ - `assets/fonts/` — **vendored** Noto Sans JP (variable) + Noto Sans, so golden images reproduce
60
+ across machines (macOS / Windows / Linux).
61
+ - `tests/golden/` — golden PNGs; `tests/fixtures/` — structured-content JSON.
62
+ - `examples/render_png.py` — CLI: render a string or a fixture JSON to a PNG.
63
+
64
+ Module-by-module map and the hover→lookup→render→mine data flow: [ARCHITECTURE.md](ARCHITECTURE.md).
65
+
66
+ ## Usage
67
+
68
+ ```bash
69
+ uv sync
70
+ uv run python examples/render_png.py --text "Saitenka" -o out.png
71
+ uv run python examples/render_png.py --entry tests/fixtures/yomu.json --bg white -o panel.png
72
+ uv run pytest # golden tests
73
+ SAITENKA_UPDATE_GOLDEN=1 uv run pytest # regenerate goldens (inspect the diff first!)
74
+ uv run python examples/bench_responsiveness.py # UI latency vs the saved baseline (BENCHMARKS.md)
75
+ ```
76
+
77
+ Responsiveness KPIs, targets, and the saved baseline live in [`BENCHMARKS.md`](BENCHMARKS.md).
78
+
79
+ ## Bolt to mpv (the airspace-safe cure)
80
+
81
+ The panel is pushed into mpv's **own OSD surface** via the `overlay-add` JSON-IPC command — one
82
+ surface, no second window, so it survives fullscreen (the Electron overlay bug can't recur). No GL,
83
+ no FFI, no Rust.
84
+
85
+ ```bash
86
+ # play a file and show the 読む panel top-left; press 'f' in mpv to test fullscreen
87
+ uv run python examples/mpv_overlay.py /path/to/video.mkv
88
+
89
+ # no file: generate a test clip, screenshot the composited mpv window, quit
90
+ uv run python examples/mpv_overlay.py --screenshot /tmp/shot.png --seconds 2
91
+ ```
92
+
93
+ A real mpv-window screenshot proving the composite is at `tests/artifacts/mpv_live_screenshot.png`.
94
+
95
+ ## MVP reader — subtitles + hover tooltip
96
+
97
+ `examples/mpv_reader.py` is the working MVP: mpv plays a video, we hide its native subs and draw our
98
+ own SubMiner-style subtitle (with per-word hitboxes), poll the mouse, and on **hover** look the word up
99
+ (fugashi lemma → JMdict via jamdict) and draw a Yomitan-like tooltip — all in mpv's OSD surface.
100
+
101
+ ```bash
102
+ uv run python examples/mpv_reader.py video.mkv --sub-file jp.srt # hover words with the mouse
103
+ uv run python examples/mpv_reader.py # test clip + generated JP line
104
+ uv run python examples/mpv_reader.py --demo-word 読む --screenshot /tmp/reader.png # screenshot demo
105
+ ```
106
+
107
+ Result (real mpv window): `tests/artifacts/mvp_reader_hover.png`. The dictionary adapter
108
+ (`app/lookup.py`) emits the same `Entry` the renderer already draws, so a monolingual / structured-
109
+ content dictionary can be swapped in behind it later without touching the renderer.
110
+
111
+ ### Word coloring (SubMiner-parity, FSRS-aware)
112
+
113
+ `app/scoring.py` colors each subtitle word: **N+1 > known > frequency-band > base** text color, plus a
114
+ JLPT-level **underline** — the exact priority model SubMiner uses (Catppuccin palette). Known words come
115
+ from Anki (`app/wordlists.py::KnownWords.from_ankiconnect`, decks→fields like SubMiner) or a static set;
116
+ frequency from any Yomitan freq zip (user-supplied, e.g. under `tools/freq/`); JLPT from the vendored
117
+ `assets/wordlists/jlpt.zip`. Result: `tests/artifacts/mvp_reader_colored.png`.
118
+
119
+ ```bash
120
+ uv run python examples/mpv_reader.py --sub-file jp.srt --color \
121
+ --known "私,本,経" --freq "Frequency General"
122
+ # or pull the known-set from Anki:
123
+ uv run python examples/mpv_reader.py --sub-file jp.srt --color \
124
+ --anki-decks '{"Kaishi 1.5k":["Word"]}'
125
+ ```
126
+
127
+ ### Subtitle source: embedded / jimaku / file
128
+
129
+ The reader takes subs from (in order): `--sub-file` → `--jimaku` (fetch from jimaku.cc, needs
130
+ `$JIMAKU_API_KEY`) → the video's **embedded** JP track (`--slang ja,jpn`, auto-selected, native
131
+ rendering hidden). Amazon-style **inline furigana** baked into ASS (`龍門光英りゅうもんみつひで`) is
132
+ stripped before tokenizing (`app/tokenize.py::strip_inline_furigana`).
133
+
134
+ ```bash
135
+ # real anime with an embedded JP track + your real Anki known-set (FSRS deck):
136
+ uv run python examples/mpv_reader.py "Nippon Sangoku - 10 [...MultiSub...].mkv" \
137
+ --color --anki-decks '{"Saitenka::Known":["Entry","Expression","Word"]}' \
138
+ --freq "Frequency General"
139
+
140
+ # a file with no JP subs → fetch from jimaku (title/episode parsed from the filename):
141
+ uv run python examples/mpv_reader.py show.mkv --jimaku --color
142
+ ```
143
+
144
+ ### One-key mining (on one surface)
145
+
146
+ `--mine` enables mining: hover a word, press the mine key (default `Ctrl+m`) → a **Lapis** card is
147
+ created via AnkiConnect with Expression / reading / **Sentence** (mined word bolded) / **Glossary** /
148
+ **Picture** (clean frame) / **SentenceAudio** (the subtitle's audio span, ffmpeg mp3) / provenance /
149
+ JMdict ID. Dedup checks the deck first (no silent duplicates); a toast confirms (`✚ mined …` /
150
+ `● already have …` / `× …`). All inside mpv's surface — no texthooker, no second window.
151
+
152
+ ```bash
153
+ uv run python examples/mpv_reader.py episode.mkv --color \
154
+ --anki-decks '{"Saitenka::Known":["Entry","Expression","Word"]}' \
155
+ --freq "Frequency General" \
156
+ --mine --mine-deck "Saitenka::Mining" --mine-model Lapis
157
+ # then hover a word in mpv and press Ctrl+m
158
+ ```
159
+
160
+ Verified end-to-end on a real episode: the card is built with a real frame jpg + subtitle mp3,
161
+ deduped, then cleaned up.
162
+
163
+ - **Bulk mining** — `Shift+m` mines every unknown content word in the current cue, all sharing one
164
+ screenshot + audio clip; toast reports `mined N · M dup`.
165
+ - **Card preview (verify without alt-tab)** — after mining, a **fixed-layout** panel shows the card so
166
+ you can check it's right: status, headword + reading, the sentence (mined word bolded), the meaning,
167
+ the **actual captured frame**, and the audio (`▶ Ns`, which **auto-plays** so you hear the clip). Mining
168
+ an already-present word previews the **existing** card instead (image + audio pulled from Anki). `p`
169
+ replays the last preview + audio. It's composed from our own primitives — no card CSS — purely to
170
+ verify correctness / image / sound.
171
+
172
+ ### Multi-dictionary tooltip (Yomitan-style, ordered)
173
+
174
+ Import any **Yomitan term-bank** dictionaries (bilingual and/or monolingual — whichever you have) once
175
+ with `saitenka import <dir>`; they build into a single **consolidated database**
176
+ (`~/.local/share/saitenka/dictionaries.sqlite`, the Yomitan model). Then `--dict "Title A" --dict
177
+ "Title B" …` (or the config lists) shows the word across all of them, **in order**, each as its own
178
+ section with the dict-name pill and rich structured content (ruby examples, notes, cross-refs). Runtime
179
+ only opens the DB — nothing is rebuilt at play time, and RAM stays low. Falls back to JMdict/jamdict when
180
+ no dictionary is configured.
181
+
182
+ ### Official-translation reveal (anti-crutch)
183
+
184
+ Default is **JP primary (mining) + EN secondary**: the reader auto-selects the JP sub track (`--slang
185
+ ja,jpn,jp`) and the embedded EN track as mpv's *secondary* sub (hidden). Press `t` to toggle the EN
186
+ line for the current cue above the JP subtitle — the professional translation on demand, not by default.
187
+
188
+ ```bash
189
+ saitenka import ~/yomitan-dicts # once: build the DB, register the titles in the config
190
+ uv run python examples/mpv_reader.py episode.mkv --color \
191
+ --anki-decks '{"Saitenka::Known":["Entry"]}' --mine \
192
+ --dict "Bilingual Dict" \
193
+ --dict "Monolingual Dict A" \
194
+ --dict "Monolingual Dict B"
195
+ # hover a word; Ctrl+m mine · Shift+m mine-all · t translation
196
+ ```
197
+
198
+ ## Escalation ladder (simplest tool first)
199
+
200
+ Pillow + `overlay-add` is the simplest thing that renders this and gets it onto mpv. If it hits a
201
+ wall — per-frame animation, huge panels, GPU scaling, live interactivity — escalate to Rust +
202
+ cosmic-text + the libmpv render API (see `rust/README.md`). The renderer here (walker, ruby, chrome,
203
+ goldens) is the spec that escalation must match.
204
+
205
+ ## Sharing it with a friend (self-contained bundle)
206
+
207
+ No PyPI or public repo needed. Build one shareable archive and send it:
208
+
209
+ ```bash
210
+ uv run poe bundle # → dist/saitenka-<ver>.zip
211
+ ```
212
+
213
+ The zip carries the wheel (all fonts/wordlists/lua/data ride inside it via `importlib.resources`),
214
+ both bootstrap installers, and an INSTALL.txt. Your friend unzips and runs the stub for their OS:
215
+
216
+ ```bash
217
+ bash overlay-install.sh # macOS / Linux (--dry-run to preview)
218
+ powershell -ExecutionPolicy Bypass -File overlay-install.ps1 # Windows
219
+ ```
220
+
221
+ The stub's only job is to get `uv`, `uv tool install ./<wheel>`, and hand off to
222
+ `saitenka setup` — an interactive Python wizard that inventories the box, installs mpv +
223
+ ffmpeg (macOS `brew`; Windows winget→choco→scoop; Linux prints copy-paste hints), runs `doctor`,
224
+ writes the config (`init`), and offers `import-settings` + `install-plugin`. Every step is
225
+ confirm-first, `--yes`/`--dry-run` are honoured, and it is resumable (re-runs skip satisfied steps).
226
+ Upgrade = re-run with a newer bundle (`uv tool install --reinstall ./<wheel>`).
227
+
228
+ ## Development
229
+
230
+ Local task runner (no CI); `uv run poe all` is the pre-push gate. Full task-by-task breakdown and
231
+ traps: [RUNNING.md](RUNNING.md) §9 / the `dev-gate` skill.