flowocr 0.1.0__py3-none-any.whl
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.
- flowocr/__init__.py +15 -0
- flowocr/analyze/__init__.py +8 -0
- flowocr/analyze/align.py +151 -0
- flowocr/analyze/build_tracks.py +2886 -0
- flowocr/analyze/cluster_layers.py +155 -0
- flowocr/analyze/game_align.py +652 -0
- flowocr/analyze/gamescript.py +1220 -0
- flowocr/analyze/gtdbundle.py +306 -0
- flowocr/analyze/match.py +67 -0
- flowocr/analyze/matchers/__init__.py +5 -0
- flowocr/analyze/matchers/gametext.py +70 -0
- flowocr/analyze/merge_nameplate.py +178 -0
- flowocr/analyze/models/slot_pair.json +148 -0
- flowocr/analyze/nameplate.py +148 -0
- flowocr/analyze/pair_features.py +168 -0
- flowocr/analyze/pair_model.py +127 -0
- flowocr/analyze/refine_boundaries.py +409 -0
- flowocr/analyze/script_align.py +641 -0
- flowocr/analyze/scriptmatch.py +1018 -0
- flowocr/analyze/slot_learned.py +306 -0
- flowocr/analyze/slot_lines.py +251 -0
- flowocr/analyze/slot_modes.py +143 -0
- flowocr/analyze/slot_pairs.py +562 -0
- flowocr/analyze/slot_veto.py +104 -0
- flowocr/analyze/uigate.py +351 -0
- flowocr/artifacts/__init__.py +5 -0
- flowocr/artifacts/evalkit.py +123 -0
- flowocr/artifacts/matchedio.py +75 -0
- flowocr/artifacts/srtio.py +166 -0
- flowocr/artifacts/tracksio.py +345 -0
- flowocr/extensions.py +77 -0
- flowocr/extract/__init__.py +8 -0
- flowocr/extract/childproc.py +118 -0
- flowocr/extract/decode_proc.py +511 -0
- flowocr/extract/decode_shards.py +574 -0
- flowocr/extract/detpost.py +215 -0
- flowocr/extract/edge_proc.py +314 -0
- flowocr/extract/edge_refine.py +598 -0
- flowocr/extract/fast_det.py +214 -0
- flowocr/extract/ffcheck.py +87 -0
- flowocr/extract/framegrid.py +265 -0
- flowocr/extract/framesource.py +1264 -0
- flowocr/extract/ocr_args.py +754 -0
- flowocr/extract/ocr_complete.py +231 -0
- flowocr/extract/ocr_parallel.py +208 -0
- flowocr/extract/ort_server.py +632 -0
- flowocr/extract/ortclient.py +363 -0
- flowocr/extract/ptsclock.py +150 -0
- flowocr/extract/recdecode.py +78 -0
- flowocr/extract/recort.py +162 -0
- flowocr/extract/recpack.py +94 -0
- flowocr/extract/recpool.py +206 -0
- flowocr/extract/recprep.py +71 -0
- flowocr/extract/refine_video.py +271 -0
- flowocr/extract/regions.py +387 -0
- flowocr/extract/reuse_v2.py +593 -0
- flowocr/extract/run_groups.py +247 -0
- flowocr/extract/run_ocr2.py +1605 -0
- flowocr/extract/supervisor.py +261 -0
- flowocr/extract/timeline.py +84 -0
- flowocr/extract/typewriter_fuse.py +394 -0
- flowocr/models.py +263 -0
- flowocr/output/__init__.py +3 -0
- flowocr/output/export.py +205 -0
- flowocr/output/layout.py +210 -0
- flowocr/output/presets/__init__.py +6 -0
- flowocr/output/presets/_overlay.py +40 -0
- flowocr/output/presets/default.py +21 -0
- flowocr/output/presets/default_all.py +20 -0
- flowocr/output/presets/dev.py +21 -0
- flowocr/output/presets/matched_srt.py +32 -0
- flowocr/output/presets/script.py +20 -0
- flowocr/output/presets/srt_main.py +33 -0
- flowocr/output/render.py +82 -0
- flowocr/output/run_srt.py +83 -0
- flowocr/output/script.py +541 -0
- flowocr/paths.py +168 -0
- flowocr/provenance.py +119 -0
- flowocr/typeset/__init__.py +9 -0
- flowocr/typeset/__main__.py +38 -0
- flowocr/typeset/assfile.py +216 -0
- flowocr/typeset/core.py +821 -0
- flowocr/typeset/fx/__init__.py +11 -0
- flowocr-0.1.0.dist-info/METADATA +109 -0
- flowocr-0.1.0.dist-info/RECORD +91 -0
- flowocr-0.1.0.dist-info/WHEEL +5 -0
- flowocr-0.1.0.dist-info/entry_points.txt +10 -0
- flowocr-0.1.0.dist-info/licenses/LICENSE +674 -0
- flowocr-0.1.0.dist-info/licenses/LICENSES/Apache-2.0.txt +201 -0
- flowocr-0.1.0.dist-info/licenses/LICENSES/PP-OCRv6-NOTICE.md +18 -0
- flowocr-0.1.0.dist-info/top_level.txt +1 -0
flowocr/paths.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""代码在哪、产物和素材在哪——**这两件事分开**。
|
|
2
|
+
|
|
3
|
+
照搬另一个项目(finesub)的做法:
|
|
4
|
+
|
|
5
|
+
> Running from a checkout uses the checkout's own data …
|
|
6
|
+
> **A git worktree resolves to the main checkout.**
|
|
7
|
+
|
|
8
|
+
理由在这个项目里更硬:`out/`、`tmp/`、`data/` 里的素材和 explore 下各实验的 venv 都是
|
|
9
|
+
**ignore 的**,linked worktree 里根本不存在。所以:
|
|
10
|
+
|
|
11
|
+
* **代码**从这份文件所在的 checkout 走(槽里改的代码就是跑的代码,`git_head` 也从这里算);
|
|
12
|
+
* **产物与素材**走 `data_root()`——在 worktree 里就是它背后的**主 checkout**。
|
|
13
|
+
|
|
14
|
+
这样"在槽里改完直接跑整片"不需要建 junction、不需要手抄绝对路径,
|
|
15
|
+
而 provenance 里的 `git_head` 仍然是**槽自己的**那棵干净的树。
|
|
16
|
+
|
|
17
|
+
**三种运行形态**(2026-09-21 迁进正式包时加的第三种):
|
|
18
|
+
|
|
19
|
+
| 形态 | 判据 | `CODE_ROOT` | `data_root()` |
|
|
20
|
+
| --- | --- | --- | --- |
|
|
21
|
+
| 源码 checkout | `<root>/.git` 存在(目录)且有 `pyproject.toml` | 那个 checkout | 同 `CODE_ROOT` |
|
|
22
|
+
| linked worktree | `<root>/.git` 是个 `gitdir:` **文件** | 槽 | **主 checkout** |
|
|
23
|
+
| 安装后运行 | 上面两条都不成立(包在 site-packages 里) | 无意义(只剩 `PACKAGE_ROOT`) | 用户数据目录(下表) |
|
|
24
|
+
|
|
25
|
+
安装态的用户数据目录:Windows `%LOCALAPPDATA%/flowocr`,其余 `$XDG_DATA_HOME/flowocr`
|
|
26
|
+
或 `~/.local/share/flowocr`。`FLOWOCR_DATA_ROOT` 在三种形态下都能覆盖
|
|
27
|
+
(对应 finesub 的 `FINESUB_ROOT`)。⚠ 装进 `<checkout>/.venv` 的**非** editable 安装也算安装态:
|
|
28
|
+
判据看的是包文件自己往上三层有没有 checkout,不看 venv 在哪。
|
|
29
|
+
|
|
30
|
+
python -m flowocr.paths # 打印产物根(驱动脚本 cd 到这里)
|
|
31
|
+
python -m flowocr.paths --code # 打印代码根
|
|
32
|
+
flowocr-paths # console script,同一份实现(`tools/paths.py` 那个 shim 2026-09-22 已删)
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import os
|
|
37
|
+
import sys
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
PACKAGE_ROOT = Path(__file__).resolve().parent
|
|
41
|
+
"""包自己所在的目录(`src/flowocr/` 或 `site-packages/flowocr/`)。"""
|
|
42
|
+
|
|
43
|
+
CODE_ROOT = PACKAGE_ROOT.parents[1]
|
|
44
|
+
"""这份代码所在的 checkout(`src/flowocr/` 往上两层)。**只在源码 / worktree 形态下有意义**,
|
|
45
|
+
安装态下它指向 site-packages 的上一层,别拿它拼路径——先问 `is_checkout()`。"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main_checkout(root: Path) -> Path | None:
|
|
49
|
+
"""`root` 是 linked worktree 的话,返回它背后的主 checkout;否则 `None`。
|
|
50
|
+
|
|
51
|
+
linked worktree 的 `.git` 是**一个文件**,内容形如
|
|
52
|
+
`gitdir: <main>/.git/worktrees/<slot>`;主 checkout 就在它上面三层
|
|
53
|
+
(去掉 `<slot>`、`worktrees`、`.git`)。这段判据抄自 finesub 的
|
|
54
|
+
`_main_worktree_root`,连"要检查倒数第二、三段是不是 worktrees/.git"都一样——
|
|
55
|
+
少了那一步,`gitdir:` 指向别处的仓库也会被当成主 checkout。
|
|
56
|
+
"""
|
|
57
|
+
pointer = root / ".git"
|
|
58
|
+
if not pointer.is_file():
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
body = pointer.read_text(encoding="utf-8").strip()
|
|
62
|
+
except OSError:
|
|
63
|
+
return None
|
|
64
|
+
if not body.startswith("gitdir:"):
|
|
65
|
+
return None
|
|
66
|
+
recorded = Path(body.removeprefix("gitdir:").strip())
|
|
67
|
+
if not recorded.is_absolute():
|
|
68
|
+
recorded = (root / recorded).resolve()
|
|
69
|
+
if (recorded.parent.name, recorded.parent.parent.name) != ("worktrees", ".git"):
|
|
70
|
+
return None
|
|
71
|
+
return recorded.parents[2]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def is_linked_worktree(root: Path | None = None) -> bool:
|
|
75
|
+
return main_checkout(root or CODE_ROOT) is not None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_checkout(root: Path | None = None) -> bool:
|
|
79
|
+
"""`root` 是不是一份源码 checkout(主 checkout 或 linked worktree)。
|
|
80
|
+
|
|
81
|
+
两个条件都要:`.git`(目录或 `gitdir:` 文件)+ `pyproject.toml`。只看 `.git` 不够——
|
|
82
|
+
site-packages 的上一层碰巧在某个别的仓库里时也有 `.git`。
|
|
83
|
+
"""
|
|
84
|
+
root = root or CODE_ROOT
|
|
85
|
+
return (root / ".git").exists() and (root / "pyproject.toml").is_file()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def user_data_root() -> Path:
|
|
89
|
+
"""安装态的产物 / 缓存根。不建目录,只算路径。"""
|
|
90
|
+
if sys.platform == "win32":
|
|
91
|
+
base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
|
|
92
|
+
return Path(base) / "flowocr"
|
|
93
|
+
base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
|
|
94
|
+
return Path(base) / "flowocr"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def data_root(root: Path | None = None) -> Path:
|
|
98
|
+
"""产物与素材的根。**worktree 解析回主 checkout;安装态落到用户数据目录。**"""
|
|
99
|
+
configured = os.environ.get("FLOWOCR_DATA_ROOT", "").strip()
|
|
100
|
+
if configured:
|
|
101
|
+
return Path(configured).expanduser().resolve()
|
|
102
|
+
root = root or CODE_ROOT
|
|
103
|
+
if not is_checkout(root):
|
|
104
|
+
return user_data_root()
|
|
105
|
+
return main_checkout(root) or root
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def models_root() -> Path:
|
|
109
|
+
"""ONNX 模型的根:`FLOWOCR_MODELS` > 数据根的 `models/`(源码态是主 checkout 的 `models/`,已 ignore;安装态是用户数据目录)。
|
|
110
|
+
2026-09-22 起两种状态同一个位置:模型改成官方发布的(`flowocr.models`),不再依赖按约定随时可删的 onnxrt 实验目录下的 `models/`——
|
|
111
|
+
那里还躺着以前自己转的几份,给历史臂用(显式路径照样认,见 `resolve_model`)。"""
|
|
112
|
+
configured = os.environ.get("FLOWOCR_MODELS", "").strip()
|
|
113
|
+
if configured:
|
|
114
|
+
return Path(configured).expanduser().resolve()
|
|
115
|
+
return data_root() / "models"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def gametext_root() -> Path:
|
|
119
|
+
"""游戏文本包(`gtd-bundle/1`,`flowocr.analyze.gtdbundle`)的默认位置:`FLOWOCR_GAMETEXT` > 数据根的 `gametext/`
|
|
120
|
+
(源码态是主 checkout 的 `gametext/`,已 ignore;安装态是用户数据目录)。包是私下拿到的,放进来即可,不用解压。"""
|
|
121
|
+
configured = os.environ.get("FLOWOCR_GAMETEXT", "").strip()
|
|
122
|
+
if configured:
|
|
123
|
+
return Path(configured).expanduser().resolve()
|
|
124
|
+
return data_root() / "gametext"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def resolve_model(path: str) -> str:
|
|
128
|
+
"""模型文件 / 目录的路径。给的路径存在(绝对,或相对当前目录——驱动脚本 cd 到产物根的老规矩)就原样用;
|
|
129
|
+
不存在就按**文件名**到 `models_root()` 里找;没有就原样返回,让打开它的那一步报清楚。
|
|
130
|
+
|
|
131
|
+
默认值字符串(`ocr_args.OLD_DET_ONNX` / `OLD_REC_ONNX`,旧的自转模型路径)**不动**:它写进 obs 的 `config`,换了会让磁盘上的现成产物全部对不上。"""
|
|
132
|
+
p = Path(path)
|
|
133
|
+
if p.exists():
|
|
134
|
+
return str(p)
|
|
135
|
+
cand = models_root() / p.name
|
|
136
|
+
return str(cand) if cand.exists() else path
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def child_env(env: dict | None = None) -> dict:
|
|
140
|
+
"""给要 `-m flowocr.…` 的子进程用的环境:把装着 `flowocr` 的那个目录放进 `PYTHONPATH` 最前面。
|
|
141
|
+
|
|
142
|
+
源码 checkout 里那是 `<root>/src`(默认不在 sys.path 上,父进程靠脚本开头的引导行放进去的,
|
|
143
|
+
子进程继承不到)。**安装态什么都不加**:包在 site-packages 里本来就找得到,而把 site-packages 塞进 `PYTHONPATH`
|
|
144
|
+
会泄漏给**别的解释器**起的子进程——2026-09-22 nightly 用 `FLOWOCR_ORT_PYTHON` 把 ORT worker 钉到另一个 venv,
|
|
145
|
+
那个解释器却先 import 到了本 venv 的 onnxruntime 和 CUDA 库(PYTHONPATH 排在它自己的 site-packages 前面)。
|
|
146
|
+
`run_ocr2` 的监督者起 worker、`--workers N` 切段都走这里。"""
|
|
147
|
+
env = dict(os.environ if env is None else env)
|
|
148
|
+
if not is_checkout():
|
|
149
|
+
return env
|
|
150
|
+
here = str(PACKAGE_ROOT.parent)
|
|
151
|
+
old = env.get("PYTHONPATH", "")
|
|
152
|
+
if here not in old.split(os.pathsep):
|
|
153
|
+
env["PYTHONPATH"] = here + (os.pathsep + old if old else "")
|
|
154
|
+
return env
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main(argv: list[str] | None = None) -> int:
|
|
158
|
+
import argparse
|
|
159
|
+
|
|
160
|
+
ap = argparse.ArgumentParser(description="打印代码根 / 产物根")
|
|
161
|
+
ap.add_argument("--code", action="store_true", help="打印代码根(默认打印产物根)")
|
|
162
|
+
a = ap.parse_args(argv)
|
|
163
|
+
print(CODE_ROOT if a.code else data_root())
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
raise SystemExit(main())
|
flowocr/provenance.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""产物要能追溯到是哪一版代码产的:`git_head()` 和 `code_fp()`,**全仓库只有这一份实现**。
|
|
2
|
+
|
|
3
|
+
原来 `build_tracks.py` 里那份(`gamescript` / `scriptmatch` / `refine_boundaries` 都借它用)
|
|
4
|
+
和 `ocr_args.py` 里那份各算各的,文件表一个记 basename、一个记 `dir/name`。
|
|
5
|
+
2026-09-21 搬进正式包时并成一份,**文件表统一记仓库相对路径**(`src/flowocr/analyze/build_tracks.py`、
|
|
6
|
+
`src/flowocr/paths.py`),于是搬目录之后表里写的就是代码真正所在的位置,
|
|
7
|
+
守卫不再读已迁走的旧路径(project-structure 计划第 1 条)。
|
|
8
|
+
|
|
9
|
+
⚠ 这次统一让所有产物的 `code_fp` 都变了(表的格式变了,不只是内容)。它证明的是代码身份,
|
|
10
|
+
证明不了新旧代码行为等价——迁移验收靠产物级对账(同一份 obs 重建、`obs_identical` / `obs_textdiff`),
|
|
11
|
+
不靠指纹相同。改动前的五个指纹记在 project-structure 账本。
|
|
12
|
+
|
|
13
|
+
`git_head` 只在源码 checkout / worktree 里有意义;安装态下没有仓库,返回 `"?"`。
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import subprocess
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from flowocr.paths import CODE_ROOT, PACKAGE_ROOT, data_root, is_checkout
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def version() -> str | None:
|
|
25
|
+
"""flowocr 的发行版本(包元数据)。安装态没有 git,`git_head` 是 `"?"`,靠它看出是哪个发行版;取不到记 None。
|
|
26
|
+
源码态以 `git_head` / `code_fp` 为准:这里读的是 venv 里登记的版本,可能落后于 `pyproject.toml`(改了版本号没重新 `uv sync`),别拿它对账。"""
|
|
27
|
+
import importlib.metadata
|
|
28
|
+
try:
|
|
29
|
+
return importlib.metadata.version("flowocr")
|
|
30
|
+
except importlib.metadata.PackageNotFoundError:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def portable(p) -> str | None:
|
|
35
|
+
"""产物里**记**的路径:绝对路径在数据根下的记成相对数据根、在用户目录下的记成 `~/…`,其余与相对路径原样。
|
|
36
|
+
产物会被分享,绝对路径会带出用户名(release-plan F8)。**只用于记**;按记下的路径去开文件用 `local()`。
|
|
37
|
+
相对路径不动:驱动都 cd 到数据根、传的就是相对路径,改它只会让参数值(`0`、`off`…)被误当路径。"""
|
|
38
|
+
if p is None:
|
|
39
|
+
return None
|
|
40
|
+
s = str(p)
|
|
41
|
+
q = Path(s)
|
|
42
|
+
if not q.is_absolute():
|
|
43
|
+
return s
|
|
44
|
+
q = q.resolve()
|
|
45
|
+
for base, prefix in ((data_root(), ""), (Path.home(), "~/")):
|
|
46
|
+
try:
|
|
47
|
+
return prefix + q.relative_to(base.resolve()).as_posix()
|
|
48
|
+
except ValueError:
|
|
49
|
+
continue
|
|
50
|
+
return s
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def local(p) -> Path:
|
|
54
|
+
"""`portable()` 记下的路径 -> 这台机器上的路径:`~` 展开;相对路径在当前目录不存在就按数据根拼。"""
|
|
55
|
+
if not str(p):
|
|
56
|
+
return Path("")
|
|
57
|
+
q = Path(str(p)).expanduser()
|
|
58
|
+
if q.is_absolute() or q.exists():
|
|
59
|
+
return q
|
|
60
|
+
return data_root() / q
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def file_of(name: str, root: Path | None = None) -> Path:
|
|
64
|
+
"""指纹表里的仓库相对路径 -> 真文件。`src/flowocr/...` 按**包自己的位置**解析(安装态没有 `src/`,包在 site-packages;
|
|
65
|
+
源码态 `PACKAGE_ROOT.parent` 就是 `<root>/src`,同一个文件);其余(`tools/`、`explore/`)只在 checkout 里有。
|
|
66
|
+
`root` 给了就只在它下面找(自检用)。2026-09-22 安装态第一次跑就在这里 FileNotFoundError。"""
|
|
67
|
+
if root is not None:
|
|
68
|
+
return root / name
|
|
69
|
+
if name.startswith("src/"):
|
|
70
|
+
return PACKAGE_ROOT.parent / name[len("src/"):]
|
|
71
|
+
return CODE_ROOT / name
|
|
72
|
+
|
|
73
|
+
DIRTY_PATHS = ("src", "dev_tools", "explore")
|
|
74
|
+
"""`git_head()` 判 `+dirty` 时看哪些目录。**别在别处抄第二份**——
|
|
75
|
+
只看**产得出产物的代码**:正式包、`tools/`、`explore/`(`dev_tools/reference/match_ref.py` 是产
|
|
76
|
+
`ours*_jp.ass` 的一级代码,audit-5 点名要纳入)。文档和 `tests/` 不看——
|
|
77
|
+
改它们不影响产物,缀上去只会天天是 dirty。"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def git_head(root: Path | None = None) -> str:
|
|
81
|
+
"""`HEAD` 的短 sha;**代码目录有未提交改动时后面缀 `+dirty`**。
|
|
82
|
+
|
|
83
|
+
为什么必须缀(2026-09-08 自己踩的):跑一批产物的中途我提交了一次,
|
|
84
|
+
于是同一批 11 份产物的 provenance 里躺着两个 `git_head`——
|
|
85
|
+
而**跑的代码自始至终是同一份**(那次提交没有改工作树的内容)。
|
|
86
|
+
反过来更危险:`HEAD` 记的是"最后一次提交是谁",改了没提交就跑,
|
|
87
|
+
provenance 会**言之凿凿地指向一个并没有产生这份产物的 commit**。
|
|
88
|
+
这和 09-06 那次"照着文档的复现命令跑打出另一张表"是同一个形状。
|
|
89
|
+
"""
|
|
90
|
+
if root is None and not is_checkout():
|
|
91
|
+
return "?" # 安装态:没有仓库,别去问 cwd 碰巧所在的别人家的 git
|
|
92
|
+
root = root or CODE_ROOT
|
|
93
|
+
try:
|
|
94
|
+
head = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True,
|
|
95
|
+
text=True, cwd=root, check=True).stdout.strip()[:12]
|
|
96
|
+
except Exception:
|
|
97
|
+
return "?"
|
|
98
|
+
try:
|
|
99
|
+
dirty = subprocess.run(["git", "status", "--porcelain", "--", *DIRTY_PATHS],
|
|
100
|
+
capture_output=True, text=True, cwd=root,
|
|
101
|
+
check=True).stdout.strip()
|
|
102
|
+
except Exception:
|
|
103
|
+
return head + "+?"
|
|
104
|
+
return head + ("+dirty" if dirty else "")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def code_fp(files: tuple[str, ...], root: Path | None = None) -> str:
|
|
108
|
+
"""`files`(仓库相对路径)的**内容指纹**:sha256 前 12 位,换行统一成 LF。
|
|
109
|
+
|
|
110
|
+
`git_head` 回答"最后一次提交是谁",回答不了"这份代码是不是同一份":HEAD 移动而代码没变时它会不同
|
|
111
|
+
(feat/game-text 默认臂混了两个 git_head,靠人去 `git diff` 解释),`+dirty` 相同时它又证明不了相同。
|
|
112
|
+
指纹两件都能回答,报表机械地比它(audit-6)。换行统一是因为主 checkout 和槽的
|
|
113
|
+
`core.autocrlf` 可能不同,同一份代码不该因此两个指纹。`root` 只给自检用(默认代码根)。
|
|
114
|
+
路径进 hash(不只内容):同一份内容换了位置也算换了代码。"""
|
|
115
|
+
h = hashlib.sha256()
|
|
116
|
+
for name in sorted(files):
|
|
117
|
+
h.update(name.encode("utf-8") + b"\0")
|
|
118
|
+
h.update(file_of(name, root).read_bytes().replace(b"\r\n", b"\n") + b"\0")
|
|
119
|
+
return h.hexdigest()[:12]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""阶段 4:特效渲染。读阶段 3 的字幕稿(`*.script.ass`,能在 Aegisub 里预览、编辑),生成最终的叠加 ASS。
|
|
2
|
+
|
|
3
|
+
python -m flowocr.typeset out/x/x-cn-main.script.ass # -> x-cn-main.ass
|
|
4
|
+
python -m flowocr.typeset x.script.ass --preset dev # 开发看:UI 黄字、非主轨浅蓝、区域号
|
|
5
|
+
python -m flowocr.typeset x.ass --restore # 阶段 4 的输出还原成字幕稿
|
|
6
|
+
|
|
7
|
+
方案:script-fx 计划;约定在 artifacts.md 的字幕稿一节;实现在 `core`,ASS 读写在 `assfile`,
|
|
8
|
+
扩展效果(`--fx`)是 `flowocr.extensions` 的 `fx` 种类,内置的放在 `flowocr.typeset.fx`。
|
|
9
|
+
"""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""`python -m flowocr.typeset`:阶段 4 的命令行。见包的文件头。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from flowocr.typeset import core
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv: list[str] | None = None) -> int:
|
|
11
|
+
ap = argparse.ArgumentParser(description="阶段 4:字幕稿 -> 最终叠加 ASS(可以反复重跑;--restore 还原字幕稿)")
|
|
12
|
+
ap.add_argument("ass", help="字幕稿(*.script.ass),或阶段 4 自己的输出")
|
|
13
|
+
ap.add_argument("-o", "--out", default="", help="默认 x.script.ass -> x.ass,其余 x.ass -> x.fx.ass;--restore 时 -> x.script.ass")
|
|
14
|
+
ap.add_argument("--preset", default="default", choices=list(core.LOOKS), help="长什么样")
|
|
15
|
+
ap.add_argument("--opt", action="append", default=[], metavar="K=V",
|
|
16
|
+
help="plate(box / rows / text)、plate_alpha(如 0x20)、typewriter(on / dim / off)、fade(on / off)、"
|
|
17
|
+
"unknown(error / ignore:不认识的 fo 参数)")
|
|
18
|
+
ap.add_argument("--fx", action="append", default=[], metavar="模块",
|
|
19
|
+
help="扩展效果:内置名 / 模块名 / .py 路径,可多次(见 flowocr.typeset.fx)")
|
|
20
|
+
ap.add_argument("--restore", action="store_true", help="只还原:删掉生成行,把源行恢复成字幕稿")
|
|
21
|
+
ap.add_argument("--force", action="store_true", help="--restore 时输出的字幕稿已经在也覆盖(默认不覆盖:可能是改过的)")
|
|
22
|
+
a = ap.parse_args(argv)
|
|
23
|
+
if a.restore:
|
|
24
|
+
out, n = core.restore_file(Path(a.ass), Path(a.out) if a.out else None, a.force)
|
|
25
|
+
print(f"-> {out}(还原 {n} 条源行)")
|
|
26
|
+
return 0
|
|
27
|
+
opts = {}
|
|
28
|
+
for kv in a.opt:
|
|
29
|
+
if "=" not in kv:
|
|
30
|
+
ap.error(f"--opt 要 K=V:{kv}")
|
|
31
|
+
k, v = kv.split("=", 1)
|
|
32
|
+
opts[k] = v
|
|
33
|
+
core.typeset_file(Path(a.ass), Path(a.out) if a.out else None, a.preset, opts, a.fx)
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""ASS 文件的读写:**按字节保留**不认识的东西,只改自己动过的字段。
|
|
2
|
+
|
|
3
|
+
为什么不用 pysubs2(script-fx 计划第 0 步):它读成对象再整份重写,时间、样式数值按它的格式重出;
|
|
4
|
+
`restore` 要还原出逐字节相同的字幕稿、别人的行(kara-templater 的 `fx`、`Banner;`)要原样留着,做不到。
|
|
5
|
+
Aegisub 保存时会加 BOM 和它自己的节(`[Aegisub Project Garbage]` 等),这里一律原样保留。
|
|
6
|
+
|
|
7
|
+
另有两样字幕稿专用的:Effect 字段里 `fo:…` 参数的编解码(`encode_fo` / `parse_fo`),和正文里几个 tag 的读取。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
EVENT_KINDS = ("Dialogue", "Comment")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Section:
|
|
19
|
+
"""一节:`name` 是方括号里的名字(文件开头 `[` 之前的内容是 name=None 的一节),`lines` 是节头之后的原始行。"""
|
|
20
|
+
name: str | None
|
|
21
|
+
head: str
|
|
22
|
+
lines: list[str] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Doc:
|
|
27
|
+
sections: list[Section]
|
|
28
|
+
nl: str = "\n"
|
|
29
|
+
bom: bool = False
|
|
30
|
+
|
|
31
|
+
def section(self, name: str) -> Section | None:
|
|
32
|
+
low = name.lower()
|
|
33
|
+
return next((s for s in self.sections if s.name and s.name.lower() == low), None)
|
|
34
|
+
|
|
35
|
+
def dumps(self) -> str:
|
|
36
|
+
out: list[str] = []
|
|
37
|
+
for s in self.sections:
|
|
38
|
+
if s.name is not None:
|
|
39
|
+
out.append(s.head)
|
|
40
|
+
out.extend(s.lines)
|
|
41
|
+
text = self.nl.join(out)
|
|
42
|
+
return ("\ufeff" if self.bom else "") + text
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def loads(text: str) -> Doc:
|
|
46
|
+
bom = text.startswith("\ufeff")
|
|
47
|
+
if bom:
|
|
48
|
+
text = text[1:]
|
|
49
|
+
nl = "\r\n" if "\r\n" in text else "\n"
|
|
50
|
+
sections = [Section(None, "")]
|
|
51
|
+
for ln in text.split(nl):
|
|
52
|
+
m = re.fullmatch(r"\s*\[([^\]]+)\]\s*", ln)
|
|
53
|
+
if m:
|
|
54
|
+
sections.append(Section(m[1], ln))
|
|
55
|
+
else:
|
|
56
|
+
sections[-1].lines.append(ln)
|
|
57
|
+
return Doc(sections, nl, bom)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def format_of(sec: Section) -> list[str] | None:
|
|
61
|
+
"""一节的 `Format:` 字段名(去掉空白)。"""
|
|
62
|
+
for ln in sec.lines:
|
|
63
|
+
if ln.startswith("Format:"):
|
|
64
|
+
return [x.strip() for x in ln.split(":", 1)[1].split(",")]
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Row:
|
|
70
|
+
"""一条 `Style:` / `Dialogue:` / `Comment:` 行:`kind` + 冒号后的空白 + 按 `Format` 切开的字段(最后一个字段吃掉剩下的逗号)。
|
|
71
|
+
字段保留原样(含空白),`str()` 拼回去逐字节相同。"""
|
|
72
|
+
kind: str
|
|
73
|
+
ws: str
|
|
74
|
+
fields: list[str]
|
|
75
|
+
names: list[str]
|
|
76
|
+
|
|
77
|
+
def __str__(self) -> str:
|
|
78
|
+
return f"{self.kind}:{self.ws}{','.join(self.fields)}"
|
|
79
|
+
|
|
80
|
+
def get(self, name: str) -> str:
|
|
81
|
+
return self.fields[self.names.index(name)]
|
|
82
|
+
|
|
83
|
+
def set(self, name: str, value: str) -> None:
|
|
84
|
+
self.fields[self.names.index(name)] = value
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_row(line: str, names: list[str], kinds: tuple[str, ...]) -> Row | None:
|
|
88
|
+
m = re.match(r"(\w+):(\s*)(.*)\Z", line, re.S)
|
|
89
|
+
if not m or m[1] not in kinds:
|
|
90
|
+
return None
|
|
91
|
+
return Row(m[1], m[2], m[3].split(",", len(names) - 1), names)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---- Effect 字段里的 fo:… 参数 ----
|
|
95
|
+
|
|
96
|
+
FO_PREFIX = "fo:"
|
|
97
|
+
FO_ESC = "%;,{}\\\n\r"
|
|
98
|
+
"""值里要按百分号编码的字符:`;` 是项的分隔;`,` 会切断 ASS 行的字段(Effect 不是最后一个字段,Aegisub 保存时还会把逗号换成分号);
|
|
99
|
+
`{` `}` `\\` 放进正文会被 libass 当成 tag(第 0 步探针:`{\\p1}` 会进绘图模式),Effect 里用不着也一并编码;`%` 本身;换行。
|
|
100
|
+
数值列表用空格分隔(`box=374 891 1588 931|…`),所以正常的参数里没有要编码的字符。"""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def fo_escape(v: str) -> str:
|
|
104
|
+
return "".join(f"%{ord(c):02X}" if c in FO_ESC else c for c in v)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def fo_unescape(v: str) -> str:
|
|
108
|
+
return re.sub(r"%([0-9A-Fa-f]{2})", lambda m: chr(int(m[1], 16)), v)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def encode_fo(params: dict) -> str:
|
|
112
|
+
"""`{"box": "…", "plate": True}` → `fo:box=…;plate`(写进 Effect 字段)。值为 True 的只写名字,None / False 的不写。"""
|
|
113
|
+
items = []
|
|
114
|
+
for k, v in params.items():
|
|
115
|
+
if v is None or v is False:
|
|
116
|
+
continue
|
|
117
|
+
items.append(k if v is True else f"{k}={fo_escape(str(v))}")
|
|
118
|
+
return FO_PREFIX + ";".join(items)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
FO_AT = re.compile(r"(?:^|;)\s*fo:")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def parse_fo(effect: str) -> dict | None:
|
|
125
|
+
"""Effect 字段里的 `fo:…` → 参数(只有名字的项值为 True);没有是 None。
|
|
126
|
+
`fo:` 可以跟在别的内容后面(`备注;fo:…`、`Banner;10;fo:…`),它之后到末尾都是参数;前面那段只留在源行上,阶段 4 不看。"""
|
|
127
|
+
m = FO_AT.search(effect)
|
|
128
|
+
if not m:
|
|
129
|
+
return None
|
|
130
|
+
params: dict = {}
|
|
131
|
+
for item in effect[m.end():].strip().split(";"):
|
|
132
|
+
if not item:
|
|
133
|
+
continue
|
|
134
|
+
k, eq, v = item.partition("=")
|
|
135
|
+
params[k] = fo_unescape(v) if eq else True
|
|
136
|
+
return params
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---- 正文里的 tag ----
|
|
140
|
+
|
|
141
|
+
def blocks(text: str) -> list[tuple[str, str]]:
|
|
142
|
+
"""正文切成 [("tags", 花括号里的内容) | ("text", 文字)]。没配对的 `{` 当文字(libass 也这样)。"""
|
|
143
|
+
out, i = [], 0
|
|
144
|
+
while i < len(text):
|
|
145
|
+
j = text.find("{", i)
|
|
146
|
+
k = text.find("}", j) if j >= 0 else -1
|
|
147
|
+
if j < 0 or k < 0:
|
|
148
|
+
out.append(("text", text[i:]))
|
|
149
|
+
break
|
|
150
|
+
if j > i:
|
|
151
|
+
out.append(("text", text[i:j]))
|
|
152
|
+
out.append(("tags", text[j + 1:k]))
|
|
153
|
+
i = k + 1
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
TAG_RE = {
|
|
158
|
+
"an": r"\\an(\d)",
|
|
159
|
+
"pos": r"\\pos\(([^)]*)\)",
|
|
160
|
+
"move": r"\\move\(([^)]*)\)",
|
|
161
|
+
"fs": r"\\fs(?=[\d.])([\d.]+)",
|
|
162
|
+
"fad": r"\\fad\(([^)]*)\)",
|
|
163
|
+
}
|
|
164
|
+
"""阶段 4 要读的几个 tag(整个 tag 匹配,见 `tag_of`)。`\\fs` 后面必须紧跟数字(不是 `\\fsp` / `\\fscx`)。"""
|
|
165
|
+
|
|
166
|
+
KARAOKE_RE = r"\\(?:ko|kf|K|k)(\d+)"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def split_tags(block: str) -> list[str]:
|
|
170
|
+
"""一个花括号块的内容切成**顶层**的 tag(`\\t(0,1000,\\fs80)` 是一个,里面的 `\\fs80` 不单算);
|
|
171
|
+
第一个 `\\` 之前的文字(注释)单独一项。拼回去和原内容相同。"""
|
|
172
|
+
out, cur, depth = [], "", 0
|
|
173
|
+
for c in block:
|
|
174
|
+
if c == "\\" and depth == 0 and cur:
|
|
175
|
+
out.append(cur)
|
|
176
|
+
cur = ""
|
|
177
|
+
cur += c
|
|
178
|
+
if c == "(":
|
|
179
|
+
depth += 1
|
|
180
|
+
elif c == ")" and depth:
|
|
181
|
+
depth -= 1
|
|
182
|
+
if cur:
|
|
183
|
+
out.append(cur)
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def tag_of(tag: str) -> tuple[str, str] | None:
|
|
188
|
+
"""一个顶层 tag 是不是阶段 4 要读的那几种:(名字, 参数);卡拉OK 的名字是 `k`、参数是厘秒。别的是 None。"""
|
|
189
|
+
for name, pat in TAG_RE.items():
|
|
190
|
+
m = re.fullmatch(pat, tag.rstrip())
|
|
191
|
+
if m:
|
|
192
|
+
return name, m[1]
|
|
193
|
+
m = re.fullmatch(KARAOKE_RE, tag.rstrip())
|
|
194
|
+
return ("k", m[1]) if m else None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def first_tag(text: str, name: str, lead_only: bool = False) -> str | None:
|
|
198
|
+
"""整行里第一次出现的某个顶层 tag 的参数(libass 对 `\\pos` / `\\move` / `\\an` / `\\fad` 也只认第一个)。
|
|
199
|
+
`lead_only`:只看正文开头那个 tag 块(`\\fs` 这类局部生效的:行内的 `A{\\fs80}B` 只管后面那几个字,不是整行的字号)。"""
|
|
200
|
+
for i, (kind, s) in enumerate(blocks(text)):
|
|
201
|
+
if kind != "tags":
|
|
202
|
+
if lead_only:
|
|
203
|
+
return None
|
|
204
|
+
continue
|
|
205
|
+
for t in split_tags(s):
|
|
206
|
+
got = tag_of(t)
|
|
207
|
+
if got and got[0] == name:
|
|
208
|
+
return got[1]
|
|
209
|
+
if lead_only:
|
|
210
|
+
return None
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def numbers(s: str) -> list[float]:
|
|
215
|
+
"""`"1,2"`(tag 的参数)或 `"1 2"`(`fo` 里的数值列表)→ [1.0, 2.0]。"""
|
|
216
|
+
return [float(x) for x in re.split(r"[,\s]+", s.strip()) if x]
|