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.
- saitenka-0.9.0/.gitignore +9 -0
- saitenka-0.9.0/.importlinter +72 -0
- saitenka-0.9.0/.python-version +1 -0
- saitenka-0.9.0/.vulture_whitelist.py +15 -0
- saitenka-0.9.0/ARCHITECTURE.md +80 -0
- saitenka-0.9.0/BENCHMARKS.md +226 -0
- saitenka-0.9.0/PKG-INFO +231 -0
- saitenka-0.9.0/README.md +190 -0
- saitenka-0.9.0/RUNNING.md +250 -0
- saitenka-0.9.0/compare/.gitignore +2 -0
- saitenka-0.9.0/compare/README.md +54 -0
- saitenka-0.9.0/compare/cases.py +27 -0
- saitenka-0.9.0/compare/dump_transforms.mjs +18 -0
- saitenka-0.9.0/compare/generate.py +127 -0
- saitenka-0.9.0/compare/refs/azukeru_subminer.jpeg +0 -0
- saitenka-0.9.0/compare/refs/honmei_subminer.jpeg +0 -0
- saitenka-0.9.0/compare/refs/kikoeru_subminer.jpeg +0 -0
- saitenka-0.9.0/compare/yomitan_capture.py +89 -0
- saitenka-0.9.0/complexipy-snapshot.json +106 -0
- saitenka-0.9.0/examples/bench_parallelism.py +166 -0
- saitenka-0.9.0/examples/bench_responsiveness.py +1357 -0
- saitenka-0.9.0/examples/mpv_overlay.py +128 -0
- saitenka-0.9.0/examples/mpv_reader.py +13 -0
- saitenka-0.9.0/examples/render_png.py +43 -0
- saitenka-0.9.0/examples/subinterpreter_crash_repro.py +94 -0
- saitenka-0.9.0/examples/vocab.json +3650 -0
- saitenka-0.9.0/overlay.example.toml +140 -0
- saitenka-0.9.0/pyproject.toml +486 -0
- saitenka-0.9.0/rust/README.md +19 -0
- saitenka-0.9.0/sgconfig/rule-tests/no-model-derived-reading-test.yml +15 -0
- saitenka-0.9.0/sgconfig/rule-tests/no-print-in-lib-test.yml +14 -0
- saitenka-0.9.0/sgconfig/rule-tests/reader-thread-no-sleep-test.yml +24 -0
- saitenka-0.9.0/sgconfig/rule-tests/single-writer-pipe-test.yml +20 -0
- saitenka-0.9.0/sgconfig/rules/no-model-derived-reading.yml +18 -0
- saitenka-0.9.0/sgconfig/rules/no-print-in-lib.yml +20 -0
- saitenka-0.9.0/sgconfig/rules/reader-thread-no-sleep.yml +17 -0
- saitenka-0.9.0/sgconfig/rules/single-writer-pipe.yml +18 -0
- saitenka-0.9.0/sgconfig.yml +7 -0
- saitenka-0.9.0/src/overlay/__init__.py +12 -0
- saitenka-0.9.0/src/overlay/app/__init__.py +1 -0
- saitenka-0.9.0/src/overlay/app/anki.py +255 -0
- saitenka-0.9.0/src/overlay/app/card_preview.py +189 -0
- saitenka-0.9.0/src/overlay/app/cli.py +1081 -0
- saitenka-0.9.0/src/overlay/app/cli_run.py +732 -0
- saitenka-0.9.0/src/overlay/app/config.py +277 -0
- saitenka-0.9.0/src/overlay/app/conflicts.py +53 -0
- saitenka-0.9.0/src/overlay/app/controller.py +1093 -0
- saitenka-0.9.0/src/overlay/app/crashlog.py +176 -0
- saitenka-0.9.0/src/overlay/app/dictdb.py +588 -0
- saitenka-0.9.0/src/overlay/app/dictionary.py +603 -0
- saitenka-0.9.0/src/overlay/app/doctor.py +732 -0
- saitenka-0.9.0/src/overlay/app/embedded_subs.py +97 -0
- saitenka-0.9.0/src/overlay/app/fsrs.py +448 -0
- saitenka-0.9.0/src/overlay/app/init_wizard.py +183 -0
- saitenka-0.9.0/src/overlay/app/jimaku.py +347 -0
- saitenka-0.9.0/src/overlay/app/lifecycle.py +130 -0
- saitenka-0.9.0/src/overlay/app/loading.py +24 -0
- saitenka-0.9.0/src/overlay/app/logsetup.py +97 -0
- saitenka-0.9.0/src/overlay/app/lookup.py +196 -0
- saitenka-0.9.0/src/overlay/app/media.py +262 -0
- saitenka-0.9.0/src/overlay/app/miner.py +274 -0
- saitenka-0.9.0/src/overlay/app/miner_ui.py +209 -0
- saitenka-0.9.0/src/overlay/app/nested_popup.py +256 -0
- saitenka-0.9.0/src/overlay/app/otel_export.py +200 -0
- saitenka-0.9.0/src/overlay/app/overlay_ids.py +25 -0
- saitenka-0.9.0/src/overlay/app/paths.py +199 -0
- saitenka-0.9.0/src/overlay/app/perf.py +94 -0
- saitenka-0.9.0/src/overlay/app/plugin.py +115 -0
- saitenka-0.9.0/src/overlay/app/popups.py +123 -0
- saitenka-0.9.0/src/overlay/app/prefetch.py +372 -0
- saitenka-0.9.0/src/overlay/app/procutil.py +52 -0
- saitenka-0.9.0/src/overlay/app/progress.py +58 -0
- saitenka-0.9.0/src/overlay/app/reader_deps.py +355 -0
- saitenka-0.9.0/src/overlay/app/report.py +289 -0
- saitenka-0.9.0/src/overlay/app/resync.py +134 -0
- saitenka-0.9.0/src/overlay/app/scoring.py +223 -0
- saitenka-0.9.0/src/overlay/app/setup_wizard.py +442 -0
- saitenka-0.9.0/src/overlay/app/signals.py +33 -0
- saitenka-0.9.0/src/overlay/app/sub_index.py +276 -0
- saitenka-0.9.0/src/overlay/app/subnav.py +107 -0
- saitenka-0.9.0/src/overlay/app/subselect.py +127 -0
- saitenka-0.9.0/src/overlay/app/subtitles.py +191 -0
- saitenka-0.9.0/src/overlay/app/telemetry.py +237 -0
- saitenka-0.9.0/src/overlay/app/telemetry_toggle.py +95 -0
- saitenka-0.9.0/src/overlay/app/toast.py +35 -0
- saitenka-0.9.0/src/overlay/app/tokenize.py +212 -0
- saitenka-0.9.0/src/overlay/app/tooltip.py +875 -0
- saitenka-0.9.0/src/overlay/app/translation.py +84 -0
- saitenka-0.9.0/src/overlay/app/wordlists.py +536 -0
- saitenka-0.9.0/src/overlay/app/yomitan_db_import.py +240 -0
- saitenka-0.9.0/src/overlay/app/yomitan_import.py +307 -0
- saitenka-0.9.0/src/overlay/assets/__init__.py +20 -0
- saitenka-0.9.0/src/overlay/assets/fonts/NotoSans.ttf +0 -0
- saitenka-0.9.0/src/overlay/assets/fonts/NotoSansJP.ttf +0 -0
- saitenka-0.9.0/src/overlay/assets/saitenka.lua +55 -0
- saitenka-0.9.0/src/overlay/assets/wordlists/jlpt.zip +0 -0
- saitenka-0.9.0/src/overlay/body_block.py +73 -0
- saitenka-0.9.0/src/overlay/draw/__init__.py +1 -0
- saitenka-0.9.0/src/overlay/draw/chip.py +114 -0
- saitenka-0.9.0/src/overlay/draw/icons.py +84 -0
- saitenka-0.9.0/src/overlay/draw/pitch.py +77 -0
- saitenka-0.9.0/src/overlay/fonts.py +120 -0
- saitenka-0.9.0/src/overlay/model.py +110 -0
- saitenka-0.9.0/src/overlay/mpvio/__init__.py +7 -0
- saitenka-0.9.0/src/overlay/mpvio/compositor.py +44 -0
- saitenka-0.9.0/src/overlay/mpvio/discover.py +184 -0
- saitenka-0.9.0/src/overlay/mpvio/ipc.py +217 -0
- saitenka-0.9.0/src/overlay/mpvio/launch.py +78 -0
- saitenka-0.9.0/src/overlay/mpvio/osd.py +144 -0
- saitenka-0.9.0/src/overlay/mpvio/transport.py +80 -0
- saitenka-0.9.0/src/overlay/otel_metrics.py +401 -0
- saitenka-0.9.0/src/overlay/panel.py +673 -0
- saitenka-0.9.0/src/overlay/parallel.py +101 -0
- saitenka-0.9.0/src/overlay/raster/__init__.py +5 -0
- saitenka-0.9.0/src/overlay/raster/pillow_backend.py +24 -0
- saitenka-0.9.0/src/overlay/raster/protocol.py +43 -0
- saitenka-0.9.0/src/overlay/render/__init__.py +1 -0
- saitenka-0.9.0/src/overlay/render/banded.py +473 -0
- saitenka-0.9.0/src/overlay/render/document.py +162 -0
- saitenka-0.9.0/src/overlay/render/flow.py +422 -0
- saitenka-0.9.0/src/overlay/render/layout.py +247 -0
- saitenka-0.9.0/src/overlay/render/ruby.py +106 -0
- saitenka-0.9.0/src/overlay/render/text.py +60 -0
- saitenka-0.9.0/src/overlay/render/window.py +197 -0
- saitenka-0.9.0/src/overlay/resources.py +31 -0
- saitenka-0.9.0/src/overlay/sc/__init__.py +1 -0
- saitenka-0.9.0/src/overlay/sc/model.py +25 -0
- saitenka-0.9.0/src/overlay/sc/walk.py +338 -0
- saitenka-0.9.0/src/overlay/version.py +18 -0
- saitenka-0.9.0/tests/artifacts/mpv_live_screenshot.png +0 -0
- saitenka-0.9.0/tests/artifacts/mvp_reader_colored.png +0 -0
- saitenka-0.9.0/tests/artifacts/mvp_reader_hover.png +0 -0
- saitenka-0.9.0/tests/artifacts/pitch_graphs.png +0 -0
- saitenka-0.9.0/tests/artifacts/r2_deftags.png +0 -0
- saitenka-0.9.0/tests/artifacts/tooltip_honmei_real.png +0 -0
- saitenka-0.9.0/tests/artifacts/tooltip_jlpt_pill.png +0 -0
- saitenka-0.9.0/tests/artifacts/tooltip_kikoeru_chain.png +0 -0
- saitenka-0.9.0/tests/artifacts/tooltip_link_kinds.png +0 -0
- saitenka-0.9.0/tests/artifacts/tooltip_links.png +0 -0
- saitenka-0.9.0/tests/artifacts/tooltip_search_results.png +0 -0
- saitenka-0.9.0/tests/artifacts/translation_top.png +0 -0
- saitenka-0.9.0/tests/conftest.py +70 -0
- saitenka-0.9.0/tests/dicthelp.py +78 -0
- saitenka-0.9.0/tests/driver.py +80 -0
- saitenka-0.9.0/tests/fake_mpv.py +48 -0
- saitenka-0.9.0/tests/fake_mpv_server.py +73 -0
- saitenka-0.9.0/tests/fixtures/sc_list.json +25 -0
- saitenka-0.9.0/tests/fixtures/sc_ruby.json +14 -0
- saitenka-0.9.0/tests/fixtures/yomu.json +63 -0
- saitenka-0.9.0/tests/golden/cjk_mixed.png +0 -0
- saitenka-0.9.0/tests/golden/interaction_base_tooltip.png +0 -0
- saitenka-0.9.0/tests/golden/interaction_nested_popup.png +0 -0
- saitenka-0.9.0/tests/golden/kanji_panel.png +0 -0
- saitenka-0.9.0/tests/golden/mpv_composite.png +0 -0
- saitenka-0.9.0/tests/golden/panel_yomu.png +0 -0
- saitenka-0.9.0/tests/golden/pitch_graphs.png +0 -0
- saitenka-0.9.0/tests/golden/plain.png +0 -0
- saitenka-0.9.0/tests/golden/richtext.png +0 -0
- saitenka-0.9.0/tests/golden/ruby_flow.png +0 -0
- saitenka-0.9.0/tests/golden/ruby_narrow.png +0 -0
- saitenka-0.9.0/tests/golden/ruby_wide.png +0 -0
- saitenka-0.9.0/tests/golden/sample_trace.json +289 -0
- saitenka-0.9.0/tests/golden/sc_list.png +0 -0
- saitenka-0.9.0/tests/golden/sc_ruby.png +0 -0
- saitenka-0.9.0/tests/golden/subtitle_yomu.png +0 -0
- saitenka-0.9.0/tests/golden/wrap.png +0 -0
- saitenka-0.9.0/tests/test_anki_config.py +51 -0
- saitenka-0.9.0/tests/test_anki_launch.py +37 -0
- saitenka-0.9.0/tests/test_attach.py +156 -0
- saitenka-0.9.0/tests/test_banded_composite.py +128 -0
- saitenka-0.9.0/tests/test_banded_wiring.py +97 -0
- saitenka-0.9.0/tests/test_bench_pathological.py +62 -0
- saitenka-0.9.0/tests/test_block_cache.py +90 -0
- saitenka-0.9.0/tests/test_bundle_installers.py +103 -0
- saitenka-0.9.0/tests/test_card_preview.py +94 -0
- saitenka-0.9.0/tests/test_cli.py +245 -0
- saitenka-0.9.0/tests/test_cli_run_mpv_exit.py +34 -0
- saitenka-0.9.0/tests/test_cli_run_options.py +28 -0
- saitenka-0.9.0/tests/test_coloring.py +293 -0
- saitenka-0.9.0/tests/test_compare.py +64 -0
- saitenka-0.9.0/tests/test_config.py +61 -0
- saitenka-0.9.0/tests/test_conflicts.py +42 -0
- saitenka-0.9.0/tests/test_controller.py +1992 -0
- saitenka-0.9.0/tests/test_crashlog.py +102 -0
- saitenka-0.9.0/tests/test_dict_tabs.py +356 -0
- saitenka-0.9.0/tests/test_dictdb.py +352 -0
- saitenka-0.9.0/tests/test_dictionary.py +558 -0
- saitenka-0.9.0/tests/test_discover.py +69 -0
- saitenka-0.9.0/tests/test_doctor.py +437 -0
- saitenka-0.9.0/tests/test_embedded_subs.py +142 -0
- saitenka-0.9.0/tests/test_fakefs.py +47 -0
- saitenka-0.9.0/tests/test_fonts.py +41 -0
- saitenka-0.9.0/tests/test_fsrs.py +386 -0
- saitenka-0.9.0/tests/test_fsrs_properties.py +780 -0
- saitenka-0.9.0/tests/test_ft_gil.py +52 -0
- saitenka-0.9.0/tests/test_install_wheel.py +83 -0
- saitenka-0.9.0/tests/test_interaction.py +120 -0
- saitenka-0.9.0/tests/test_ipc_chaos.py +142 -0
- saitenka-0.9.0/tests/test_jimaku_client.py +277 -0
- saitenka-0.9.0/tests/test_jimaku_furigana.py +54 -0
- saitenka-0.9.0/tests/test_jimaku_key.py +183 -0
- saitenka-0.9.0/tests/test_kanji.py +162 -0
- saitenka-0.9.0/tests/test_launch.py +127 -0
- saitenka-0.9.0/tests/test_lazy_panel.py +399 -0
- saitenka-0.9.0/tests/test_lifecycle.py +112 -0
- saitenka-0.9.0/tests/test_live_mpv.py +144 -0
- saitenka-0.9.0/tests/test_loading.py +127 -0
- saitenka-0.9.0/tests/test_logsetup.py +72 -0
- saitenka-0.9.0/tests/test_media.py +86 -0
- saitenka-0.9.0/tests/test_mining.py +325 -0
- saitenka-0.9.0/tests/test_mvp_subtitle.py +60 -0
- saitenka-0.9.0/tests/test_mvp_tokenize_lookup.py +111 -0
- saitenka-0.9.0/tests/test_otel_export.py +283 -0
- saitenka-0.9.0/tests/test_otel_metrics.py +248 -0
- saitenka-0.9.0/tests/test_parallel.py +32 -0
- saitenka-0.9.0/tests/test_paths.py +143 -0
- saitenka-0.9.0/tests/test_perf.py +102 -0
- saitenka-0.9.0/tests/test_pitch_graph.py +122 -0
- saitenka-0.9.0/tests/test_plugin.py +84 -0
- saitenka-0.9.0/tests/test_prefetch_lookahead.py +135 -0
- saitenka-0.9.0/tests/test_procutil.py +22 -0
- saitenka-0.9.0/tests/test_progress.py +56 -0
- saitenka-0.9.0/tests/test_progressive.py +74 -0
- saitenka-0.9.0/tests/test_properties.py +147 -0
- saitenka-0.9.0/tests/test_property.py +69 -0
- saitenka-0.9.0/tests/test_raster_backend.py +80 -0
- saitenka-0.9.0/tests/test_reader_deps.py +121 -0
- saitenka-0.9.0/tests/test_report.py +131 -0
- saitenka-0.9.0/tests/test_resources.py +35 -0
- saitenka-0.9.0/tests/test_resync.py +281 -0
- saitenka-0.9.0/tests/test_sc_walk_parsing.py +100 -0
- saitenka-0.9.0/tests/test_scoring_properties.py +304 -0
- saitenka-0.9.0/tests/test_setup_wizard.py +203 -0
- saitenka-0.9.0/tests/test_signals.py +27 -0
- saitenka-0.9.0/tests/test_stage1_text.py +13 -0
- saitenka-0.9.0/tests/test_stage2_cjk.py +26 -0
- saitenka-0.9.0/tests/test_stage3_wrap.py +31 -0
- saitenka-0.9.0/tests/test_stage4_richtext.py +68 -0
- saitenka-0.9.0/tests/test_stage5_ruby.py +50 -0
- saitenka-0.9.0/tests/test_stage6_ruby_flow.py +44 -0
- saitenka-0.9.0/tests/test_stage7_sc.py +168 -0
- saitenka-0.9.0/tests/test_stage8_panel.py +50 -0
- saitenka-0.9.0/tests/test_stage9_compositor.py +35 -0
- saitenka-0.9.0/tests/test_stage9_ipc.py +177 -0
- saitenka-0.9.0/tests/test_stress.py +103 -0
- saitenka-0.9.0/tests/test_stress_memory.py +36 -0
- saitenka-0.9.0/tests/test_sub_index.py +156 -0
- saitenka-0.9.0/tests/test_sub_index_properties.py +184 -0
- saitenka-0.9.0/tests/test_subselect.py +132 -0
- saitenka-0.9.0/tests/test_telemetry.py +209 -0
- saitenka-0.9.0/tests/test_telemetry_toggle.py +96 -0
- saitenka-0.9.0/tests/test_transport_contract.py +239 -0
- saitenka-0.9.0/tests/test_window_geometry.py +174 -0
- saitenka-0.9.0/tests/test_windowed_hit.py +96 -0
- saitenka-0.9.0/tests/test_windowed_panel.py +177 -0
- saitenka-0.9.0/tests/test_windowed_prefetch.py +162 -0
- saitenka-0.9.0/tests/test_yomitan_db_import.py +168 -0
- saitenka-0.9.0/tests/test_yomitan_import.py +187 -0
- saitenka-0.9.0/tests/util.py +245 -0
- saitenka-0.9.0/tools/fuzz/fuzz_sub_index.py +39 -0
- saitenka-0.9.0/tools/mutate/run.py +67 -0
- saitenka-0.9.0/tools/semgrep/rules.yml +18 -0
- saitenka-0.9.0/tools/semgrep/uv.toml +15 -0
- saitenka-0.9.0/uv.lock +1927 -0
|
@@ -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.
|
saitenka-0.9.0/PKG-INFO
ADDED
|
@@ -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.
|