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,821 @@
1
+ """阶段 4 的实现:读一份字幕稿(或它自己的输出),按源行生成最终的叠加行。
2
+
3
+ 方案:script-fx 计划。约定(artifacts.md 的字幕稿一节):
4
+
5
+ * 源行 = `[Events]` 里 Effect 是 `fo:…`(阶段 3 写的参数)或空的 `Dialogue:`。处理过的源行改成 `Comment:`、
6
+ Effect 前面加 `fo-src|`;生成行 Effect 标 `fo-fx`、样式用派生的 `<Style>.fo-fx`。
7
+ 别的行(用户自己注释掉的、别人的 `fx`、`Banner;`…)原样留着。
8
+ * `restore` 删掉 `fo-fx` 行和派生样式、把 `fo-src|` 行恢复成源行,得到逐字节相同的字幕稿;处理之前先自动 `restore`,
9
+ 所以可以反复重跑。
10
+ * 带 `fo` 参数的源行按参数生成,**固定顺序**:排版(分行、字号、每行锚点、量文字边界)→ 元素(正文、底板、编号,
11
+ 外加扩展的元素)→ 运动(所有元素一起按 `path` 逐段展开)→ 时间效果(逐字 `\\alpha`、`\\fad`、配色)。
12
+ 不带 `fo` 的源行(手补的)照主流 tag 原样生成一条,只换样式。
13
+ * 谁说了算(owner 2026-09-27):`fo` 的 `was` 记着阶段 3 写下的、归阶段 4 管的 tag 的值;源行里的值不一样就是人改过,
14
+ 这一项以人为准、管它的参数在这一行上作废,打一行提示。
15
+ * 打字机:`fo` 的 `tw`(首字出现到全字出现的毫秒数)逐字匀速显出;人在正文里写了 `\\k` 族 tag 的行按人写的节奏,`tw` 不用。
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from dataclasses import dataclass, field, replace
21
+ from pathlib import Path
22
+
23
+ from flowocr import extensions
24
+ from flowocr.output import export, layout
25
+ from flowocr.typeset import assfile
26
+
27
+ EFFECT_SRC, EFFECT_FX = "fo-src|", "fo-fx"
28
+ """处理过的源行 Effect 的前缀(后面是原来的 Effect,`restore` 去掉它);生成行的 Effect。"""
29
+ FX_STYLE_SUFFIX = ".fo-fx"
30
+ """派生样式名的后缀。`restore` 按它删派生样式,所以要一个用户不会自己起的名字(`.fx` 会撞上手起的 `Sign.fx`)。"""
31
+ INFO_KEY = "FlowOCR Typeset"
32
+
33
+ OVERLAY_COLOR = "101010"
34
+ """底板颜色(ASS 的 BBGGRR):近黑。透明度另给(`plate_alpha`)。"""
35
+ OFFMAIN_COLOR = "FFD773"
36
+ """`dev` 里非主轨聚类的字色(BBGGRR):#73d7ff 浅蓝(owner 2026-09-25:原来的淡红和白字区别不够明显)。"""
37
+ UI_COLOR = "33E0FF"
38
+ """`dev` 里被判成常驻 UI 的字色(BBGGRR):#ffe033 黄(owner 2026-09-25,原来是灰)。"""
39
+ LABEL_SCALE, LABEL_MIN = 0.6, 18
40
+ """`dev` 的聚类编号:正文字号的这么多倍,不小于这个字号(ASS 的 Fontsize)。"""
41
+ LABEL_COLOR, LABEL_INSET = "2D2DFF", 3
42
+ """`dev` 的聚类编号:字色 #ff2d2d(BBGGRR),画在这条字幕底板外接矩形的**内侧**右上角、离边这么多像素
43
+ (owner 2026-09-25:原来在底板外侧、太小,看不清也分不清属于哪条)。"""
44
+
45
+ FX_STYLE = {"OutlineColour": "&H00101010", "BackColour": "&H00000000", "BorderStyle": "1", "Outline": "2", "Shadow": "0"}
46
+ """派生样式 `<Style>.fo-fx` 覆盖源样式的这几项:字幕稿的样式用 `BorderStyle=3` 出预览底框(没有描边),
47
+ 生成行要的是现在叠加的样子(近黑描边 2、没有框、没有阴影)。字体、颜色、字号这些跟着源样式走。"""
48
+
49
+ TYPEWRITER_MODES = ("off", "on", "dim")
50
+ FO_PARAMS = {"ev", "key", "box", "fit", "rows", "cap", "plate", "path", "tw", "wrap", "off", "why", "was", "spk"}
51
+ """内置认得的 `fo` 参数;扩展效果的名字另加。不认识的当场报错(`unknown=ignore` 才跳过)。"""
52
+ KIND_OF_STYLE = {"Panel": "block", "Choice": "choice", "Offmain": "offmain", "Name": "name"}
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Look:
57
+ """阶段 4 的一个预设:长什么样。`label`:底板内侧右上角画区域号;`colors`:UI 黄字、非主轨浅蓝。"""
58
+ alpha: int
59
+ plate: str
60
+ typewriter: str
61
+ fade: str
62
+ label: bool
63
+ colors: bool
64
+
65
+
66
+ LOOKS = {
67
+ "default": Look(0x20, "rows", "on", "on", False, False),
68
+ # owner 2026-09-24:开发看——底板 50%、UI 黄字、非主轨浅蓝、底板内侧右上角红字区域号(颜色与编号位置 09-25 改)
69
+ "dev": Look(0x80, "rows", "on", "on", True, True),
70
+ }
71
+
72
+
73
+ @dataclass
74
+ class Element:
75
+ """一条生成行的内容:行首 tag 块是 `head` + 位置 + `tail` + 淡入淡出 + `tail2`,后面接 `body`。
76
+ 位置由 `(x, y)` 按轨迹算(`layout.pos_tag`),`where` 给了就直接用它(人改过位置的行)。
77
+ `fade`:这个元素跟着源行淡入淡出;`tokens`:逐字显出的正文记号(`body` 由它按段重算)——
78
+ 在动的行按轨迹拆成几段时,时间效果按**源行**的时间算、换成各段的相对时间,不在每段从头放一遍。"""
79
+ layer: int
80
+ x: float
81
+ y: float
82
+ head: str
83
+ tail: str
84
+ body: str
85
+ kind: str
86
+ where: str | None = None
87
+ tail2: str = ""
88
+ fade: bool = False
89
+ tokens: list | None = None
90
+
91
+
92
+ @dataclass
93
+ class Line:
94
+ """一条源行排好版的样子,扩展效果的挂点读它(只读)。"""
95
+ style: str
96
+ actor: str
97
+ params: dict
98
+ start_us: int
99
+ end_us: int
100
+ region: int
101
+ kind: str
102
+ rows: list[list[float]]
103
+ lines: list[str]
104
+ size: int
105
+ align: str
106
+ plates: list[list[float]] = field(default_factory=list)
107
+ look: Look | None = None
108
+
109
+
110
+ # ---- 还原 ----
111
+
112
+ def restore(doc: assfile.Doc, origin: list[int] | None = None) -> int:
113
+ """删掉 `fo-fx` 行、派生样式和阶段 4 写的 `[Script Info]` 键,把 `fo-src|` 行恢复成源行(去掉前缀)。返回恢复的源行数。
114
+ `origin` 给了就填上 `[Events]` 里留下的每一行原来是这一节的第几行(报提示时按用户打开的那份文件报行号)。"""
115
+ n = 0
116
+ ev = doc.section("Events")
117
+ if ev is not None:
118
+ names = assfile.format_of(ev) or []
119
+ kept = []
120
+ for i, ln in enumerate(ev.lines):
121
+ r = assfile.parse_row(ln, names, assfile.EVENT_KINDS) if names else None
122
+ if r is not None and r.get("Effect").strip() == EFFECT_FX:
123
+ continue
124
+ if r is not None and r.kind == "Comment" and r.get("Effect").startswith(EFFECT_SRC):
125
+ r.kind = "Dialogue"
126
+ r.set("Effect", r.get("Effect")[len(EFFECT_SRC):])
127
+ ln = str(r)
128
+ n += 1
129
+ kept.append(ln)
130
+ if origin is not None:
131
+ origin.append(i)
132
+ ev.lines = kept
133
+ st = doc.section("V4+ Styles")
134
+ if st is not None:
135
+ names = assfile.format_of(st) or []
136
+ st.lines = [ln for ln in st.lines
137
+ if not ((r := assfile.parse_row(ln, names, ("Style",))) and r.get("Name").endswith(FX_STYLE_SUFFIX))]
138
+ info = doc.section("Script Info")
139
+ if info is not None:
140
+ info.lines = [ln for ln in info.lines if not ln.startswith(INFO_KEY + ":")]
141
+ return n
142
+
143
+
144
+ # ---- 读源行 ----
145
+
146
+ def cs_of(t: str) -> int:
147
+ h, m, s = t.strip().split(":")
148
+ return (int(h) * 3600 + int(m) * 60) * 100 + round(float(s) * 100)
149
+
150
+
151
+ def parse_was(v) -> dict[str, str]:
152
+ if not isinstance(v, str):
153
+ return {}
154
+ return dict(x.split(":", 1) for x in v.split("|") if ":" in x)
155
+
156
+
157
+ def box4(s: str) -> list[float]:
158
+ b = assfile.numbers(s)
159
+ if len(b) != 4:
160
+ raise ValueError(f"框要 4 个数(x0 y0 x1 y1),给的是 {s!r}")
161
+ return b
162
+
163
+
164
+ def rows_of(v) -> list[list[float]]:
165
+ return [box4(r) for r in str(v).split("|") if r]
166
+
167
+
168
+ def path_of(v) -> list:
169
+ out = []
170
+ for p in str(v).split("|"):
171
+ t, b = p.split(":", 1)
172
+ out.append([int(t), box4(b)])
173
+ return out
174
+
175
+
176
+ def text_rows(text: str) -> tuple[list[list[tuple]], list[str], str, int | None]:
177
+ """去掉 `fo` 之后的正文 → (逐行的记号, 逐行的纯文字, 行首块里不归阶段 4 管的 tag, 全字出现的时刻毫秒)。
178
+ 记号是 ("tags", 块内容) / ("ch", 字, 出现时刻毫秒或 None) / ("nl",)(行里的软换行 `\\n` 不算分行)。
179
+ 卡拉OK(人手写的节奏):每个 `\\k` 族 tag 开一个音节,音节里的字在音节起点出现(音节起点 = 前面各音节时长之和,厘秒);
180
+ 全字出现 = 最后一个音节的起点;没有卡拉OK是 None。"""
181
+ rows: list[list[tuple]] = [[]]
182
+ t_cs, cur, has_k = 0, None, False
183
+ lead = ""
184
+ parts = assfile.blocks(text)
185
+ for i, (kind, s) in enumerate(parts):
186
+ if kind == "tags":
187
+ kept = []
188
+ for t in assfile.split_tags(s): # 顶层的 tag:\t(…,\fs80) 里面的不单算
189
+ got = assfile.tag_of(t)
190
+ if got and got[0] == "k": # 卡拉OK 计时换成逐字 \alpha,不留
191
+ cur = t_cs
192
+ t_cs += int(got[1])
193
+ has_k = True
194
+ continue
195
+ if got and i == 0: # 行首块里归阶段 4 管的(\an \pos \move \fs \fad)由阶段 4 重写
196
+ continue
197
+ kept.append(t) # 其余(行内的 \fs80、\1c、\t(…)…)原样留着
198
+ rest = "".join(kept)
199
+ if i == 0:
200
+ lead = rest
201
+ elif rest.strip():
202
+ rows[-1].append(("tags", rest))
203
+ continue
204
+ j = 0
205
+ while j < len(s):
206
+ if s.startswith("\\N", j):
207
+ rows.append([])
208
+ j += 2
209
+ continue
210
+ if s.startswith("\\n", j):
211
+ rows[-1].append(("nl",))
212
+ j += 2
213
+ continue
214
+ ch = "\\h" if s.startswith("\\h", j) else s[j] # 硬空格 \h 是一个字:逐字 \alpha 不能插进它中间
215
+ rows[-1].append(("ch", ch, None if cur is None else cur * 10))
216
+ j += len(ch)
217
+ plain = ["".join(PLAIN.get(t[1], t[1]) for t in r if t[0] == "ch") for r in rows]
218
+ return rows, plain, lead, cur * 10 if has_k else None
219
+
220
+
221
+ PLAIN = {"\\h": " "}
222
+ """纯文字(量字宽、折行、给扩展效果看)里转义换成的字:`\\h` 是不断行的空格。"""
223
+
224
+
225
+ def with_tw(rows: list[list[tuple]], tw_ms: int, nl_slot: bool) -> list[list[tuple]]:
226
+ """`tw` 匀速逐字:几行合起来的第 k 个位置(从 0 数)在 `tw_ms * k // (n - 1)` 毫秒出现——首字在起点、末字正好在全字出现。
227
+ `nl_slot`:行里的换行(整块画在一条里的几行)也占一个位置(和拆之前逐字 `\\alpha` 的算法一样:tracks 事件本身带的换行算一个字);
228
+ 按原框逐行摆的几行之间不占。"""
229
+ kinds = ("ch", "nl") if nl_slot else ("ch",)
230
+ n = sum(t[0] in kinds for r in rows for t in r)
231
+ last, k, out = max(1, n - 1), 0, []
232
+ for r in rows:
233
+ row = []
234
+ for t in r:
235
+ if t[0] == "ch":
236
+ row.append(("ch", t[1], tw_ms * k // last))
237
+ else:
238
+ row.append(t)
239
+ k += t[0] in kinds
240
+ out.append(row)
241
+ return out
242
+
243
+
244
+ def body_of(tokens: list[tuple], mode: str, full_ms: int | None = None) -> str:
245
+ """一行的正文:`on` 逐字 `\\alpha`(没出现的字全透明但照样占位置,居中的行不会边打边挪);
246
+ `dim` 全字出现(`full_ms`)之前整行半透明;`off` 直接显出。"""
247
+ if mode == "dim":
248
+ plain = body_of(tokens, "off")
249
+ return ("{\\alpha&H80&\\t(%d,%d,\\alpha&H00&)}" % (full_ms, full_ms + 1) + plain) if full_ms is not None else plain
250
+ out = []
251
+ for t in tokens:
252
+ if t[0] == "tags":
253
+ out.append("{" + t[1] + "}")
254
+ elif t[0] == "nl":
255
+ out.append("\\N")
256
+ elif mode == "on" and t[2] is not None:
257
+ out.append("{\\alpha&HFF&\\t(%d,%d,\\alpha&H00&)}" % (t[2], t[2] + 1) + t[1])
258
+ else:
259
+ out.append(t[1])
260
+ return "".join(out)
261
+
262
+
263
+ def rows_joined(rows: list[list[tuple]]) -> list[tuple]:
264
+ """几行并成一行(行间 `\\N`):一个原文框、译文却有几行时,整块画在这个框里。"""
265
+ out: list[tuple] = []
266
+ for k, r in enumerate(rows):
267
+ if k:
268
+ out.append(("nl",))
269
+ out += r
270
+ return out
271
+
272
+
273
+ @dataclass
274
+ class Src:
275
+ """一条要处理的源行。"""
276
+ no: int # 文件里的行号(从 1 数),报提示用
277
+ row: assfile.Row
278
+ fo: dict | None
279
+ text: str
280
+
281
+ @property
282
+ def style(self) -> str:
283
+ return self.row.get("Style").strip()
284
+
285
+ @property
286
+ def actor(self) -> str:
287
+ return self.row.get("Name")
288
+
289
+
290
+ # ---- 生成 ----
291
+
292
+ def derive_style(r: assfile.Row, name: str) -> str:
293
+ """源样式 `r` → 派生样式 `<name>.fo-fx`(`FX_STYLE` 那几项换掉,其余照抄)。"""
294
+ d = replace(r, fields=list(r.fields))
295
+ d.set("Name", name + FX_STYLE_SUFFIX)
296
+ for k, v in FX_STYLE.items():
297
+ if k in r.names:
298
+ d.set(k, v)
299
+ return str(d)
300
+
301
+
302
+ def plate_el(rect: list[float], back: str) -> Element:
303
+ """一块底板:矩形 `[x0, y0, x1, y1]` 画成一条绘图(左上角锚点),`back` 是颜色和透明度;跟着源行淡入淡出。"""
304
+ w, h = rect[2] - rect[0], rect[3] - rect[1]
305
+ return Element(0, rect[0], rect[1], "\\an7", "\\p1\\bord0\\shad0" + back,
306
+ "m 0 0 l %.0f 0 %.0f %.0f 0 %.0f{\\p0}" % (w, w, h, h), "plate", fade=True)
307
+
308
+
309
+ def carry_rows(rows: list[list[tuple]]) -> list[list[tuple]]:
310
+ """按原框逐行摆时每行是一条单独的 Dialogue;前面几行里的行内 tag(`A{\\1c&H0000FF&}B\\NCD` 的颜色)在源行里对下一行照样生效,
311
+ 所以按顺序补到下一行开头(后出现的覆盖先出现的,和 libass 读一整行时一样)。"""
312
+ out, carried = [], []
313
+ for r in rows:
314
+ out.append(([("tags", "".join(carried))] if carried else []) + r)
315
+ carried += [t[1] for t in r if t[0] == "tags"]
316
+ return out
317
+
318
+
319
+ def runs_of(tokens: list[tuple]) -> list[list[tuple[str, float | None]]]:
320
+ """一条的记号 → 看得见的每一行(行里的 `\\n` / 并起来的几行分开)按行内 `\\fs` 切成的几段 (文字, 字号;None = 整行字号)。
321
+ `\\r` 回到样式的字号。`\\t(…,\\fs80)` 这种渐变不算(量的是起始的样子)。"""
322
+ lines: list[list[tuple[str, float | None]]] = [[]]
323
+ size, cur = None, ""
324
+ for t in tokens:
325
+ if t[0] == "tags":
326
+ new = size
327
+ for tag in assfile.split_tags(t[1]):
328
+ got = assfile.tag_of(tag)
329
+ if got and got[0] == "fs":
330
+ new = float(got[1])
331
+ elif re.fullmatch(r"\\r[^\\]*", tag.rstrip()):
332
+ new = None
333
+ if new != size:
334
+ if cur:
335
+ lines[-1].append((cur, size))
336
+ cur, size = "", new
337
+ elif t[0] == "nl":
338
+ if cur:
339
+ lines[-1].append((cur, size))
340
+ cur = ""
341
+ lines.append([])
342
+ else:
343
+ cur += PLAIN.get(t[1], t[1])
344
+ if cur:
345
+ lines[-1].append((cur, size))
346
+ return lines
347
+
348
+
349
+ def tokens_width(tokens: list[tuple], size: int, font: str, widths: dict) -> float:
350
+ """一条正文画出来的宽(像素):看得见的每一行里各段的墨迹宽 × 各自的字号,取最宽的那行。行内没有 `\\fs` 时就是整行 × 字号。"""
351
+ return max((sum(widths.get((font, s), 0.0) * (sz or size) for s, sz in ln) for ln in runs_of(tokens)), default=0.0)
352
+
353
+
354
+ def shift_times(block: str, a: int, dur: int) -> str:
355
+ """一个 tag 块里人写的 `\\t(…)` / `\\fade(…)` 的时刻(相对源行起点)改成相对这一段的起点(源行起点后 `a` 毫秒):
356
+ 段接段连起来和一整条相同,不在每段从头变一遍。`\\t` 没写时刻或结束时刻写 0 的是整条 `[0, dur]`(libass 把结束 0 读成到行尾);
357
+ 时刻减完可以是负的(libass 照算进度),但段开始前已经变完的 `\\t` 换成里面的 tag 本身——结束时刻减到正好 0 会被当成到段尾。"""
358
+ out = []
359
+ for tag in assfile.split_tags(block):
360
+ m = re.fullmatch(r"\\(t|fade)\((.*)\)\s*", tag, re.S)
361
+ if not m:
362
+ out.append(tag)
363
+ continue
364
+ args = split_args(m[2])
365
+ if m[1] == "fade" and len(args) == 7:
366
+ out.append("\\fade(%s,%s,%s,%d,%d,%d,%d)" % (*args[:3], *(int(float(v)) - a for v in args[3:])))
367
+ continue
368
+ if m[1] == "t":
369
+ nums = [x for x in args if not x.lstrip().startswith("\\")]
370
+ rest = ",".join(x for x in args if x.lstrip().startswith("\\"))
371
+ t1, t2 = (float(nums[0]), float(nums[1]) or float(dur)) if len(nums) >= 2 else (0.0, float(dur))
372
+ if t2 - a <= 0:
373
+ out.append(rest)
374
+ continue
375
+ accel = nums[2] if len(nums) >= 3 else nums[0] if len(nums) == 1 else None
376
+ out.append("\\t(%d,%d,%s%s)" % (t1 - a, t2 - a, accel + "," if accel else "", rest))
377
+ continue
378
+ out.append(tag)
379
+ return "".join(out)
380
+
381
+
382
+ def split_args(s: str) -> list[str]:
383
+ """tag 参数按顶层逗号切(`\\t(0,500,\\clip(1,2,3,4))` 里 clip 的逗号不切)。"""
384
+ out, cur, depth = [], "", 0
385
+ for c in s:
386
+ if c == "," and depth == 0:
387
+ out.append(cur)
388
+ cur = ""
389
+ continue
390
+ cur += c
391
+ depth += (c == "(") - (c == ")")
392
+ out.append(cur)
393
+ return out
394
+
395
+
396
+ def shift_tokens(tokens: list[tuple], off_ms: int, dur: int) -> list[tuple]:
397
+ """逐字显出的时刻改成从这一段的起点(源行起点后 `off_ms`)算,在这之前已经出现的字直接显出;
398
+ 行内人写的 `\\t` / `\\fade` 也换算(`shift_times`)。"""
399
+ out = []
400
+ for t in tokens:
401
+ if t[0] == "ch" and t[2] is not None:
402
+ out.append(("ch", t[1], t[2] - off_ms if t[2] - off_ms > 0 else None))
403
+ elif t[0] == "tags":
404
+ out.append(("tags", shift_times(t[1], off_ms, dur)))
405
+ else:
406
+ out.append(t)
407
+ return out
408
+
409
+
410
+ def fade_tag(fin: int, fout: int, dur: int, a: int, whole: bool) -> str:
411
+ """源行 `[0, dur]` 毫秒上的淡入 `fin` / 淡出 `fout`,落在从 `a` 开始的这一段里的样子。整条就一段(`whole`)时是原样的 `\\fad`;
412
+ 在动的行按轨迹拆成几段时用七参数的 `\\fade`(透明度 255 = 全透明),段接段连起来和一整条淡入淡出相同,不在每段从头淡一遍。"""
413
+ if not fin and not fout:
414
+ return ""
415
+ if whole:
416
+ return "\\fad(%d,%d)" % (fin, fout)
417
+ t3 = dur - fout - a if fout else dur - a + 1 # 没有淡出:透明度到行尾都是 0
418
+ if fin and a < fin:
419
+ return "\\fade(%d,0,255,0,%d,%d,%d)" % (round(255 * (1 - a / fin)), fin - a, t3, max(t3, dur - a))
420
+ if not fout:
421
+ return ""
422
+ if a < dur - fout:
423
+ return "\\fade(0,0,255,0,0,%d,%d)" % (t3, dur - a)
424
+ x = round(255 * (a - (dur - fout)) / fout) # 这一段从淡出中间开始
425
+ return "\\fade(%d,%d,255,0,0,0,%d)" % (x, x, dur - a)
426
+
427
+
428
+ def dialogue(layer: int, s_us: int, e_us: int, style: str, actor: str, text: str) -> str:
429
+ return (f"Dialogue: {layer},{export.ass_time(s_us, True)},{export.ass_time(e_us, False)},"
430
+ f"{style}{FX_STYLE_SUFFIX},{actor},0,0,0,{EFFECT_FX},{text}")
431
+
432
+
433
+ class Typesetter:
434
+ """一次处理:`look` + 选项 + 扩展效果。`notes` 收提示(行号, 话),`counts` 收计数。"""
435
+
436
+ def __init__(self, look: Look, fx_mods: tuple = (), unknown: str = "error", widths: dict | None = None):
437
+ self.look = look
438
+ self.fx = {m.NAME: m for m in fx_mods}
439
+ self.unknown = unknown
440
+ self.widths = widths # 已经量好的字宽(连跑时阶段 3 交过来的),量过的不再起 ffmpeg
441
+ self.H = 1080 # 画面高(run 时从 PlayResY 读):扩出行时别扩出画面
442
+ self.notes: list[tuple[int, str]] = []
443
+ self.counts: dict[str, int] = {}
444
+
445
+ def count(self, k: str, n: int = 1) -> None:
446
+ self.counts[k] = self.counts.get(k, 0) + n
447
+
448
+ def run(self, doc: assfile.Doc) -> None:
449
+ ev0 = doc.section("Events")
450
+ base = (sum(len(s.lines) + (s.name is not None) for s in doc.sections[:doc.sections.index(ev0)]) + 1
451
+ if ev0 is not None else 0) # 按输入文件数行号:restore 之前算
452
+ origin: list[int] = []
453
+ restore(doc, origin)
454
+ info = doc.section("Script Info")
455
+ def play_res(key: str, default: int) -> int:
456
+ return int(next((ln.split(":", 1)[1] for ln in info.lines if ln.startswith(key + ":")), default)) if info else default
457
+ W = play_res("PlayResX", 1920)
458
+ self.H = play_res("PlayResY", 1080)
459
+ st_sec, ev_sec = doc.section("V4+ Styles"), doc.section("Events")
460
+ if ev_sec is None:
461
+ raise ValueError("没有 [Events] 节,不是字幕稿")
462
+ st_names = assfile.format_of(st_sec) or [] if st_sec else []
463
+ styles = {r.get("Name").strip(): r for ln in (st_sec.lines if st_sec else [])
464
+ if (r := assfile.parse_row(ln, st_names, ("Style",)))}
465
+ ev_names = assfile.format_of(ev_sec)
466
+ if not ev_names or ev_names[-1] != "Text":
467
+ raise ValueError("[Events] 的 Format 不对(最后一个字段要是 Text)")
468
+ srcs: dict[int, Src] = {}
469
+ for i, ln in enumerate(ev_sec.lines):
470
+ r = assfile.parse_row(ln, ev_names, assfile.EVENT_KINDS)
471
+ if r is None or r.kind != "Dialogue":
472
+ continue
473
+ no = base + origin[i] + 1
474
+ fo = assfile.parse_fo(r.get("Effect"))
475
+ if fo is None and r.get("Effect").strip():
476
+ self.notes.append((no, f"Effect 列是 {r.get('Effect').strip()[:40]!r},不是源行,原样保留"))
477
+ continue
478
+ srcs[i] = Src(no, r, fo, r.get("Text"))
479
+ self.check_params(srcs.values())
480
+ used = sorted({s.style for s in srcs.values()})
481
+ fallback = styles.get("Default") or next(iter(styles.values()), None)
482
+ base_style = {s: styles.get(s) or fallback for s in used}
483
+ for s in used:
484
+ if s not in styles:
485
+ self.notes.append((0, f"样式 {s!r} 不存在,派生样式照 {fallback.get('Name').strip() if fallback else '(无)'} 生成"))
486
+ fonts = {s: r.get("Fontname").strip() if r else export.FONT_CN for s, r in base_style.items()}
487
+ for s, f in fonts.items():
488
+ if f not in export.FONT_H_RATIO:
489
+ self.notes.append((0, f"样式 {s!r} 的字体 {f!r} 没有量过框高换算(export.FONT_H_RATIO),字号适配按 {export.FONT_CN} 的比例"))
490
+ laid, bad = {}, []
491
+ for i, src in srcs.items():
492
+ if src.fo is None:
493
+ continue
494
+ try:
495
+ laid[i] = self.lay_out(src, fonts[src.style], W)
496
+ except (ValueError, KeyError, IndexError) as e: # 人手改坏的参数值(tw=1.5s、box 少一个数…)
497
+ bad.append(f"第 {src.no} 行:{type(e).__name__}: {e}")
498
+ if bad:
499
+ raise ValueError(f"fo 参数的值读不了 {len(bad)} 处:" + ";".join(bad[:12]) + ("…" if len(bad) > 12 else ""))
500
+ widths = export.ink_widths(((f, "\n".join(keys)) for f, keys in (x["measure"] for x in laid.values())),
501
+ known=self.widths)
502
+ for x in laid.values():
503
+ self.fit(x, widths, W)
504
+ self.same_size(laid)
505
+ out = []
506
+ for i, ln in enumerate(ev_sec.lines):
507
+ if i not in srcs:
508
+ out.append(ln)
509
+ continue
510
+ src = srcs[i]
511
+ gen = self.generate(laid[i], widths, W) if i in laid else [self.plain(src)]
512
+ c = replace(src.row, fields=list(src.row.fields))
513
+ c.kind = "Comment"
514
+ c.set("Effect", EFFECT_SRC + src.row.get("Effect"))
515
+ out.append(str(c))
516
+ out += gen
517
+ self.count("源行")
518
+ self.count("生成行", len(gen))
519
+ ev_sec.lines = out
520
+ if st_sec is not None:
521
+ derived = [derive_style(r, s) for s, r in base_style.items() if r is not None]
522
+ last = max((k for k, ln in enumerate(st_sec.lines) if ln.startswith("Style:")), default=len(st_sec.lines) - 1)
523
+ st_sec.lines[last + 1:last + 1] = derived
524
+ if info is not None:
525
+ lk = self.look
526
+ val = f"plate={lk.plate} alpha=0x{lk.alpha:02X} typewriter={lk.typewriter} fade={lk.fade}" \
527
+ f"{' label' if lk.label else ''}{' colors' if lk.colors else ''}"
528
+ last = max((k for k, ln in enumerate(info.lines) if ln.strip()), default=-1)
529
+ info.lines.insert(last + 1, f"{INFO_KEY}: {val}")
530
+
531
+ def check_params(self, srcs) -> None:
532
+ known = FO_PARAMS | set(self.fx)
533
+ bad = [(s.no, k) for s in srcs if s.fo for k in s.fo if k not in known]
534
+ if bad and self.unknown != "ignore":
535
+ where = "、".join(f"第 {no} 行 {k}" for no, k in bad[:12]) + ("…" if len(bad) > 12 else "")
536
+ raise ValueError(f"不认识的 fo 参数 {len(bad)} 处:{where}。扩展效果用 --fx 加载;要跳过加 --opt unknown=ignore")
537
+ for no, k in bad:
538
+ self.notes.append((no, f"不认识的 fo 参数 {k!r},跳过"))
539
+
540
+ # -- 第 1 步:排版 --
541
+
542
+ def lay_out(self, src: Src, font: str, W: int) -> dict:
543
+ """解析一条带 `fo` 的源行,定下哪些参数有效(谁说了算)、要量哪些字宽。字号在 `fit` 里定。"""
544
+ p = src.fo
545
+ text = src.text
546
+ rows_tok, plain, lead, full_ms = text_rows(text)
547
+ was = parse_was(p.get("was"))
548
+ pos, move, an, fad = (assfile.first_tag(text, k) for k in ("pos", "move", "an", "fad"))
549
+ fs = assfile.first_tag(text, "fs", lead_only=True) # 整行的字号;行内的 A{\fs80}B 只管后面几个字,不算
550
+
551
+ def same(tag_args, recorded): # tag 的参数用逗号,fo 里记的用空格
552
+ return tag_args is not None and assfile.numbers(tag_args) == assfile.numbers(recorded)
553
+ moved = ("pos" in was and not same(pos, was["pos"])) or ("move" in was and not same(move, was["move"])) \
554
+ or ("pos" in was and move is not None) or ("move" in was and pos is not None)
555
+ realigned = "an" in was and not same(an, was["an"])
556
+ if realigned:
557
+ self.notes.append((src.no, f"对齐改过(\\an{an}):以人为准,这一行按人给的锚点和位置画"))
558
+ moved = moved or realigned
559
+ resized = "fs" in was and not same(fs, was["fs"])
560
+ box = rows_of(p["box"]) if p.get("box") else []
561
+ kind = KIND_OF_STYLE.get(src.style, "line")
562
+ if kind == "block" and not p.get("wrap"):
563
+ kind = "line"
564
+ if moved and not realigned:
565
+ self.notes.append((src.no, "位置改过:以人为准,这一行不按原框逐行摆、不按轨迹走"))
566
+ if resized:
567
+ self.notes.append((src.no, f"字号改过(\\fs{fs}):以人为准,不再适配"))
568
+ mode = "free"
569
+ cap = max(len(box), int(p["cap"]) if p.get("cap") not in (None, True) else 0) # 原文框本来装几行(阶段 3 记的)
570
+ base = tuple(box[0][:2]) if box else (0, 0) # 轨迹 path 记的是原文框的位置:扩出行只改排版,不改这个基准
571
+ if kind != "block" and p.get("rows") and box and not moved:
572
+ n = len(plain)
573
+ if n == len(box):
574
+ mode = "rows"
575
+ elif n > cap == len(box): # 多出来的行按原来一行的高度扩出去,逐行摆
576
+ box, mode = layout.expand_rows(box, n - cap, self.H), "rows"
577
+ elif n > cap and len(box) == 1: # 一个框本来装着几行:框按一行的高度长出去
578
+ box, mode = [layout.expand_box(box[0], cap, n - cap, self.H)], "single"
579
+ elif len(box) == 1:
580
+ mode = "single"
581
+ else:
582
+ self.notes.append((src.no, f"正文 {n} 行、原文框 {len(box)} 个(装 {cap} 行)对不上:不按原框逐行摆"))
583
+ if n > cap and mode != "free":
584
+ self.notes.append((src.no, f"正文 {n} 行、原文框装 {cap} 行:按原来一行的高度扩出 {n - cap} 行(压到别的字不管)"))
585
+ m = re.fullmatch(r"r(\d+)", src.actor.strip())
586
+ s0, e0 = cs_of(src.row.get("Start")) * 10_000, cs_of(src.row.get("End")) * 10_000
587
+ fit = p.get("fit")
588
+ tw = int(p["tw"]) if p.get("tw") not in (None, True) and full_ms is None else None
589
+ x = {"src": src, "font": font, "rows_tok": rows_tok, "plain": plain, "lead": lead,
590
+ "has_k": full_ms is not None or bool(tw), "tw": tw, "full_ms": full_ms if full_ms is not None else tw,
591
+ "an": int(an) if an else 5, "pos": pos, "move": move, "fs": float(fs) if fs else None, "fad": fad,
592
+ "box": box, "base": base, "cap": cap, "kind": kind, "mode": mode, "moved": moved, "resized": resized,
593
+ "fit": (fit if fit in ("src", "tr") else "src") if fit and not resized else None,
594
+ "translated": fit == "tr", "path": path_of(p["path"]) if p.get("path") and not moved else None,
595
+ "region": int(m[1]) if m else -1, "start_us": s0, "end_us": e0}
596
+ if kind == "block":
597
+ paras = [re.sub(r"\\N", "", q) for q in re.split(r"\\N\\N", "\\N".join(plain))] if plain else []
598
+ x["paras"], x["wrap"] = paras, int(p["wrap"])
599
+ x["measure"] = (font, layout.width_keys(paras, True))
600
+ else:
601
+ lines = plain if mode != "single" else ["\n".join(plain)]
602
+ x["lines"] = lines
603
+ # 底板要按画出来的宽:行内 \fs 切开的几段各量各的(tokens_width);字号适配按整行(fit_line)
604
+ tok_rows = carry_rows(rows_tok) if mode == "rows" else [rows_joined(rows_tok)]
605
+ runs = [s for r in tok_rows for ln in runs_of(r) for s, _ in ln]
606
+ x["measure"] = (font, runs + layout.width_keys(lines, False))
607
+ return x
608
+
609
+ def fit(self, x: dict, widths: dict, W: int) -> None:
610
+ if x["kind"] == "block":
611
+ box = x["box"][0] if x["box"] else [0, 0, W, 0]
612
+ keep = int(x["fs"]) if x["resized"] and x["fs"] is not None else None
613
+ x["size"], x["wrapped"] = layout.block_layout(box, x["paras"], x["wrap"], x["font"], widths, size=keep)
614
+ return
615
+ if x["fit"] and x["box"] and x["mode"] != "free":
616
+ x["size"] = layout.fit_line(x["box"], x["lines"], x["font"], widths, x["fit"] == "tr", W)
617
+ elif x["fit"] and x["box"]:
618
+ # 不按原框逐行摆(行数对不上 / 人挪过):整块在几个框合起来的范围里适配,但正文行数比原文框装的少时
619
+ # 行高按原来一行的高度算——两行并成一行不该把字放大到两行那么高(owner 2026-09-27 改字幕稿时撞到:\fs51 → \fs102)
620
+ bx, n = x["box"], len(x["lines"])
621
+ y0, y1 = bx[0][1], bx[-1][3]
622
+ union = [min(r[0] for r in bx), y0, max(r[2] for r in bx), y0 + (y1 - y0) * n / max(n, x["cap"])]
623
+ x["size"] = layout.fit_line([union], ["\n".join(x["lines"])], x["font"], widths, x["fit"] == "tr", W)
624
+ else:
625
+ x["size"] = int(x["fs"]) if x["fs"] is not None else 48
626
+
627
+ def same_size(self, laid: dict) -> None:
628
+ """同栏同时在屏的选项按钮字号取中位数(`layout.same_size`);只算还在适配字号的那些。"""
629
+ idx = [i for i, x in laid.items() if x["kind"] in ("choice", "offmain") and x["fit"] and x["box"]]
630
+ got = layout.same_size([(laid[i]["start_us"], laid[i]["end_us"], laid[i]["box"][0]) for i in idx],
631
+ [laid[i]["size"] for i in idx])
632
+ for i, s in zip(idx, got):
633
+ laid[i]["size"] = s
634
+
635
+ # -- 第 2–4 步:元素、运动、时间效果 --
636
+
637
+ def generate(self, x: dict, widths: dict, W: int) -> list[str]:
638
+ """第 2 步(元素)按种类摆好,其余(编号、扩展挂点、第 3 步运动、第 4 步时间效果)面板和普通条目走同一条路。"""
639
+ src, look, p = x["src"], self.look, x["src"].fo
640
+ back = "\\1c&H%s&\\1a&H%02X&" % (OVERLAY_COLOR, look.alpha)
641
+ fad = assfile.numbers(x["fad"]) if x["fad"] and look.fade == "on" else []
642
+ x["fad_ms"] = (int(fad[0]), int(fad[1])) if len(fad) == 2 else (0, 0)
643
+ if any(x["fad_ms"]):
644
+ self.count("fade")
645
+ actor, style = src.actor, src.style
646
+ s_us, e_us, size = x["start_us"], x["end_us"], x["size"]
647
+ user_where = "\\move(%s)" % x["move"] if x["move"] else "\\pos(%s)" % x["pos"] if x["pos"] else ""
648
+ segs, base = [(s_us, e_us, None, None)], (0, 0)
649
+ els: list[Element] = []
650
+ if x["kind"] == "block":
651
+ box = [x["box"][0]] if x["box"] else []
652
+ plates = layout.plate_rects(box, [], None, "box") if p.get("plate") and box else []
653
+ els += [plate_el(pl, back) for pl in plates]
654
+ body = "\\N".join(x["wrapped"])
655
+ if box and not x["moved"]:
656
+ els.append(Element(1, box[0][0], box[0][1], "\\an7", "\\fs%d" % size, body, "text", tail2=x["lead"], fade=True))
657
+ else: # 人挪过(\pos / \move)或改过锚点:照人给的画
658
+ els.append(Element(1, 0, 0, "\\an%d" % x["an"] if x["moved"] else "\\an7", "\\fs%d" % size, body, "text",
659
+ where=user_where, tail2=x["lead"], fade=True))
660
+ align, lines = "left", x["paras"]
661
+ x["mode_tw"] = "off"
662
+ else:
663
+ align = layout.ALIGN_OF.get(x["an"], {1: "left", 7: "left", 3: "right", 9: "right"}.get(x["an"], "center"))
664
+ color = (UI_COLOR if look.colors and style == "UI" else OFFMAIN_COLOR if look.colors and p.get("off") else None)
665
+ ctag = "\\1c&H%s&" % color if color else ""
666
+ x["mode_tw"] = look.typewriter if x["has_k"] else "off"
667
+ if x["has_k"]:
668
+ self.count("typewriter")
669
+ box, lines = x["box"], x["lines"]
670
+ tok = x["rows_tok"] if x["mode"] == "rows" else [rows_joined(x["rows_tok"])]
671
+ if x["tw"]:
672
+ tok = with_tw(tok, x["tw"], x["mode"] != "rows")
673
+ if x["mode"] == "rows":
674
+ tok = carry_rows(tok)
675
+ tw = [tokens_width(t, size, x["font"], widths) for t in tok]
676
+ plate_mode = p.get("plate") if isinstance(p.get("plate"), str) else look.plate
677
+ if x["mode"] in ("rows", "single"):
678
+ axes = layout.axes_in_screen(box, tw, align, x["translated"], W)
679
+ plates = layout.plate_rects(box, tw, axes, plate_mode, align) if p.get("plate") else []
680
+ els += [plate_el(pl, back) for pl in plates]
681
+ for r, a, t in zip(box, axes, tok):
682
+ els.append(Element(1, a, (r[1] + r[3]) / 2, "\\an%d" % layout.ANCHOR[align], "\\fs%d%s" % (size, ctag),
683
+ body_of(t, x["mode_tw"], x["full_ms"]), "text", tail2=x["lead"], fade=True, tokens=t))
684
+ if x["path"]:
685
+ segs = layout.motion_segments(x["path"], s_us, e_us)
686
+ base = x["base"]
687
+ else:
688
+ plates = layout.plate_rects(box, [], None, "box") if p.get("plate") and box else []
689
+ els += [plate_el(pl, back) for pl in plates]
690
+ els.append(Element(1, 0, 0, "\\an%d" % x["an"], "\\fs%d%s" % (size, ctag),
691
+ body_of(tok[0], x["mode_tw"], x["full_ms"]), "text",
692
+ where=user_where, tail2=x["lead"], fade=True, tokens=tok[0]))
693
+ line = Line(style, actor, p, s_us, e_us, x["region"], x["kind"], box, lines, size, align, plates, look)
694
+ if look.label and x["region"] >= 0 and plates and x["kind"] != "block":
695
+ px, py = max(q[2] for q in plates) - LABEL_INSET, min(q[1] for q in plates) + LABEL_INSET
696
+ els.append(Element(2, px, py, "\\an9", "\\fs%d\\bord1\\1c&H%s&" % (max(LABEL_MIN, int(LABEL_SCALE * size)), LABEL_COLOR),
697
+ f"{x['region']}" + ("ui" if style == "UI" else ""), "label"))
698
+ for name, mod in self.fx.items():
699
+ if name in p and hasattr(mod, "elements"):
700
+ els += list(mod.elements(line, p[name]))
701
+ x["line"] = line
702
+ self.count(x["kind"])
703
+ return self.emit(x, els, segs, base)
704
+
705
+ def emit(self, x: dict, els: list[Element], segs, base) -> list[str]:
706
+ """第 3、4 步:所有元素一起按轨迹逐段展开,每段各一套(底板、正文、编号的先后照元素的顺序);
707
+ 时间效果按**源行**的时间算——拆成几段时淡入淡出换成各段的 `\\fade`、逐字显出的时刻和人写的 `\\t` / `\\fade`
708
+ 都减去这一段的起点,不在每段从头放一遍。扩展效果的 `tags` 挂点在每一段的元素定稿之后调,改的东西各段都在。"""
709
+ src, p = x["src"], x["src"].fo
710
+ s_us, e_us = x["start_us"], x["end_us"]
711
+ dur, (fin, fout) = (e_us - s_us) // 1000, x["fad_ms"]
712
+ whole = len(segs) == 1
713
+ out = []
714
+ for seg in segs:
715
+ a = (seg[0] - s_us) // 1000
716
+ ftag = fade_tag(fin, fout, dur, a, whole)
717
+ full = x["full_ms"] - a if x["full_ms"] is not None and x["full_ms"] - a > 0 else None
718
+ for el in els:
719
+ e = el if whole else replace(
720
+ el, tail2=shift_times(el.tail2, a, dur),
721
+ body=el.body if el.tokens is None else body_of(shift_tokens(el.tokens, a, dur), x["mode_tw"], full))
722
+ e = replace(e)
723
+ for name, mod in self.fx.items():
724
+ if name in p and hasattr(mod, "tags"):
725
+ mod.tags(x["line"], p[name], e)
726
+ where = e.where if e.where is not None else layout.pos_tag(e.x, e.y, seg, base)
727
+ text = "{%s%s%s%s%s}%s" % (e.head, where, e.tail, ftag if e.fade else "", e.tail2, e.body)
728
+ out.append(dialogue(e.layer, seg[0], seg[1], src.style, src.actor, text))
729
+ return out
730
+
731
+ def plain(self, src: Src) -> str:
732
+ """不带 `fo` 的源行:照主流 tag 原样生成一条,只换派生样式、标 `fo-fx`。"""
733
+ r = replace(src.row, fields=list(src.row.fields))
734
+ r.set("Style", src.style + FX_STYLE_SUFFIX)
735
+ r.set("Effect", EFFECT_FX)
736
+ self.count("plain")
737
+ return str(r)
738
+
739
+
740
+ def look_of(preset: str, options: dict | None) -> tuple[Look, str]:
741
+ o = dict(options or {})
742
+ if preset not in LOOKS:
743
+ raise ValueError(f"阶段 4 的预设只有 {list(LOOKS)}:{preset!r}")
744
+ lk = LOOKS[preset]
745
+ alpha = o.get("plate_alpha", lk.alpha)
746
+ lk = replace(lk, plate=str(o.get("plate", lk.plate)), typewriter=str(o.get("typewriter", lk.typewriter)),
747
+ fade=str(o.get("fade", lk.fade)), alpha=int(alpha, 0) if isinstance(alpha, str) else int(alpha))
748
+ if lk.plate not in layout.PLATE_MODES:
749
+ raise ValueError(f"plate 只能取 {list(layout.PLATE_MODES)}:{lk.plate!r}")
750
+ if lk.typewriter not in TYPEWRITER_MODES:
751
+ raise ValueError(f"typewriter 只能取 {list(TYPEWRITER_MODES)}:{lk.typewriter!r}")
752
+ if lk.fade not in ("off", "on"):
753
+ raise ValueError(f"fade 只能是 off / on:{lk.fade!r}")
754
+ unknown = str(o.get("unknown", "error"))
755
+ if unknown not in ("error", "ignore"):
756
+ raise ValueError(f"unknown 只能是 error / ignore:{unknown!r}")
757
+ return lk, unknown
758
+
759
+
760
+ def load_fx(specs) -> tuple:
761
+ mods = []
762
+ for spec in specs or ():
763
+ m = extensions.load(spec, "fx")
764
+ if not isinstance(getattr(m, "NAME", None), str) or not (hasattr(m, "elements") or hasattr(m, "tags")):
765
+ raise TypeError(f"扩展效果 `{spec}` 要有 NAME(它在 fo 里的参数名)和 elements / tags 至少一个挂点")
766
+ mods.append(m)
767
+ return tuple(mods)
768
+
769
+
770
+ def typeset_file(src: Path, out: Path | None = None, preset: str = "default", options: dict | None = None,
771
+ fx=(), quiet: bool = False, widths: dict | None = None) -> tuple[Path, Typesetter]:
772
+ """读一份字幕稿(或阶段 4 自己的输出),写最终 ASS。默认输出:`x.script.ass` → `x.ass`,其余 `x.ass` → `x.fx.ass`。
773
+ `widths`:已经量好的字宽(叠加预设连跑时阶段 3 交过来的)。"""
774
+ src = Path(src)
775
+ look, unknown = look_of(preset, options)
776
+ doc = assfile.loads(src.read_text(encoding="utf-8"))
777
+ ts = Typesetter(look, load_fx(fx), unknown, widths)
778
+ ts.run(doc)
779
+ out = Path(out) if out else default_out(src)
780
+ out.write_text(doc.dumps(), encoding="utf-8")
781
+ if not quiet:
782
+ report(out, ts)
783
+ return out, ts
784
+
785
+
786
+ def default_out(src: Path) -> Path:
787
+ name = src.name
788
+ if name.endswith(".script.ass"):
789
+ return src.with_name(name[:-len(".script.ass")] + ".ass")
790
+ return src.with_name(src.stem + ".fx.ass")
791
+
792
+
793
+ def restore_file(src: Path, out: Path | None = None, force: bool = False) -> tuple[Path, int]:
794
+ """阶段 4 的输出 → 字幕稿(逐字节相同)。默认输出 `x.ass` / `x.fx.ass` → `x.script.ass`。
795
+ 输出的文件已经在时不覆盖(除非 `force`):那份字幕稿可能是人改过的,拿一份旧的最终 ASS 还原会把改动冲掉。"""
796
+ src = Path(src)
797
+ if out is None:
798
+ if src.name.endswith(".script.ass"):
799
+ raise ValueError(f"{src.name} 已经是字幕稿;要还原的是阶段 4 的输出(x.ass),或用 -o 指定输出")
800
+ stem = src.name[:-len(".fx.ass")] if src.name.endswith(".fx.ass") else src.stem
801
+ out = src.with_name(stem + ".script.ass")
802
+ out = Path(out)
803
+ if out.exists() and not force:
804
+ raise FileExistsError(f"{out} 已经在(可能是改过的字幕稿),不覆盖;确认要覆盖加 --force,或用 -o 另写一份")
805
+ doc = assfile.loads(src.read_text(encoding="utf-8"))
806
+ n = restore(doc)
807
+ out.write_text(doc.dumps(), encoding="utf-8")
808
+ return out, n
809
+
810
+
811
+ def report(out: Path, ts: Typesetter) -> None:
812
+ c = ts.counts
813
+ kinds = "、".join(f"{k} {c[k]}" for k in ("line", "name", "choice", "offmain", "block", "plain") if c.get(k))
814
+ print(f"-> {out}(源行 {c.get('源行', 0)} 条 → 生成 {c.get('生成行', 0)} 条:{kinds};"
815
+ f"打字机 {c.get('typewriter', 0)} 条、淡入淡出 {c.get('fade', 0)} 条)")
816
+ if ts.notes:
817
+ print(f" 提示 {len(ts.notes)} 条:")
818
+ for no, msg in ts.notes[:12]:
819
+ print(f" {'第 %d 行:' % no if no else ''}{msg}")
820
+ if len(ts.notes) > 12:
821
+ print(f" …另 {len(ts.notes) - 12} 条")