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.
Files changed (91) hide show
  1. flowocr/__init__.py +15 -0
  2. flowocr/analyze/__init__.py +8 -0
  3. flowocr/analyze/align.py +151 -0
  4. flowocr/analyze/build_tracks.py +2886 -0
  5. flowocr/analyze/cluster_layers.py +155 -0
  6. flowocr/analyze/game_align.py +652 -0
  7. flowocr/analyze/gamescript.py +1220 -0
  8. flowocr/analyze/gtdbundle.py +306 -0
  9. flowocr/analyze/match.py +67 -0
  10. flowocr/analyze/matchers/__init__.py +5 -0
  11. flowocr/analyze/matchers/gametext.py +70 -0
  12. flowocr/analyze/merge_nameplate.py +178 -0
  13. flowocr/analyze/models/slot_pair.json +148 -0
  14. flowocr/analyze/nameplate.py +148 -0
  15. flowocr/analyze/pair_features.py +168 -0
  16. flowocr/analyze/pair_model.py +127 -0
  17. flowocr/analyze/refine_boundaries.py +409 -0
  18. flowocr/analyze/script_align.py +641 -0
  19. flowocr/analyze/scriptmatch.py +1018 -0
  20. flowocr/analyze/slot_learned.py +306 -0
  21. flowocr/analyze/slot_lines.py +251 -0
  22. flowocr/analyze/slot_modes.py +143 -0
  23. flowocr/analyze/slot_pairs.py +562 -0
  24. flowocr/analyze/slot_veto.py +104 -0
  25. flowocr/analyze/uigate.py +351 -0
  26. flowocr/artifacts/__init__.py +5 -0
  27. flowocr/artifacts/evalkit.py +123 -0
  28. flowocr/artifacts/matchedio.py +75 -0
  29. flowocr/artifacts/srtio.py +166 -0
  30. flowocr/artifacts/tracksio.py +345 -0
  31. flowocr/extensions.py +77 -0
  32. flowocr/extract/__init__.py +8 -0
  33. flowocr/extract/childproc.py +118 -0
  34. flowocr/extract/decode_proc.py +511 -0
  35. flowocr/extract/decode_shards.py +574 -0
  36. flowocr/extract/detpost.py +215 -0
  37. flowocr/extract/edge_proc.py +314 -0
  38. flowocr/extract/edge_refine.py +598 -0
  39. flowocr/extract/fast_det.py +214 -0
  40. flowocr/extract/ffcheck.py +87 -0
  41. flowocr/extract/framegrid.py +265 -0
  42. flowocr/extract/framesource.py +1264 -0
  43. flowocr/extract/ocr_args.py +754 -0
  44. flowocr/extract/ocr_complete.py +231 -0
  45. flowocr/extract/ocr_parallel.py +208 -0
  46. flowocr/extract/ort_server.py +632 -0
  47. flowocr/extract/ortclient.py +363 -0
  48. flowocr/extract/ptsclock.py +150 -0
  49. flowocr/extract/recdecode.py +78 -0
  50. flowocr/extract/recort.py +162 -0
  51. flowocr/extract/recpack.py +94 -0
  52. flowocr/extract/recpool.py +206 -0
  53. flowocr/extract/recprep.py +71 -0
  54. flowocr/extract/refine_video.py +271 -0
  55. flowocr/extract/regions.py +387 -0
  56. flowocr/extract/reuse_v2.py +593 -0
  57. flowocr/extract/run_groups.py +247 -0
  58. flowocr/extract/run_ocr2.py +1605 -0
  59. flowocr/extract/supervisor.py +261 -0
  60. flowocr/extract/timeline.py +84 -0
  61. flowocr/extract/typewriter_fuse.py +394 -0
  62. flowocr/models.py +263 -0
  63. flowocr/output/__init__.py +3 -0
  64. flowocr/output/export.py +205 -0
  65. flowocr/output/layout.py +210 -0
  66. flowocr/output/presets/__init__.py +6 -0
  67. flowocr/output/presets/_overlay.py +40 -0
  68. flowocr/output/presets/default.py +21 -0
  69. flowocr/output/presets/default_all.py +20 -0
  70. flowocr/output/presets/dev.py +21 -0
  71. flowocr/output/presets/matched_srt.py +32 -0
  72. flowocr/output/presets/script.py +20 -0
  73. flowocr/output/presets/srt_main.py +33 -0
  74. flowocr/output/render.py +82 -0
  75. flowocr/output/run_srt.py +83 -0
  76. flowocr/output/script.py +541 -0
  77. flowocr/paths.py +168 -0
  78. flowocr/provenance.py +119 -0
  79. flowocr/typeset/__init__.py +9 -0
  80. flowocr/typeset/__main__.py +38 -0
  81. flowocr/typeset/assfile.py +216 -0
  82. flowocr/typeset/core.py +821 -0
  83. flowocr/typeset/fx/__init__.py +11 -0
  84. flowocr-0.1.0.dist-info/METADATA +109 -0
  85. flowocr-0.1.0.dist-info/RECORD +91 -0
  86. flowocr-0.1.0.dist-info/WHEEL +5 -0
  87. flowocr-0.1.0.dist-info/entry_points.txt +10 -0
  88. flowocr-0.1.0.dist-info/licenses/LICENSE +674 -0
  89. flowocr-0.1.0.dist-info/licenses/LICENSES/Apache-2.0.txt +201 -0
  90. flowocr-0.1.0.dist-info/licenses/LICENSES/PP-OCRv6-NOTICE.md +18 -0
  91. flowocr-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,205 @@
1
+ """从富信息 JSON **投影**出 SRT,外加叠加 ASS 共用的工具(时间、转义、量字宽、定字号)。
2
+
3
+ 契约(`output-format-plan(已归档)`):**导出器不做任何判断。**
4
+ 切分(cue)、常驻 UI 的判定、并带的结果都已经写在 `-tracks.json` 里了,
5
+ 这里只是按 flag 取舍、按 cue 排版。所以回抠、并带、剔 UI 的产物自动一致——
6
+ audit-4 C6(`--srt-only` 绕过了输出过滤)在结构上不可能再发生。
7
+
8
+ python -m flowocr.output.export out/gs-gi-s2/gi-s2-tracks.json # 每条轨一份 SRT
9
+
10
+ 叠加字幕(把字压回原位)分两步:阶段 3 的字幕稿在 `flowocr.output.script`,阶段 4 的特效在 `flowocr.typeset`,
11
+ 入口是 `render` 的预设(`script`,以及两步连跑的 `default` / `default_all` / `dev`;script-fx 计划)。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ from pathlib import Path
17
+
18
+ from flowocr.artifacts import srtio # noqa: E402
19
+ from flowocr.artifacts import tracksio # noqa: E402
20
+
21
+ FONT_CN = "Microsoft YaHei"
22
+ """叠加 ASS 用的字体。**挑的是这台 Windows 上真有的**——写一个不存在的字体名,
23
+ libass 会静默换一个替身,下面那个量出来的换算比例当场作废。"""
24
+
25
+ FONT_H_RATIO = {FONT_CN: 1.35}
26
+ """框高 → `Fontsize` 的换算,**量出来的**(`output-format-plan(已归档)`)。
27
+
28
+ `Fontsize` 不是像素高:字形高只有它的 0.58–0.81 倍,而且**随字体和是不是全角而变**
29
+ (同一个 Fontsize 在 Meiryo 和 Yu Mincho 下差 20%)。一次性探针 `probe_ass_fontsize.py` 用 libass
30
+ 渲一条已知字号的 ASS、量白色字形的外接框高,`ratio = Fontsize / 字形高`,取 CJK 正文那一列;
31
+ 拉丁为主的素材偏小约 6%。换字体请重跑那个探针,别照抄。
32
+ """
33
+
34
+
35
+ def h_ratio(font: str) -> float:
36
+ """`FONT_H_RATIO` 里查不到的字体(用户在字幕稿里换了字体)按 `FONT_CN` 的比例算;阶段 4 会打一行提示。"""
37
+ return FONT_H_RATIO.get(font, FONT_H_RATIO[FONT_CN])
38
+
39
+
40
+ INK_REF_SIZE = 48
41
+ """量字宽用的参考字号。**墨迹宽和字号成正比**(2026-09-12 在 24/48/72 三档上量过,
42
+ 三种字体、六种文本,比例逐档相同到千分之五以内),所以渲一次就能反推任意字号的宽。"""
43
+ INK_PAGE_H, INK_PAGE_W = 12_000, 4096
44
+ """一页多高。**行高按这一页里最大的字号定**:固定行高时,调用方传进来的大字号(核对探针用的是
45
+ 适配后的字号,最大过 100)会把字形伸进下一行,下一行量到的宽就成了上一行的(第一版的 31 倍"溢出"就是这个)。"""
46
+
47
+
48
+ def ass_doc(W: int, H: int, styles: list[str], events: list[str], comment: str = "",
49
+ info: dict | None = None) -> str:
50
+ """一份 ASS。`info` 是另加进 `[Script Info]` 的键:要留得住就写成键,Aegisub 会丢掉 `;` 开头的注释行。"""
51
+ return "\n".join(
52
+ ["[Script Info]", *([f"; {comment}"] if comment else []),
53
+ "ScriptType: v4.00+", "WrapStyle: 2", "ScaledBorderAndShadow: yes",
54
+ f"PlayResX: {W}", f"PlayResY: {H}", *(f"{k}: {v}" for k, v in (info or {}).items()), "",
55
+ "[V4+ Styles]",
56
+ "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, "
57
+ "OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, "
58
+ "ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, "
59
+ "MarginL, MarginR, MarginV, Encoding", *styles, "",
60
+ "[Events]",
61
+ "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
62
+ *events]) + "\n"
63
+
64
+
65
+ def render_ink(items: list[tuple[str, float, str]]) -> list[tuple[int, int]]:
66
+ """把 `(字体, 字号, 单行文本)` 逐条用 **libass 真渲**(ffmpeg 的 subtitles 滤镜),
67
+ 返回每条白色墨迹的外接框 `(宽, 高)`;渲不出墨迹的记 `(0, 0)`。
68
+
69
+ 为什么不拿字体文件的度量估(第一版就是这么做的,一次性探针 `probe_ass_fit.py` 量过):
70
+ libass 的 `Fontsize` 不是 em、各字体缩放口径不同(Yu Mincho 估宽系统性偏大 17%),
71
+ 而且**字体里没有的字会被 libass 回退到别的字体**(Consolas 渲日文 −15%~+40%)——
72
+ 估不准的地方正好是最该量的地方。一页一次 ffmpeg,几百行一秒。"""
73
+ import subprocess
74
+ import tempfile
75
+ from PIL import Image
76
+ out: list[tuple[int, int]] = []
77
+ row_h = int(max((s for _, s, _ in items), default=INK_REF_SIZE) * 2)
78
+ per_page = max(1, INK_PAGE_H // row_h)
79
+ with tempfile.TemporaryDirectory(prefix="flowocr-ink-") as d:
80
+ for p0 in range(0, len(items), per_page):
81
+ page = items[p0:p0 + per_page]
82
+ H = row_h * len(page)
83
+ fonts = sorted({f for f, _, _ in page})
84
+ styles = [f"Style: s{i},{f},{INK_REF_SIZE},&H00FFFFFF,&H000000FF,&H00000000,"
85
+ "&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1" for i, f in enumerate(fonts)]
86
+ events = [f"Dialogue: 0,0:00:00.00,0:00:01.00,s{fonts.index(f)},,0,0,0,,"
87
+ + "{" + f"\\pos(8,{k * row_h + 4})\\fs{s:g}" + "}" + ass_text(t)
88
+ for k, (f, s, t) in enumerate(page)]
89
+ (Path(d) / "ink.ass").write_text(ass_doc(INK_PAGE_W, H, styles, events), encoding="utf-8")
90
+ # 相对文件名 + cwd:filter 语法里 Windows 盘符那个冒号不好转义(同 probe_ass_fontsize)
91
+ r = subprocess.run(["ffmpeg", "-y", "-v", "error", "-f", "lavfi", "-i",
92
+ f"color=c=black:s={INK_PAGE_W}x{H}:d=1", "-vf", "subtitles=ink.ass",
93
+ "-frames:v", "1", "ink.png"], capture_output=True, text=True, cwd=d)
94
+ if r.returncode != 0:
95
+ raise SystemExit(f"字宽渲染失败(ffmpeg):{r.stderr.strip()[:300]}")
96
+ img = Image.open(Path(d) / "ink.png").convert("L").point(lambda v: 255 if v > 128 else 0)
97
+ for k in range(len(page)):
98
+ bb = img.crop((0, k * row_h, INK_PAGE_W, (k + 1) * row_h)).getbbox()
99
+ out.append((bb[2] - bb[0], bb[3] - bb[1]) if bb else (0, 0))
100
+ return out
101
+
102
+
103
+ def ink_widths(pairs, known: dict | None = None) -> dict[tuple[str, str], float]:
104
+ """`{(字体, 单行文本): 字号 1 时的墨迹宽}`。同一行文本只渲一次;`known` 里已经量过的不再渲
105
+ (叠加预设连跑阶段 3、4 时,阶段 4 用阶段 3 量好的)。返回的包含 `known` 里的。"""
106
+ known = known or {}
107
+ uniq = sorted({(f, ln) for f, t in pairs for ln in t.split("\n") if ln.strip()} - set(known))
108
+ got = render_ink([(f, INK_REF_SIZE, ln) for f, ln in uniq]) if uniq else []
109
+ return {**known, **{k: w / INK_REF_SIZE for k, (w, _) in zip(uniq, got)}}
110
+
111
+
112
+ FIT_MIN_SIZE = 8
113
+
114
+
115
+ def fit_size(text: str, font: str, box_w: float, box_h: float,
116
+ widths: dict[tuple[str, str], float]) -> int:
117
+ """给一条事件定字号:**框高和框宽各给一个上限,取小的**。
118
+
119
+ 只按框高定(上一版)时,字体比屏幕上的字宽、或者 OCR 多读 / 读错了字,渲出来就冲出框外,
120
+ 用途 1 的 overlay 会盖到邻居(2026-09-12 owner 看预览时指出)。
121
+ 宽度上限拿 `ink_widths` 真渲出来的宽反推:`Fontsize = 框宽 / 字号 1 时的墨迹宽`。"""
122
+ lines = [ln for ln in text.split("\n") if ln.strip()] or [text]
123
+ by_h = box_h / len(lines) * h_ratio(font)
124
+ w1 = max(widths.get((font, ln), 0.0) for ln in lines)
125
+ by_w = box_w / w1 if w1 > 0 else by_h
126
+ return max(FIT_MIN_SIZE, int(min(by_h, by_w)))
127
+
128
+
129
+ def ass_time(us: int, up: bool) -> str:
130
+ """µs → `H:MM:SS.cc`。ASS 只到厘秒:**起点向上取整、终点向下取整**,
131
+ 免得两条相邻事件重叠出一个假的"同屏"(`output-format-plan(已归档)`)。"""
132
+ cs = -(-us // 10_000) if up else us // 10_000
133
+ cs = max(cs, 0)
134
+ h, cs = divmod(cs, 360_000)
135
+ m, cs = divmod(cs, 6_000)
136
+ s, cs = divmod(cs, 100)
137
+ return f"{h:d}:{m:02d}:{s:02d}.{cs:02d}"
138
+
139
+
140
+ def ass_text(s: str) -> str:
141
+ """正文进 ASS:换行变 `\\N`,花括号会被当成覆盖标签,换成半角括号。"""
142
+ return s.replace("\\", "/").replace("{", "(").replace("}", ")").replace("\n", "\\N")
143
+
144
+
145
+ def cue_start(doc: dict, cue: dict, evs: list[dict], start: str) -> int:
146
+ """这条 cue 用哪个起点。**这是投影,不是产物里的状态**(第二轮复审):
147
+
148
+ `full`("整条打完")= 各成员 `t_full` 的**最大值**,夹在 cue 里。
149
+ 逐行各算各的是错的:一条字幕里说话人名短、打完得早,台词长、打完得晚,
150
+ 取最早那行会把起点拉回"首字出现"——整片实测起点误差 0.100 s 退回 0.400 s。
151
+
152
+ ⚠ **算完不许写回 `cue["t_start"]`**:写回去之后那个值就不再等于任何事件的
153
+ 首字时间,下一次想切回 `first` 就配不上、**回不去了**。
154
+ 两个起点口径必须都能从同一份 JSON 投影出来。
155
+ """
156
+ lo, hi = cue["t_start"], cue["t_end"]
157
+ if start != "full":
158
+ return lo
159
+ fulls = [e["t_full"] for e in evs if e.get("t_full") is not None]
160
+ return max(lo, min(max(fulls), hi - 1)) if fulls else lo
161
+
162
+
163
+ def export_srt(doc: dict, outdir: Path, only: str = "",
164
+ suffix: str = "", start: str = "first") -> list[tuple[str, int]]:
165
+ """每条轨一份 SRT。**和 `build_tracks` 写出来的应当逐字节相同**——
166
+ 两边都是"同一份 cue 表 + 同一份 ui_lines"的投影。
167
+
168
+ `start="full"` 只改这次导出的起点口径,**不改 JSON**(见 `cue_start`)。
169
+ """
170
+ out = []
171
+ for tr in doc["tracks"]:
172
+ if only and tr["id"] != only:
173
+ continue
174
+ blocks = []
175
+ for cue in tr["cues"]:
176
+ evs = tracksio.cue_lines(doc, tr, cue)
177
+ if evs:
178
+ blocks.append((cue_start(doc, cue, evs, start), cue["t_end"],
179
+ [e["text"] for e in evs]))
180
+ name = tr["srt"][:-4] + suffix + ".srt" if suffix else tr["srt"]
181
+ n = srtio.write_srt_blocks(outdir / name, blocks)
182
+ out.append((name, n))
183
+ return out
184
+
185
+
186
+ def main(argv: list[str] | None = None) -> int:
187
+ ap = argparse.ArgumentParser(description="从 -tracks.json 导出每条轨的 SRT(叠加 ASS 走 flowocr.output.render)")
188
+ ap.add_argument("tracks")
189
+ ap.add_argument("--outdir", default="", help="默认写在 tracks.json 旁边")
190
+ ap.add_argument("--track", default="", help="只导某一条轨")
191
+ ap.add_argument("--start", choices=("first", "full"), default="first",
192
+ help="cue 起点口径:first = 首字出现(默认,和建轨写的 SRT 相同);full = 全字出现(贴合以稳定态计时的工具),"
193
+ "文件名加 -full、不覆盖默认那份。起点口径只在导出时投影,不改 tracks.json")
194
+ a = ap.parse_args(argv)
195
+ src = Path(a.tracks)
196
+ doc = tracksio.load(src)
197
+ outdir = Path(a.outdir) if a.outdir else src.parent
198
+ outdir.mkdir(parents=True, exist_ok=True)
199
+ for name, n in export_srt(doc, outdir, a.track, suffix="-full" if a.start == "full" else "", start=a.start):
200
+ print(f"{name} {n} 条 cue")
201
+ return 0
202
+
203
+
204
+ if __name__ == "__main__":
205
+ raise SystemExit(main())
@@ -0,0 +1,210 @@
1
+ """叠加字幕的排版:字号、锚点、底板、画面边界、按轨迹逐段运动。阶段 3(字幕稿要写预览位置)和阶段 4(生成最终行)共用这一份。
2
+
3
+ 方案:overlay-style 计划(对齐、锚点、底板)、script-fx 计划(拆成字幕稿 + 特效渲染之后这些从 `overlay` 挪到这里)。
4
+ 这里只做几何,不做判断:对齐方式是阶段 2 判好的,效果时刻是阶段 3 判好的。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from flowocr.output import export
9
+
10
+ OVERLAY_PAD = 6
11
+ """底板比 OCR 框外扩的像素:盖住原字笔画的边缘。也是字离画面边的最小距离。"""
12
+
13
+ PLATE_MODES = ("box", "rows", "text")
14
+ """底板怎么画(owner 2026-09-12 要三种比观感):
15
+ * `box`:原字幕正文框的并集,一整块;
16
+ * `rows`:逐行一块,每行宽 = max(原文这一行的区间, 译文这一行的区间)——多行字幕不填满矩形;
17
+ * `text`:只有译文文字底下有底板。"""
18
+
19
+ OVERLAY_MIN_SCALE = 0.75
20
+ """叠加字号不低于"按框高换算的字号"的这么多(owner 2026-09-12:第五个选项字被压得过小)。只给译文。"""
21
+
22
+ ANCHOR = {"left": 4, "center": 5, "right": 6}
23
+ """对齐方式 → `\\an`(竖直居中,横向锚左沿 / 中心 / 右沿)。"""
24
+ ALIGN_OF = {v: k for k, v in ANCHOR.items()}
25
+
26
+
27
+ def text_span(axis: float, w: float, align: str) -> tuple[float, float]:
28
+ """锚在 `axis` 上、宽 `w` 的一行字占的横向区间。"""
29
+ if align == "left":
30
+ return axis, axis + w
31
+ if align == "right":
32
+ return axis - w, axis
33
+ return axis - w / 2, axis + w / 2
34
+
35
+
36
+ def anchor_axis(rows: list[list[float]], align: str, common: bool) -> list[float]:
37
+ """每行的锚轴。`common`(matched 的多行)取整组的公共轴:居中用**最宽那行**的中心,左 / 右用最外的沿——
38
+ `rows_of` 的行框只是这一行**被收下的**事件的并集,一行被 det 切成两框、其中一个被剔时只剩半行,
39
+ 拿它自己的中心当轴,这一行会歪半个行宽;缺了半截的那行不会是最宽的(两行时中位数就是平均,挡不住)。
40
+ 不 `common`(tracks 一个事件一行)取这一行自己的框。"""
41
+ def own(r):
42
+ return r[0] if align == "left" else r[2] if align == "right" else (r[0] + r[2]) / 2
43
+ if not common:
44
+ return [own(r) for r in rows]
45
+ if align == "left":
46
+ a = min(r[0] for r in rows)
47
+ elif align == "right":
48
+ a = max(r[2] for r in rows)
49
+ else:
50
+ a = own(max(rows, key=lambda r: r[2] - r[0]))
51
+ return [a] * len(rows)
52
+
53
+
54
+ def plate_rects(rows: list[list[float]], text_w: list[float], axis, mode: str,
55
+ align: str = "center") -> list[list[float]]:
56
+ """每行一个底板矩形 [x0, y0, x1, y1](`box` 模式一整块)。`axis` 是锚轴(一个数 = 各行共用,或逐行一个);
57
+ 译文区间按锚点算(`text_span`)。上下相邻的两行外扩后会叠一条,半透明时叠的地方更黑,所以从中间劈开。"""
58
+ p = OVERLAY_PAD
59
+ if mode == "box":
60
+ return [[min(r[0] for r in rows) - p, rows[0][1] - p, max(r[2] for r in rows) + p, rows[-1][3] + p]]
61
+ axes = axis if isinstance(axis, list) else [axis] * len(rows)
62
+ out = []
63
+ for r, w, a in zip(rows, text_w, axes):
64
+ tx0, tx1 = text_span(a, w, align)
65
+ x0, x1 = (tx0, tx1) if mode == "text" else (min(r[0], tx0), max(r[2], tx1))
66
+ out.append([x0 - p, r[1] - p, x1 + p, r[3] + p])
67
+ for a, b in zip(out, out[1:]):
68
+ if b[1] < a[3]:
69
+ a[3] = b[1] = (a[3] + b[1]) / 2
70
+ return out
71
+
72
+
73
+ def chunks(p: str, n: int = 24) -> list[str]:
74
+ """长段量字宽时切小块再加:`render_ink` 一页 4096 px 宽,整段渲会被截断。"""
75
+ return [p[i:i + n] for i in range(0, len(p), n)] or [p]
76
+
77
+
78
+ def width_keys(lines: list[str], block: bool) -> list[str]:
79
+ """一条要量哪些字宽:普通条目量每一行(行里的换行拆开量),面板量每段切成的小块。"""
80
+ if block:
81
+ return [c for p in lines for c in chunks(p)]
82
+ return [x for ln in lines for x in ln.split("\n")]
83
+
84
+
85
+ def line_width(font: str, text: str, widths: dict) -> float:
86
+ """字号 1 时一行(可能带换行、按最宽的那段算)的墨迹宽。"""
87
+ return max((widths.get((font, x), 0.0) for x in text.split("\n")), default=0.0)
88
+
89
+
90
+ def fit_line(rows: list[list[float]], lines: list[str], font: str, widths: dict, translated: bool, W: int) -> int:
91
+ """普通条目的字号 = `export.fit_size`:框高按行数分、框宽按 libass 真渲的墨迹宽反推,取小的。
92
+ 译文另有下限:框很窄(选项按钮 `「未着」` 日文括号窄、中文括号宽)时不许缩到框高换算字号的
93
+ `OVERLAY_MIN_SCALE` 以下,宁可让字超出框、底板跟着放宽;但不许超出画面。原文压回自己的框,只按框定
94
+ (框偏高时保底会把字撑出框,gi-s2 摆帧)。"""
95
+ box = [min(r[0] for r in rows), rows[0][1], max(r[2] for r in rows), rows[-1][3]]
96
+ size = export.fit_size("\n".join(lines), font, box[2] - box[0], box[3] - box[1], widths)
97
+ if not translated:
98
+ return size
99
+ # 保底按**看得见的行数**分框高:一个框里画几行(人在字幕稿里加了换行)时,保底不能按一行算,不然几行叠起来冲出框
100
+ n = sum(len([x for x in ln.split("\n") if x.strip()]) or 1 for ln in lines)
101
+ by_h = (box[3] - box[1]) / max(1, n) * export.h_ratio(font)
102
+ w1 = max((line_width(font, ln, widths) for ln in lines), default=0.0)
103
+ size = max(size, int(OVERLAY_MIN_SCALE * by_h))
104
+ return min(size, int((W - 4 * OVERLAY_PAD) / w1)) if w1 > 0 else size
105
+
106
+
107
+ def expand_rows(rows: list[list[float]], k: int, H: int) -> list[list[float]]:
108
+ """正文比原文框装得下的多出 `k` 行:按原来一行的高度(几个框时取行距)往下扩出 `k` 行,往下会出画面就往上扩。
109
+ 扩出的行横向取原文几行合起来的范围;压到别的字不管(owner 2026-09-27:一个框里分成两行时扩出一行,不为重叠做保证)。"""
110
+ rows = [list(r) for r in rows]
111
+ if k <= 0:
112
+ return rows
113
+ h = rows[-1][3] - rows[-1][1]
114
+ pitch = (rows[-1][1] - rows[0][1]) / (len(rows) - 1) if len(rows) > 1 else h
115
+ x0, x1 = min(r[0] for r in rows), max(r[2] for r in rows)
116
+ below = [[x0, rows[-1][1] + j * pitch, x1, rows[-1][1] + j * pitch + h] for j in range(1, k + 1)]
117
+ if below[-1][3] <= H - OVERLAY_PAD:
118
+ return rows + below
119
+ return [[x0, rows[0][1] - j * pitch, x1, rows[0][1] - j * pitch + h] for j in range(k, 0, -1)] + rows
120
+
121
+
122
+ def expand_box(rect: list[float], cap: int, k: int, H: int) -> list[float]:
123
+ """一个框本来装着 `cap` 行(OCR 原文带换行),正文多出 `k` 行:框按一行的高度(框高 / `cap`)长出 `k` 行,方向同 `expand_rows`。"""
124
+ h = (rect[3] - rect[1]) / cap
125
+ grown = expand_rows([[rect[0], rect[1] + i * h, rect[2], rect[1] + (i + 1) * h] for i in range(cap)], k, H)
126
+ return [rect[0], grown[0][1], rect[2], grown[-1][3]]
127
+
128
+
129
+ def same_size(boxes: list[tuple[int, int, list[float]]], sizes: list[int]) -> list[int]:
130
+ """同一栏、同时在屏的选项按钮字号取中位数:各按钮的框高、读法(整句 / 残读并进来的)不一,
131
+ 各算各的就一个大一个小(hsr 0:23 摆帧:第二个选项比兄弟大一号)。`boxes[i]` = (起, 止, 第一行框)。"""
132
+ out = []
133
+ for s0, e0, bi in boxes:
134
+ peers = [sz for (s1, e1, bj), sz in zip(boxes, sizes)
135
+ if s1 < e0 and s0 < e1
136
+ and min(bi[2], bj[2]) - max(bi[0], bj[0]) > 0.5 * min(bi[2] - bi[0], bj[2] - bj[0])]
137
+ out.append(sorted(peers)[len(peers) // 2])
138
+ return out
139
+
140
+
141
+ def axes_in_screen(rows: list[list[float]], text_w: list[float], align: str, common: bool, W: int) -> list[float]:
142
+ """锚轴,最宽那行放不进画面就把整组的轴往里推(左 / 右锚时译文比原文长会从一侧伸出去)。"""
143
+ axes = anchor_axis(rows, align, common)
144
+ spans = [text_span(a, w, align) for a, w in zip(axes, text_w)]
145
+ lo, hi = min(s for s, _ in spans), max(z for _, z in spans)
146
+ pad = OVERLAY_PAD
147
+ shift = pad - lo if lo < pad else (W - pad) - hi if hi > W - pad else 0
148
+ return [a + shift for a in axes]
149
+
150
+
151
+ def block_layout(box: list[float], paras: list[str], row_h: int, font: str, widths: dict,
152
+ size: int | None = None) -> tuple[int, list[str]]:
153
+ """长文本块:译文按段折行填进块里。字号从原文行高换算起,放不下就往小缩(`size` 给了就用它,只折行);
154
+ 折行按每段的平均字宽手算(libass 的自动折行只在空格处断,中文整段不会折)。返回(字号, 折好的行,段间空一行)。"""
155
+ bw, bh = box[2] - box[0], box[3] - box[1]
156
+ unit = [sum(widths.get((font, c), 0.0) for c in chunks(p)) / max(1, len(p)) for p in paras]
157
+
158
+ def layout(size: int) -> list[str]:
159
+ out = []
160
+ for p, u in zip(paras, unit):
161
+ k = max(1, int(bw / max(u * size, 1e-6)))
162
+ out += [p[i:i + k] for i in range(0, len(p), k)] + [""]
163
+ return out[:-1]
164
+ if size is not None:
165
+ return size, layout(size)
166
+ size = max(export.FIT_MIN_SIZE, int(row_h * export.h_ratio(font)))
167
+ while size > export.FIT_MIN_SIZE and len(layout(size)) * size > bh:
168
+ size -= 1
169
+ return size, layout(size)
170
+
171
+
172
+ def motion_segments(boxes: list | None, s: int, e: int) -> list[tuple[int, int, tuple, tuple]]:
173
+ """在动的事件在 `[s, e]` 里**按轨迹逐段拆**,每段 = (起, 止, 起点框左上角, 止点框左上角)。
174
+
175
+ 一条从首点到末点的 `\\move` 会把中间过程抹平(先静止后上移的字被画成从头匀速滑、步进滚动被画成平滑滚动),
176
+ 所以同一位置的相邻两点是一段停顿,位置变了才是一段位移。轨迹段被 `[s, e]` 裁掉一半时端点**跟着插值**——
177
+ 照搬原端点会把整段位移重播一遍。轨迹之外的尾巴停在最后一个位置。不动的(没有轨迹)返回一段、端点为 None。"""
178
+ if not boxes or len(boxes) < 2:
179
+ return [(s, e, None, None)]
180
+
181
+ def at(b0, b1, t0, t1, t):
182
+ f = 0.0 if t1 <= t0 else (t - t0) / (t1 - t0)
183
+ return (b0[0] + (b1[0] - b0[0]) * f, b0[1] + (b1[1] - b0[1]) * f)
184
+
185
+ out = []
186
+ for (t0, b0), (t1, b1) in zip(boxes, boxes[1:]):
187
+ a, z = max(s, t0), min(e, t1)
188
+ if z <= a:
189
+ continue
190
+ if b0[:2] == b1[:2]:
191
+ out.append((a, z, (b0[0], b0[1]), (b0[0], b0[1])))
192
+ else:
193
+ out.append((a, z, at(b0, b1, t0, t1, a), at(b0, b1, t0, t1, z)))
194
+ last_t, last_b = boxes[-1]
195
+ if e > max(s, last_t):
196
+ out.append((max(s, last_t), e, (last_b[0], last_b[1]), (last_b[0], last_b[1])))
197
+ return out or [(s, e, None, None)]
198
+
199
+
200
+ def pos_tag(x: float, y: float, seg: tuple, base: tuple[float, float]) -> str:
201
+ """一个元素在一段里的位置标签:静止用 `\\pos`,位移用 `\\move`。`(x, y)` 是按 `base`(条目框的左上角)
202
+ 排出来的静止位置,在动的段把它平移到轨迹上。"""
203
+ a, z, p0, p1 = seg
204
+ if p0 is None:
205
+ return "\\pos(%.0f,%.0f)" % (x, y)
206
+ x0, y0 = x + p0[0] - base[0], y + p0[1] - base[1]
207
+ if p0 == p1:
208
+ return "\\pos(%.0f,%.0f)" % (x0, y0)
209
+ x1, y1 = x + p1[0] - base[0], y + p1[1] - base[1]
210
+ return "\\move(%.0f,%.0f,%.0f,%.0f,0,%d)" % (x0, y0, x1, y1, max(z - a, 0) // 1000)
@@ -0,0 +1,6 @@
1
+ """内置输出预设。一个模块一个预设,入口是 `render(document, output_dir, options) -> list[Path]`(`flowocr.extensions`)。
2
+
3
+ 现在有:`srt_main`(主轨一份 SRT)、`matched_srt`(匹配产物拍平成 SRT)、`script`(只写字幕稿,阶段 3)、
4
+ 叠加三个 `default` / `default_all` / `dev`(字幕稿 + 阶段 4 的最终 ASS,`_overlay`)。
5
+ 用户自己写的预设不必放进来——`extensions.load("<路径或模块名>", "preset")` 同样能加载;可复制改的例子在仓库的 `examples/`。
6
+ """
@@ -0,0 +1,40 @@
1
+ """三个叠加预设(`default` / `default_all` / `dev`)的共同实现:阶段 3 写字幕稿、阶段 4 接着生成最终 ASS,两份都留下。
2
+
3
+ | 预设 | 阶段 3(留哪些) | 阶段 4(长什么样) |
4
+ | --- | --- | --- |
5
+ | `default` | `keep=main`:tracks 主轨 + 名牌轨 / matched 的 body、extra、name | `default`:底板 α 0x20 |
6
+ | `default_all` | `keep=all`:全部区域轨 / 全部层 + 没叠译文的原文 | `default` |
7
+ | `dev` | `keep=all`,另写 `why` | `dev`:底板 α 0x80、UI 黄字、非主轨浅蓝、区域号 |
8
+
9
+ options 按键分给两个阶段:`keep` `why` `lang` `layers` `tracks` 给阶段 3(`flowocr.output.script.render_script`),
10
+ `plate` `plate_alpha` `unknown` 给阶段 4(`flowocr.typeset.core`),`typewriter` `fade` 两边都给
11
+ (阶段 3 的 off 是不判、不写,阶段 4 的 off 是不画;`typewriter=off` 时阶段 3 照原来的规矩改判淡入)。
12
+ `name` 是最终 ASS 的文件名,字幕稿在它旁边、`.ass` 换成 `.script.ass`。
13
+ 阶段 3 量好的字宽直接交给阶段 4,不再起一遍 ffmpeg。返回 `[最终 ASS, 字幕稿]`(`dev_tools/preview.py`、`scriptmatch --ass` 靠这个顺序和后缀)。
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+
19
+ from flowocr.output import script
20
+ from flowocr.typeset import core
21
+
22
+ PRESETS = {"default": ("main", "off", "default"), "default_all": ("all", "off", "default"), "dev": ("all", "on", "dev")}
23
+ STAGE3 = ("keep", "why", "lang", "layers", "tracks", "typewriter", "fade")
24
+ STAGE4 = ("plate", "plate_alpha", "unknown", "typewriter", "fade")
25
+
26
+
27
+ def render(document: dict, output_dir, options: dict | None, preset: str) -> list[Path]:
28
+ o = dict(options or {})
29
+ keep, why, look = PRESETS[preset]
30
+ o3 = {"keep": keep, "why": why, **{k: v for k, v in o.items() if k in STAGE3}}
31
+ o4 = {k: v for k, v in o.items() if k in STAGE4}
32
+ core.look_of(look, o4) # 选项写错了在写字幕稿之前就报
33
+ final = None
34
+ if o.get("name"):
35
+ name = str(o["name"])
36
+ o3["name"] = (name[:-4] if name.endswith(".ass") else name) + ".script.ass"
37
+ final = Path(output_dir) / name # 最终文件就叫给的名字(不以 .ass 结尾也不改)
38
+ draft, _n, _items, widths = script.render_script(document, output_dir, o3, suffix=preset)
39
+ final, _ts = core.typeset_file(draft, final, look, o4, widths=widths)
40
+ return [final, draft]
@@ -0,0 +1,21 @@
1
+ """内置预设 `default`(**生产默认**,owner 2026-09-24;`render` 不给 `--preset` 时就是它):只留主轨的叠加字幕。
2
+
3
+ * tracks 产物:主轨(和 `srt_main` 同一个行集,常驻 UI 按主轨自己的判定剔掉)+ 名牌轨;
4
+ * matched 产物:`body` / `extra` / `name` 三层——认领上剧本的台词,不论在不在主轨(`--feed nonoise` 多出来的正是主轨外的台词);
5
+ * 样式:底板 `rows`、近黑、α 0x20;对齐按 `regions[].align`,效果检出即开。
6
+
7
+ 写两份:字幕稿 `<tag>[-<lang>]-default.script.ass`(阶段 3,能在 Aegisub 里编辑)和最终的 `<tag>[-<lang>]-default.ass`(阶段 4)。
8
+ 选项和实现见 `flowocr.output.presets._overlay`;方案见 script-fx 计划。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ from flowocr.artifacts import matchedio, tracksio
15
+ from flowocr.output.presets import _overlay
16
+
17
+ ACCEPTS = (tracksio.SCHEMA, matchedio.SCHEMA)
18
+
19
+
20
+ def render(document: dict, output_dir, options: dict | None = None) -> list[Path]:
21
+ return _overlay.render(document, output_dir, options, "default")
@@ -0,0 +1,20 @@
1
+ """内置预设 `default_all`(owner 2026-09-24):同 `dev` 的保留范围,但成品的样子。
2
+
3
+ * tracks 产物:全部区域轨的事件,常驻 UI 也照常画(删不删由用户自己判断);matched 产物:全部六层,没叠译文的事件(没认领上剧本、或没这个语种的译文)照 tracks 画 OCR 原文;
4
+ * 不标聚类编号、不改非主轨的字色;
5
+ * 底板 `rows`,近黑、α 0x20(更不透明,盖住原文);对齐按 `regions[].align`,效果检出即开。
6
+
7
+ 写两份:字幕稿 `…-default_all.script.ass` 和最终的 `…-default_all.ass`。选项和实现见 `flowocr.output.presets._overlay`;方案见 script-fx 计划。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from flowocr.artifacts import matchedio, tracksio
14
+ from flowocr.output.presets import _overlay
15
+
16
+ ACCEPTS = (tracksio.SCHEMA, matchedio.SCHEMA)
17
+
18
+
19
+ def render(document: dict, output_dir, options: dict | None = None) -> list[Path]:
20
+ return _overlay.render(document, output_dir, options, "default_all")
@@ -0,0 +1,21 @@
1
+ """内置预设 `dev`(开发默认,owner 2026-09-24):**全部保留**的叠加字幕,看得出每条字属于哪个聚类。
2
+
3
+ * tracks 产物:全部区域轨的事件(含常驻 UI,黄字 #ffe033);matched 产物:全部六层,没叠译文的事件(没认领上剧本、或没这个语种的译文)照 tracks 画 OCR 原文;
4
+ * 非主轨聚类的字用浅蓝(#73d7ff),每条字幕底板外接矩形内侧的右上角一个红字(#ff2d2d)区域号(不影响字幕本身的位置和对齐);
5
+ * 底板 `rows`,近黑、α 0x80(50%,看得见底下的原文);对齐按阶段 2 判的 `regions[].align`,效果检出即开;
6
+ * 字幕稿里每行的 `fo` 另带 `why`(效果判定的依据)。
7
+
8
+ 写两份:字幕稿 `…-dev.script.ass` 和最终的 `…-dev.ass`。选项和实现见 `flowocr.output.presets._overlay`;方案见 script-fx 计划。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ from flowocr.artifacts import matchedio, tracksio
15
+ from flowocr.output.presets import _overlay
16
+
17
+ ACCEPTS = (tracksio.SCHEMA, matchedio.SCHEMA)
18
+
19
+
20
+ def render(document: dict, output_dir, options: dict | None = None) -> list[Path]:
21
+ return _overlay.render(document, output_dir, options, "dev")
@@ -0,0 +1,32 @@
1
+ """内置预设:把**匹配产物**(`*-matched.json`)拍平成一份 SRT——匹配上的出剧本原文,没匹配上原样留 OCR。
2
+
3
+ 和 `scriptmatch --srt` 写的**逐字节相同**(同一份 `cues`、同一个 `matchedio.body`、同一个 `srtio` 写出;守卫钉着)。
4
+ ⚠ SRT 是**拍平投影,只出主 ref**:覆盖(ref ∪ extra)比这里出现的多,量覆盖看匹配产物本身(matcher 计划)。
5
+
6
+ options:
7
+ * `lang`:`jp`(默认)/ `cn`,正文取哪种语言;
8
+ * `name`:文件名(默认 `<matched 的 stem 去掉 -matched>-<lang>.srt`)。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ from flowocr.artifacts import matchedio, srtio
15
+
16
+ ACCEPTS = (matchedio.SCHEMA,)
17
+ """这个预设吃哪几种阶段 2 产物(`flowocr.output.render` 按 `document["schema"]` 核)。"""
18
+
19
+
20
+ def render(document: dict, output_dir, options: dict | None = None) -> list[Path]:
21
+ o = dict(options or {})
22
+ matchedio.validate(document)
23
+ lang = str(o.get("lang", "jp"))
24
+ if lang not in ("jp", "cn"):
25
+ raise ValueError(f"lang 只能是 jp / cn:{lang!r}")
26
+ out = Path(output_dir)
27
+ out.mkdir(parents=True, exist_ok=True)
28
+ stem = Path(matchedio.tracks_path(document) or "matched").stem.removesuffix("-tracks")
29
+ p = out / str(o.get("name") or f"{stem}-{lang}.srt")
30
+ srtio.write_srt_blocks(p, ((int(r["start"] * 1e6), int(r["end"] * 1e6), matchedio.body(r, lang))
31
+ for r in document["cues"]))
32
+ return [p]
@@ -0,0 +1,20 @@
1
+ """内置预设 `script`:只跑阶段 3,写一份**字幕稿**(`*.script.ass`),在 Aegisub / mpv 里预览、编辑,改完再跑阶段 4:
2
+
3
+ python -m flowocr.output.render x-tracks.json --preset script --opt keep=all --outdir tmp/x
4
+ python -m flowocr.typeset tmp/x/x-all.script.ass
5
+
6
+ options 见 `flowocr.output.script.render_script`(`keep` main / all、`why`、`lang`、`layers`、`tracks`、`typewriter`、`fade`、`name`)。
7
+ 字幕稿的格式见 artifacts.md 的字幕稿一节;方案见 script-fx 计划。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from flowocr.artifacts import matchedio, tracksio
14
+ from flowocr.output import script
15
+
16
+ ACCEPTS = (tracksio.SCHEMA, matchedio.SCHEMA)
17
+
18
+
19
+ def render(document: dict, output_dir, options: dict | None = None) -> list[Path]:
20
+ return [script.render_script(document, output_dir, options)[0]]
@@ -0,0 +1,33 @@
1
+ """内置预设:**只取主轨**的一份 SRT(project-structure:"SRT 默认可以只取主轨")。
2
+
3
+ 从 `*-tracks.json` 投影(`export.export_srt`),和 `build_tracks` 写在产物旁的那份主轨 SRT **逐字节相同**——
4
+ 同一份 cue 表、同一份 `ui_lines`,导出器不重新判断(docs/architecture/artifacts.md)。
5
+
6
+ options:
7
+ * `start`:cue 起点口径,`first`(默认,首字出现)/ `full`(全字出现);
8
+ * `name`:文件名(默认用 provenance 里的 `main_srt`,即 build_tracks 写的那个名字)。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ from flowocr.artifacts import tracksio
15
+ from flowocr.output import export
16
+
17
+ ACCEPTS = (tracksio.SCHEMA,)
18
+ """这个预设吃哪几种阶段 2 产物(`flowocr.output.render` 按 `document["schema"]` 核)。"""
19
+
20
+
21
+ def render(document: dict, output_dir, options: dict | None = None) -> list[Path]:
22
+ o = dict(options or {})
23
+ tid = document["provenance"].get("main_track") or ""
24
+ if not tid:
25
+ raise ValueError("这份产物没有主轨(provenance.main_track 为空)——挑不出主轨时用别的预设或自己写一个")
26
+ out = Path(output_dir)
27
+ out.mkdir(parents=True, exist_ok=True)
28
+ written = [out / name for name, _n in export.export_srt(document, out, only=tid, start=o.get("start", "first"))]
29
+ if o.get("name"):
30
+ target = out / str(o["name"])
31
+ written[0].replace(target)
32
+ written = [target]
33
+ return written