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/typeset/core.py
ADDED
|
@@ -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} 条")
|