@dsh-gis/dsh-video-studio-skill 0.7.2 → 0.8.1
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.
- package/CHANGELOG.md +951 -0
- package/README.md +51 -8
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/index.js +4 -1
- package/package.json +5 -3
- package/schemas/film.schema.json +162 -2
- package/skills/video-studio/SKILL.md +211 -11
- package/skills/video-studio/assets/golden_frames.json +65 -0
- package/skills/video-studio/assets/web/engine.js +63 -2
- package/skills/video-studio/assets/web/film.js +224 -101
- package/skills/video-studio/assets/web/index.html +4 -2
- package/skills/video-studio/assets/web/qa_text.js +36 -8
- package/skills/video-studio/references/audio-variation.md +4 -3
- package/skills/video-studio/references/audio.md +2 -2
- package/skills/video-studio/references/change-impact.md +1 -1
- package/skills/video-studio/references/quality-gates.md +25 -3
- package/skills/video-studio/references/style-packs.md +7 -3
- package/skills/video-studio/references/v08-quality.md +256 -0
- package/skills/video-studio/references/web-engine.md +19 -2
- package/skills/video-studio/references/word-timeline.md +147 -0
- package/skills/video-studio/requirements.txt +14 -0
- package/skills/video-studio/scripts/filmcheck.py +121 -0
- package/skills/video-studio/scripts/filmkit.py +521 -38
- package/skills/video-studio/scripts/gates.py +88 -4
- package/skills/video-studio/scripts/preflight.py +774 -0
- package/skills/video-studio/scripts/qa_frames.py +49 -8
- package/skills/video-studio/scripts/qaasr.py +1563 -0
- package/skills/video-studio/scripts/qavmaf.py +1442 -0
- package/skills/video-studio/scripts/render.py +719 -32
- package/skills/video-studio/scripts/scorekit.py +16 -30
- package/skills/video-studio/scripts/selfcheck.py +274 -0
- package/skills/video-studio/scripts/sfxkit.py +130 -14
- package/skills/video-studio/scripts/stagewatch.py +19 -6
- package/skills/video-studio/scripts/stylekit.py +43 -12
- package/skills/video-studio/scripts/voicekit.py +15 -7
- package/skills/video-studio/scripts/webengine.py +1119 -72
- package/tools/ci_check.py +171 -31
- package/tools/estimate.py +130 -19
- package/tools/golden_frames.py +664 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""preflight.py — 渲染前资源闸门(v0.8.0)。
|
|
3
|
+
|
|
4
|
+
为什么要有它:缺字形(✓ / emoji / 生僻字)和坏 TTS 音频都是「渲染完才报」的问题——
|
|
5
|
+
filmkit.glyph_missing_chars() 检测本身是准的(正常中文 [],✓/↵/emoji 检出),
|
|
6
|
+
但它现在只在 layout / qa 阶段被调用:等 300 帧渲完,才发现某句 big 里有个方块。
|
|
7
|
+
preflight 不新写检测器,只把现成检测前移,并把结论整理成「字符 + 字段 + 句 id」的结构化错误:
|
|
8
|
+
**渲染前一秒就能 fail,而不是渲完才 fail**。
|
|
9
|
+
|
|
10
|
+
检查项(三档语义 pass / fail / skipped,**"没查"绝不能写成"通过"**):
|
|
11
|
+
1 script 剧本可解析、最小结构合法(title/lines/id 唯一/text 非空)
|
|
12
|
+
2 glyphs 所有上屏文本逐字过 filmkit.glyph_missing_chars;
|
|
13
|
+
字段:text / sub / big / kicker / lead / note / title / end_text /
|
|
14
|
+
chapter / items[].text(含 timeline.scenes 里的同名字段)
|
|
15
|
+
按模板实际绘制权重检测:big/kicker=bold、lead=light、sub=reg+light、
|
|
16
|
+
items=reg、title/chapter=reg、text=bold+reg
|
|
17
|
+
3 tts work/tts/*.mp3 若存在,必须能被 ffprobe 解析出音频流;
|
|
18
|
+
文件名要能对上行 id,时长要与台词长度相符(过短=截断/坏缓存)
|
|
19
|
+
4 fonts 字体候选至少一个真的能加载(filmkit.font() 对 bold/reg/light 各取一次)
|
|
20
|
+
5 web_page engine="web" 时入口页存在(script.web.entry 或 <剧本目录>/web/index.html)
|
|
21
|
+
|
|
22
|
+
接口(纯函数,不打印、不落盘、不改入参):
|
|
23
|
+
check(script, *, work=None, engine="pil", timeline=None) -> dict
|
|
24
|
+
script : film.json 的 dict,或 film.json 路径(str/Path)
|
|
25
|
+
work : work 目录(有 work/tts/*.mp3 才查音频;None = 不查,记 skipped)
|
|
26
|
+
engine : "pil"(默认)| "web"
|
|
27
|
+
timeline : work/timeline.json 的 dict 或路径(可选,用于扫场景上的字幕文本、
|
|
28
|
+
以及提示"timeline 已过时")
|
|
29
|
+
返回 {"ok": bool, "errors": [{code,msg,where,...}], "warnings": [...],
|
|
30
|
+
"checks": {检查项: {"status": "pass|fail|skipped", "detail": str}}, "engine": str}
|
|
31
|
+
|
|
32
|
+
CLI:
|
|
33
|
+
python preflight.py --script film.json [--work work] [--engine pil]
|
|
34
|
+
[--timeline work/timeline.json] [--json] [--strict]
|
|
35
|
+
退出码:0 = 没有 error;1 = 有 error(--strict 时 skipped 也算 error)。
|
|
36
|
+
--json 时 stdout 只有 JSON(键名全英文),给人看的提示走 stderr。
|
|
37
|
+
|
|
38
|
+
依赖:仅标准库;glyph/font 检查需要同目录的 filmkit.py + Pillow(缺了就是 error,不静默跳过)。
|
|
39
|
+
ffmpeg/ffprobe 探测口径与 filmkit 一致:FILMKIT_FFMPEG 优先,找不到可执行文件一律 fail-closed。
|
|
40
|
+
"""
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
__version__ = "0.8.0"
|
|
44
|
+
|
|
45
|
+
import argparse
|
|
46
|
+
import json
|
|
47
|
+
import os
|
|
48
|
+
import shutil
|
|
49
|
+
import subprocess
|
|
50
|
+
import sys
|
|
51
|
+
|
|
52
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
53
|
+
if HERE not in sys.path:
|
|
54
|
+
sys.path.insert(0, HERE)
|
|
55
|
+
|
|
56
|
+
try: # 同目录的 filmkit 提供字形/字体/ffprobe 口径
|
|
57
|
+
import filmkit as _K
|
|
58
|
+
except Exception: # noqa: BLE001 —— 独立拷走本文件时给出可读错误
|
|
59
|
+
_K = None
|
|
60
|
+
|
|
61
|
+
# 检查项 id(checks 里的键,顺序即打印顺序)
|
|
62
|
+
CHECK_IDS = ("script", "glyphs", "tts", "fonts", "web_page")
|
|
63
|
+
|
|
64
|
+
# 上屏文本字段 -> 该字段实际被绘制的字重(见 assets/scenes.py 的绘制函数)。
|
|
65
|
+
# 为什么要分权重:msyhbd / msyh / msyhl 是三个文件,字形覆盖不完全相同;
|
|
66
|
+
# 用错权重的检测会把"只有 reg 缺、但该字段画 reg"误报成"没事",或者反过来。
|
|
67
|
+
FIELD_WEIGHTS = {
|
|
68
|
+
"text": ("bold", "reg"), # body 缺 big 时取 text[:18] 画 bold;字幕回退画 reg
|
|
69
|
+
"sub": ("reg", "light"), # body 画 reg;hook / outro 画 light
|
|
70
|
+
"big": ("bold",),
|
|
71
|
+
"kicker": ("bold",),
|
|
72
|
+
"lead": ("light",),
|
|
73
|
+
"note": ("bold",), # 模板未用;按 QA 的 check_glyphs 口径(bold)兜底
|
|
74
|
+
"title": ("reg", "bold"), # 右下角 brand 画 reg;hook 缺 big 时画 bold
|
|
75
|
+
"end_text": ("bold",),
|
|
76
|
+
"chapter": ("reg",),
|
|
77
|
+
}
|
|
78
|
+
ITEM_FIELD = "items[].text"
|
|
79
|
+
ITEM_WEIGHTS = ("reg",)
|
|
80
|
+
# QA 阶段沿用的口径(filmkit.check_glyphs 用 32/bold 扫 text/sub/big/lead/kicker/note),
|
|
81
|
+
# preflight 与它的差异只在"按字段选权重",所以两者冲突时以本文件更细的口径为准。
|
|
82
|
+
|
|
83
|
+
# 探测单个 mp3 的超时(秒);坏文件/网络盘上 ffprobe 也可能卡住
|
|
84
|
+
PROBE_TIMEOUT = 15
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------- 小工具
|
|
88
|
+
def _setup_stdio():
|
|
89
|
+
"""输出被重定向时切 UTF-8(Windows 控制台默认 GBK,中文/符号会乱码)。
|
|
90
|
+
|
|
91
|
+
与 render.py / selfcheck.py 同口径:只在非交互(管道/日志)时切编码。
|
|
92
|
+
"""
|
|
93
|
+
for stream in (sys.stdout, sys.stderr):
|
|
94
|
+
try:
|
|
95
|
+
if stream is not None and not stream.isatty():
|
|
96
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
97
|
+
except Exception: # noqa: BLE001
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _err(errors, code, msg, where="", **extra):
|
|
102
|
+
"""记一条 error(code/msg/where 是契约,extra 里的 line_id/field/chars 便于机读)。"""
|
|
103
|
+
item = {"code": str(code), "msg": str(msg), "where": str(where or "")}
|
|
104
|
+
for k, v in extra.items():
|
|
105
|
+
if v is not None:
|
|
106
|
+
item[k] = v
|
|
107
|
+
errors.append(item)
|
|
108
|
+
return item
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _warn(warnings, code, msg, where="", **extra):
|
|
112
|
+
item = {"code": str(code), "msg": str(msg), "where": str(where or "")}
|
|
113
|
+
for k, v in extra.items():
|
|
114
|
+
if v is not None:
|
|
115
|
+
item[k] = v
|
|
116
|
+
warnings.append(item)
|
|
117
|
+
return item
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _check(checks, cid, status, detail=""):
|
|
121
|
+
"""记一项检查结论。status 只允许 pass/fail/skipped(三档语义,别名直接拒)。"""
|
|
122
|
+
if status not in ("pass", "fail", "skipped"):
|
|
123
|
+
raise ValueError("检查状态只能是 pass/fail/skipped,收到 %r" % (status,))
|
|
124
|
+
checks[cid] = {"status": status, "detail": str(detail or "")}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------- 可执行文件探测
|
|
128
|
+
_FFMPEG_CACHE = []
|
|
129
|
+
_FFPROBE_CACHE = []
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _probe_exe(path):
|
|
133
|
+
"""候选是否真的可用:必须跑通 "<exe> -version" 且 returncode == 0(fail-closed)。
|
|
134
|
+
|
|
135
|
+
只看 os.path.exists 是不够的:本机曾静默选中过 2019 年的旧 ffmpeg,一路跑到滤镜
|
|
136
|
+
阶段才挂。探测阶段异常/超时一律视为不可用。
|
|
137
|
+
"""
|
|
138
|
+
if not path:
|
|
139
|
+
return ""
|
|
140
|
+
exe = str(path)
|
|
141
|
+
try:
|
|
142
|
+
if not os.path.isfile(exe):
|
|
143
|
+
exe = shutil.which(exe) or exe
|
|
144
|
+
if not os.path.isfile(exe):
|
|
145
|
+
return ""
|
|
146
|
+
p = subprocess.run([exe, "-version"], capture_output=True, timeout=10)
|
|
147
|
+
if p.returncode != 0:
|
|
148
|
+
return ""
|
|
149
|
+
return os.path.abspath(exe) if os.path.isabs(exe) else exe
|
|
150
|
+
except Exception: # noqa: BLE001 —— 探测失败 = 候选不可用
|
|
151
|
+
return ""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _ffmpeg():
|
|
155
|
+
"""解析 ffmpeg:FILMKIT_FFMPEG 优先,其次 filmkit.FFMPEG,最后 PATH。找不到返回 ""。"""
|
|
156
|
+
if _FFMPEG_CACHE:
|
|
157
|
+
return _FFMPEG_CACHE[0]
|
|
158
|
+
cands = []
|
|
159
|
+
env = (os.environ.get("FILMKIT_FFMPEG") or "").strip()
|
|
160
|
+
if len(env) >= 2 and env[0] == env[-1] and env[0] in "\"'":
|
|
161
|
+
env = env[1:-1].strip() # 容忍用户把路径连引号一起粘进环境变量
|
|
162
|
+
if env:
|
|
163
|
+
cands.append(env)
|
|
164
|
+
if _K is not None:
|
|
165
|
+
try:
|
|
166
|
+
cands.append(str(getattr(_K, "FFMPEG", "") or ""))
|
|
167
|
+
except Exception: # noqa: BLE001
|
|
168
|
+
pass
|
|
169
|
+
cands.append(shutil.which("ffmpeg") or "")
|
|
170
|
+
for c in cands:
|
|
171
|
+
hit = _probe_exe(c)
|
|
172
|
+
if hit:
|
|
173
|
+
_FFMPEG_CACHE.append(hit)
|
|
174
|
+
return hit
|
|
175
|
+
_FFMPEG_CACHE.append("")
|
|
176
|
+
return ""
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _ffprobe():
|
|
180
|
+
"""解析 ffprobe:FFMPEG 同目录的 ffprobe(.exe) 优先,其次 filmkit.ffprobe_path(),最后 PATH。
|
|
181
|
+
|
|
182
|
+
找不到返回 ""(调用方据此 fail-closed,而不是把"没查"写成"通过")。
|
|
183
|
+
"""
|
|
184
|
+
if _FFPROBE_CACHE:
|
|
185
|
+
return _FFPROBE_CACHE[0]
|
|
186
|
+
cands = []
|
|
187
|
+
ff = _ffmpeg()
|
|
188
|
+
if ff:
|
|
189
|
+
base = os.path.basename(ff)
|
|
190
|
+
names = ("ffprobe.exe", "ffprobe") if base.lower().endswith(".exe") \
|
|
191
|
+
else ("ffprobe", "ffprobe.exe")
|
|
192
|
+
d = os.path.dirname(os.path.abspath(ff))
|
|
193
|
+
for n in names:
|
|
194
|
+
cands.append(os.path.join(d, n))
|
|
195
|
+
if _K is not None:
|
|
196
|
+
try:
|
|
197
|
+
cands.append(str(_K.ffprobe_path() or ""))
|
|
198
|
+
except Exception: # noqa: BLE001
|
|
199
|
+
pass
|
|
200
|
+
cands.append(shutil.which("ffprobe") or "")
|
|
201
|
+
for c in cands:
|
|
202
|
+
hit = _probe_exe(c)
|
|
203
|
+
if hit:
|
|
204
|
+
_FFPROBE_CACHE.append(hit)
|
|
205
|
+
return hit
|
|
206
|
+
_FFPROBE_CACHE.append("")
|
|
207
|
+
return ""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _ffprobe_json(path):
|
|
211
|
+
"""用 ffprobe 解析一个媒体文件,返回 (ok, 信息dict, 错误串)。"""
|
|
212
|
+
exe = _ffprobe()
|
|
213
|
+
if not exe:
|
|
214
|
+
return False, {}, "找不到可用的 ffprobe(设置 FILMKIT_FFMPEG 指向 ffmpeg,或把 ffprobe 放进 PATH)"
|
|
215
|
+
cmd = [exe, "-v", "error", "-show_entries",
|
|
216
|
+
"stream=codec_type,codec_name,duration,sample_rate,channels:format=duration,size",
|
|
217
|
+
"-of", "json", str(path)]
|
|
218
|
+
try:
|
|
219
|
+
p = subprocess.run(cmd, capture_output=True, timeout=PROBE_TIMEOUT)
|
|
220
|
+
except subprocess.TimeoutExpired:
|
|
221
|
+
return False, {}, "ffprobe 超时(>%ds)" % PROBE_TIMEOUT
|
|
222
|
+
except OSError as e:
|
|
223
|
+
return False, {}, "ffprobe 启动失败:%s" % e
|
|
224
|
+
if p.returncode != 0:
|
|
225
|
+
tail = (p.stderr or b"").decode("utf-8", "replace").strip().splitlines()
|
|
226
|
+
return False, {}, "ffprobe 退出码 %d:%s" % (p.returncode, (tail[-1] if tail else "")[:300])
|
|
227
|
+
try:
|
|
228
|
+
data = json.loads((p.stdout or b"{}").decode("utf-8", "replace") or "{}")
|
|
229
|
+
except ValueError as e:
|
|
230
|
+
return False, {}, "ffprobe 输出不是合法 JSON:%s" % e
|
|
231
|
+
return True, (data if isinstance(data, dict) else {}), ""
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------- 剧本装载
|
|
235
|
+
def _load_script(script, errors):
|
|
236
|
+
"""script(dict 或路径)-> 规范化 dict;不修改调用方传入的对象。
|
|
237
|
+
|
|
238
|
+
失败返回 None(错误已记进 errors)。字段默认值与 render.load_script 对齐:
|
|
239
|
+
缺 id 自动补 l01、缺 sub 用 text(这样 preflight 扫的字段与真正上屏的一致)。
|
|
240
|
+
"""
|
|
241
|
+
import copy
|
|
242
|
+
if isinstance(script, dict):
|
|
243
|
+
data = copy.deepcopy(script)
|
|
244
|
+
path = str(data.get("_path") or "")
|
|
245
|
+
base = str(data.get("_dir") or (os.path.dirname(os.path.abspath(path))
|
|
246
|
+
if path else os.getcwd()))
|
|
247
|
+
else:
|
|
248
|
+
path = os.path.abspath(str(script))
|
|
249
|
+
base = os.path.dirname(path)
|
|
250
|
+
if not os.path.isfile(path):
|
|
251
|
+
_err(errors, "script_missing", "剧本不存在:%s" % path, "script")
|
|
252
|
+
return None
|
|
253
|
+
try:
|
|
254
|
+
with open(path, encoding="utf-8") as fh:
|
|
255
|
+
data = json.load(fh)
|
|
256
|
+
except Exception as e: # noqa: BLE001
|
|
257
|
+
_err(errors, "script_json", "剧本不是合法 JSON:%s(%s)" % (path, e), "script")
|
|
258
|
+
return None
|
|
259
|
+
if not isinstance(data, dict):
|
|
260
|
+
_err(errors, "script_shape", "剧本顶层必须是 JSON 对象(收到 %s)"
|
|
261
|
+
% type(data).__name__, "script")
|
|
262
|
+
return None
|
|
263
|
+
data["_dir"] = base
|
|
264
|
+
data["_path"] = path or str(data.get("_path") or "")
|
|
265
|
+
lines = data.get("lines")
|
|
266
|
+
if not isinstance(lines, list) or not lines:
|
|
267
|
+
_err(errors, "script_shape", "剧本 lines 必须是非空数组(收到 %r)"
|
|
268
|
+
% (type(lines).__name__,), "script.lines")
|
|
269
|
+
return data
|
|
270
|
+
seen = set()
|
|
271
|
+
for i, ln in enumerate(lines):
|
|
272
|
+
if not isinstance(ln, dict):
|
|
273
|
+
_err(errors, "line_shape", "lines[%d] 必须是对象(收到 %s)"
|
|
274
|
+
% (i, type(ln).__name__), "lines[%d]" % i)
|
|
275
|
+
continue
|
|
276
|
+
ln.setdefault("id", "l%02d" % (i + 1))
|
|
277
|
+
if "sub" not in ln and isinstance(ln.get("text"), str):
|
|
278
|
+
ln["sub"] = ln["text"]
|
|
279
|
+
text = ln.get("text")
|
|
280
|
+
if not isinstance(text, str) or not text.strip():
|
|
281
|
+
_err(errors, "line_text_missing", "lines[%d](id=%s)缺少 text(朗读文本)"
|
|
282
|
+
% (i, ln.get("id")), "lines[%d].text" % i,
|
|
283
|
+
line_id=str(ln.get("id")), field="text")
|
|
284
|
+
sid = str(ln.get("id"))
|
|
285
|
+
if sid in seen:
|
|
286
|
+
_err(errors, "line_id_dup", "lines[%d] 的 id=%r 重复(TTS 文件与 cue 会串)"
|
|
287
|
+
% (i, ln.get("id")), "lines[%d].id" % i, line_id=sid)
|
|
288
|
+
seen.add(sid)
|
|
289
|
+
if not str(data.get("title") or "").strip():
|
|
290
|
+
_err(errors, "script_shape", "剧本缺少 title(右下角 brand 会画空)", "script.title")
|
|
291
|
+
return data
|
|
292
|
+
|
|
293
|
+
# ---------------------------------------------------------------- 文本遍历
|
|
294
|
+
def _weights_for(field):
|
|
295
|
+
return FIELD_WEIGHTS.get(field) or ("bold",)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _iter_text_items(script):
|
|
299
|
+
"""产出所有上屏文本项:(where, line_id, field, text, weights)。
|
|
300
|
+
|
|
301
|
+
覆盖剧本行字段 + items[].text + 剧本级 title/end_text。timeline 里的场景
|
|
302
|
+
由 _iter_timeline_items 补扫(同一 id/字段不重复)。
|
|
303
|
+
"""
|
|
304
|
+
lines = script.get("lines") if isinstance(script.get("lines"), list) else []
|
|
305
|
+
for i, ln in enumerate(lines):
|
|
306
|
+
if not isinstance(ln, dict):
|
|
307
|
+
continue
|
|
308
|
+
sid = str(ln.get("id") or "l%02d" % (i + 1))
|
|
309
|
+
for fld in ("text", "sub", "big", "kicker", "lead", "note", "chapter"):
|
|
310
|
+
v = ln.get(fld)
|
|
311
|
+
if isinstance(v, str) and v.strip():
|
|
312
|
+
yield ("lines[%d].%s(id=%s)" % (i, fld, sid), sid, fld, v,
|
|
313
|
+
_weights_for(fld))
|
|
314
|
+
items = ln.get("items")
|
|
315
|
+
if isinstance(items, list):
|
|
316
|
+
for j, it in enumerate(items):
|
|
317
|
+
if isinstance(it, dict):
|
|
318
|
+
txt = it.get("text")
|
|
319
|
+
else:
|
|
320
|
+
txt = it
|
|
321
|
+
if isinstance(txt, str) and txt.strip():
|
|
322
|
+
yield ("lines[%d].items[%d].text(id=%s)" % (i, j, sid), sid,
|
|
323
|
+
ITEM_FIELD, txt, ITEM_WEIGHTS)
|
|
324
|
+
for fld in ("title", "end_text"):
|
|
325
|
+
v = script.get(fld)
|
|
326
|
+
if isinstance(v, str) and v.strip():
|
|
327
|
+
yield ("script.%s" % fld, "", fld, v, _weights_for(fld))
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _load_timeline(timeline, warnings):
|
|
331
|
+
"""timeline(dict 或路径)-> dict 或 None(可选输入;读不了只告警,不阻断)。"""
|
|
332
|
+
if not timeline:
|
|
333
|
+
return None
|
|
334
|
+
if isinstance(timeline, dict):
|
|
335
|
+
return timeline
|
|
336
|
+
path = os.path.abspath(str(timeline))
|
|
337
|
+
if not os.path.isfile(path):
|
|
338
|
+
_warn(warnings, "timeline_missing", "timeline 不存在,跳过场景文本补扫:%s" % path,
|
|
339
|
+
"timeline")
|
|
340
|
+
return None
|
|
341
|
+
try:
|
|
342
|
+
with open(path, encoding="utf-8") as fh:
|
|
343
|
+
data = json.load(fh)
|
|
344
|
+
except Exception as e: # noqa: BLE001
|
|
345
|
+
_warn(warnings, "timeline_unreadable", "timeline 读不了,跳过场景文本补扫:%s(%s)"
|
|
346
|
+
% (path, e), "timeline")
|
|
347
|
+
return None
|
|
348
|
+
if not isinstance(data, dict):
|
|
349
|
+
_warn(warnings, "timeline_shape", "timeline 顶层不是对象,跳过场景文本补扫:%s" % path,
|
|
350
|
+
"timeline")
|
|
351
|
+
return None
|
|
352
|
+
return data
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _iter_timeline_items(timeline, seen):
|
|
356
|
+
"""补扫 timeline.scenes 上的文本:layout 会拿 scenes 再查一遍字形。
|
|
357
|
+
|
|
358
|
+
seen: {(line_id, field): text} 已扫过的剧本内容。同一个 (id, 字段) 且**文本相同**才跳过;
|
|
359
|
+
文本不同说明 timeline 与剧本已经不一致(旧轴),照样查——漏扫等于把未知当通过。
|
|
360
|
+
"""
|
|
361
|
+
scenes = (timeline or {}).get("scenes")
|
|
362
|
+
if not isinstance(scenes, list):
|
|
363
|
+
return
|
|
364
|
+
for i, sc in enumerate(scenes):
|
|
365
|
+
if not isinstance(sc, dict):
|
|
366
|
+
continue
|
|
367
|
+
sid = str(sc.get("id") or "s%02d" % i)
|
|
368
|
+
for fld in ("text", "sub", "big", "kicker", "lead", "note", "chapter"):
|
|
369
|
+
v = sc.get(fld)
|
|
370
|
+
if not isinstance(v, str) or not v.strip():
|
|
371
|
+
continue
|
|
372
|
+
if seen.get((sid, fld)) == v:
|
|
373
|
+
continue
|
|
374
|
+
yield ("timeline.scenes[%d].%s(id=%s)" % (i, fld, sid), sid, fld, v,
|
|
375
|
+
_weights_for(fld))
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------- 检查:字形
|
|
379
|
+
def _check_glyphs(script, timeline, errors, warnings, checks):
|
|
380
|
+
"""所有上屏文本逐字过 filmkit.glyph_missing_chars(渲染前把方块字拦下来)。
|
|
381
|
+
|
|
382
|
+
按字段实际字重检测(见 FIELD_WEIGHTS):同一句 big 用 msyhbd 画、sub 用 msyh,
|
|
383
|
+
三个字体文件覆盖不同,必须分开看。字号用 32(与 QA 的 check_glyphs 口径一致;
|
|
384
|
+
字形覆盖只由字体文件决定,与字号无关)。
|
|
385
|
+
"""
|
|
386
|
+
if _K is None:
|
|
387
|
+
_err(errors, "filmkit_missing",
|
|
388
|
+
"找不到同目录的 filmkit.py,无法做字形检测(fail-closed)", "glyphs")
|
|
389
|
+
_check(checks, "glyphs", "fail", "filmkit.py 缺失")
|
|
390
|
+
return
|
|
391
|
+
raw_lines = script.get("lines")
|
|
392
|
+
usable = [ln for ln in raw_lines if isinstance(ln, dict)] \
|
|
393
|
+
if isinstance(raw_lines, list) else []
|
|
394
|
+
if not usable:
|
|
395
|
+
# 剧本结构坏到没有一行可扫:字形检查**没有执行**,只能 skipped(不能写 pass)
|
|
396
|
+
_check(checks, "glyphs", "skipped", "剧本没有可扫的行(lines 不可用)")
|
|
397
|
+
return
|
|
398
|
+
n_texts = 0
|
|
399
|
+
n_chars = 0
|
|
400
|
+
bad_lines = 0
|
|
401
|
+
items = list(_iter_text_items(script))
|
|
402
|
+
seen = {(sid, fld): text for _where, sid, fld, text, _w in items if sid}
|
|
403
|
+
items += list(_iter_timeline_items(timeline, seen))
|
|
404
|
+
for where, sid, fld, text, weights in items:
|
|
405
|
+
n_texts += 1
|
|
406
|
+
n_chars += len(text)
|
|
407
|
+
per_char = {} # char -> [weight...]
|
|
408
|
+
for wgt in weights:
|
|
409
|
+
try:
|
|
410
|
+
miss = _K.glyph_missing_chars(text, 32, wgt)
|
|
411
|
+
except Exception as e: # noqa: BLE001 —— 字体加载失败已在 fonts 项报过
|
|
412
|
+
_err(errors, "glyph_check_failed",
|
|
413
|
+
"字形检测执行失败(%s):%s" % (type(e).__name__, e), where)
|
|
414
|
+
_check(checks, "glyphs", "fail", "检测器异常")
|
|
415
|
+
return
|
|
416
|
+
for ch in miss:
|
|
417
|
+
per_char.setdefault(ch, []).append(wgt)
|
|
418
|
+
if per_char:
|
|
419
|
+
bad_lines += 1
|
|
420
|
+
chars = "".join(per_char.keys())
|
|
421
|
+
wlist = sorted({w for ws in per_char.values() for w in ws})
|
|
422
|
+
_err(errors, "glyph_missing",
|
|
423
|
+
"字段 %s 缺字形 %s(字重 %s):渲染时会画成方块" %
|
|
424
|
+
(fld, " ".join(repr(c) for c in per_char.keys()), "/".join(wlist)),
|
|
425
|
+
where, line_id=sid, field=fld, chars=chars, weights=wlist)
|
|
426
|
+
# 字族提示:filmkit.glyph_missing_chars 固定用 sans 字族的字体(msyh 一族)。
|
|
427
|
+
# serif / kai / round 主题的实际字体文件不同,覆盖可能有差异——检测结论按 sans 口径给出,
|
|
428
|
+
# 这里明确说一声,避免"preflight 过了但 serif 片子里是方块"的静默盲区。
|
|
429
|
+
fam = _theme_family(script)
|
|
430
|
+
if fam and fam != "sans":
|
|
431
|
+
_warn(warnings, "glyph_family_unchecked",
|
|
432
|
+
"本片风格字体族是 %r:字形检测按 sans(msyh)口径执行,%s 族字体覆盖未逐一核对"
|
|
433
|
+
% (fam, fam), "glyphs")
|
|
434
|
+
unscanned = len(raw_lines) - len(usable) if isinstance(raw_lines, list) else 0
|
|
435
|
+
scope = "扫描 %d 段文本 / %d 字" % (n_texts, n_chars)
|
|
436
|
+
if unscanned:
|
|
437
|
+
scope += "(另有 %d 行结构异常未被扫,见 script 项错误)" % unscanned
|
|
438
|
+
if bad_lines:
|
|
439
|
+
_check(checks, "glyphs", "fail", "缺字形 %d 处(%s)" % (bad_lines, scope))
|
|
440
|
+
elif unscanned:
|
|
441
|
+
# 有行没扫 = 检测范围不完整,不能因为"扫到的部分没问题"就写 pass
|
|
442
|
+
_check(checks, "glyphs", "skipped", scope + ",检测范围不完整")
|
|
443
|
+
else:
|
|
444
|
+
_check(checks, "glyphs", "pass", scope + ",无缺字形")
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _theme_family(script):
|
|
448
|
+
"""尽力取本片主题的字体族(sans/serif/kai/round);取不到返回 ""。"""
|
|
449
|
+
try:
|
|
450
|
+
import stylekit as ST
|
|
451
|
+
except Exception: # noqa: BLE001
|
|
452
|
+
return ""
|
|
453
|
+
try:
|
|
454
|
+
plan = ST.resolve_script_style(script)
|
|
455
|
+
th = plan.get("theme") if isinstance(plan, dict) else None
|
|
456
|
+
return str((th or {}).get("family") or "")
|
|
457
|
+
except Exception: # noqa: BLE001 —— 风格解析失败留给渲染阶段报错
|
|
458
|
+
return ""
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# ---------------------------------------------------------------- 检查:字体
|
|
462
|
+
def _check_fonts(errors, warnings, checks):
|
|
463
|
+
"""字体候选必须真的能加载(不只看文件存在)。"""
|
|
464
|
+
if _K is None:
|
|
465
|
+
_err(errors, "filmkit_missing",
|
|
466
|
+
"找不到同目录的 filmkit.py,无法校验字体(fail-closed)", "fonts")
|
|
467
|
+
_check(checks, "fonts", "fail", "filmkit.py 缺失")
|
|
468
|
+
return
|
|
469
|
+
cands = {}
|
|
470
|
+
for wgt, paths in (getattr(_K, "FONT_CANDIDATES", {}) or {}).items():
|
|
471
|
+
cands[wgt] = [p for p in (paths or []) if os.path.exists(p)]
|
|
472
|
+
if cands and not any(cands.values()):
|
|
473
|
+
_err(errors, "font_missing",
|
|
474
|
+
"filmkit.FONT_CANDIDATES 里没有任何字体文件存在(渲染会抛「找不到中文字体」)",
|
|
475
|
+
"fonts")
|
|
476
|
+
_check(checks, "fonts", "fail", "0 个候选存在")
|
|
477
|
+
return
|
|
478
|
+
resolved, bad = {}, []
|
|
479
|
+
for wgt in ("bold", "reg", "light"):
|
|
480
|
+
try:
|
|
481
|
+
f = _K.font(32, wgt)
|
|
482
|
+
resolved[wgt] = os.path.basename(str(getattr(f, "path", "") or "?"))
|
|
483
|
+
except Exception as e: # noqa: BLE001
|
|
484
|
+
bad.append("%s: %s" % (wgt, e))
|
|
485
|
+
if bad:
|
|
486
|
+
_err(errors, "font_unusable", "字体加载失败:%s" % ";".join(bad), "fonts")
|
|
487
|
+
_check(checks, "fonts", "fail", ";".join(bad)[:160])
|
|
488
|
+
return
|
|
489
|
+
_check(checks, "fonts", "pass",
|
|
490
|
+
"bold=%s / reg=%s / light=%s" % (resolved.get("bold"), resolved.get("reg"),
|
|
491
|
+
resolved.get("light")))
|
|
492
|
+
|
|
493
|
+
# ---------------------------------------------------------------- 检查:TTS
|
|
494
|
+
def _check_tts(script, work, errors, warnings, checks):
|
|
495
|
+
"""work/tts/*.mp3 若存在,必须能被 ffprobe 解析出音频流。
|
|
496
|
+
|
|
497
|
+
目录不存在 / work 没传 = skipped("还没跑 TTS"是正常前置状态,不是通过)。
|
|
498
|
+
目录存在但一个文件都没有 = skipped(同上)。有 mp3 但 ffprobe 不可用 = error(fail-closed)。
|
|
499
|
+
|
|
500
|
+
除了"能解析出音频流",还要**对得上剧本**,否则门禁只是看着干净:
|
|
501
|
+
* 文件名与行 id 对账:filmkit.tts_batch 的文件名恒为 "<id>.mp3",所以
|
|
502
|
+
"某句没有对应 mp3 / 有剧本里没有的 mp3" 说明 work/tts 与剧本不是同一份
|
|
503
|
+
(改过 id、拷来的旧缓存)。只比个数会漏掉"l09 顶替 l04"——数量相同但对不上。
|
|
504
|
+
* 时长与台词长度对账:正常旁白约 0.15–0.25 s/字,连英文也不会快过 0.06 s/字。
|
|
505
|
+
比这还短只可能是音频被截断 / 缓存坏了,渲染时这一句会被压没。
|
|
506
|
+
这两项都是 warning(启发式,不阻断):判据保守,但比静默放过强。
|
|
507
|
+
"""
|
|
508
|
+
if not work:
|
|
509
|
+
_check(checks, "tts", "skipped", "没传 work,未检查 TTS 音频")
|
|
510
|
+
return
|
|
511
|
+
tts_dir = os.path.join(str(work), "tts")
|
|
512
|
+
if not os.path.isdir(tts_dir):
|
|
513
|
+
_check(checks, "tts", "skipped", "还没跑 TTS(%s 不存在)" % tts_dir)
|
|
514
|
+
return
|
|
515
|
+
files = sorted(n for n in os.listdir(tts_dir) if n.lower().endswith(".mp3"))
|
|
516
|
+
if not files:
|
|
517
|
+
_check(checks, "tts", "skipped", "%s 里没有 .mp3" % tts_dir)
|
|
518
|
+
return
|
|
519
|
+
exe = _ffprobe()
|
|
520
|
+
if not exe:
|
|
521
|
+
_err(errors, "ffprobe_missing",
|
|
522
|
+
"有 %d 个 TTS 音频需要校验,但找不到可用的 ffprobe:设置 FILMKIT_FFMPEG 指向 "
|
|
523
|
+
"ffmpeg(同目录需有 ffprobe)或把 ffprobe 放进 PATH" % len(files), "tts")
|
|
524
|
+
_check(checks, "tts", "fail", "ffprobe 不可用(fail-closed)")
|
|
525
|
+
return
|
|
526
|
+
bad = 0
|
|
527
|
+
durs = {}
|
|
528
|
+
for name in files:
|
|
529
|
+
path = os.path.join(tts_dir, name)
|
|
530
|
+
where = "tts/%s" % name
|
|
531
|
+
try:
|
|
532
|
+
size = os.path.getsize(path)
|
|
533
|
+
except OSError as e:
|
|
534
|
+
bad += 1
|
|
535
|
+
_err(errors, "tts_unreadable", "TTS 音频读不了:%s(%s)" % (path, e), where)
|
|
536
|
+
continue
|
|
537
|
+
if size <= 0:
|
|
538
|
+
bad += 1
|
|
539
|
+
_err(errors, "tts_empty", "TTS 音频是空文件(0 字节):%s" % path, where)
|
|
540
|
+
continue
|
|
541
|
+
ok, info, msg = _ffprobe_json(path)
|
|
542
|
+
if not ok:
|
|
543
|
+
bad += 1
|
|
544
|
+
_err(errors, "tts_corrupt", "TTS 音频 ffprobe 解析失败(%s):%s" % (path, msg), where)
|
|
545
|
+
continue
|
|
546
|
+
streams = [s for s in (info.get("streams") or [])
|
|
547
|
+
if str(s.get("codec_type")) == "audio"]
|
|
548
|
+
if not streams:
|
|
549
|
+
bad += 1
|
|
550
|
+
_err(errors, "tts_no_audio", "TTS 文件里没有音频流:%s" % path, where)
|
|
551
|
+
continue
|
|
552
|
+
dur = None
|
|
553
|
+
try:
|
|
554
|
+
dur = float((info.get("format") or {}).get("duration") or 0.0) or None
|
|
555
|
+
except (TypeError, ValueError):
|
|
556
|
+
dur = None
|
|
557
|
+
if dur:
|
|
558
|
+
durs[name] = round(dur, 3)
|
|
559
|
+
else:
|
|
560
|
+
_warn(warnings, "tts_no_duration", "ffprobe 能解析但没有时长(%s)" % path, where)
|
|
561
|
+
lines = script.get("lines") if isinstance(script.get("lines"), list) else []
|
|
562
|
+
if len(files) < len(lines):
|
|
563
|
+
_warn(warnings, "tts_partial", "TTS 音频 %d 个 < 剧本 %d 句(可能还没合成完)"
|
|
564
|
+
% (len(files), len(lines)), "tts")
|
|
565
|
+
# ---- 文件名 <-> 行 id 对账(见函数 docstring:只比个数会漏掉"数量相同但对不上")----
|
|
566
|
+
by_id = {str(ln.get("id")): ln for ln in lines if isinstance(ln, dict)}
|
|
567
|
+
stems = [os.path.splitext(n)[0] for n in files]
|
|
568
|
+
missing = [lid for lid in by_id if lid not in stems]
|
|
569
|
+
extra = [s for s in stems if s not in by_id]
|
|
570
|
+
if missing:
|
|
571
|
+
_warn(warnings, "tts_missing_lines",
|
|
572
|
+
"work/tts 里没有这些句子的音频:%s(剧本里这几句 id 没变、音频却不在;"
|
|
573
|
+
"重跑 --stage tts 会补齐。也可能只是 tts 还没跑完)"
|
|
574
|
+
% "、".join(missing[:8]), "tts", line_ids=missing)
|
|
575
|
+
if extra:
|
|
576
|
+
_warn(warnings, "tts_stale_files",
|
|
577
|
+
"work/tts 里有剧本用不到的音频:%s(旧片缓存/改过 id?它们不会被这次渲染使用)"
|
|
578
|
+
% "、".join(sorted(extra)[:8]), "tts", file_names=sorted(extra)[:8])
|
|
579
|
+
# ---- 时长 <-> 台词长度对账:过短几乎是截断/坏缓存 ----
|
|
580
|
+
short = []
|
|
581
|
+
for name in files:
|
|
582
|
+
dur = durs.get(name)
|
|
583
|
+
ln = by_id.get(os.path.splitext(name)[0])
|
|
584
|
+
if not dur or not isinstance(ln, dict):
|
|
585
|
+
continue
|
|
586
|
+
n_chars = sum(1 for ch in str(ln.get("text") or "") if not ch.isspace())
|
|
587
|
+
if n_chars >= 4 and float(dur) < 0.06 * n_chars:
|
|
588
|
+
short.append("%s %.2fs/%d 字" % (name, float(dur), n_chars))
|
|
589
|
+
if short:
|
|
590
|
+
_warn(warnings, "tts_suspect_short",
|
|
591
|
+
"这些 TTS 音频短得不合理(<0.06 s/字,正常旁白 0.15–0.25 s/字):%s —— "
|
|
592
|
+
"音频被截断或缓存坏了的典型表现,渲染时这一句会被压没;"
|
|
593
|
+
"用 --stage tts --force-tts 重合成" % "、".join(short[:8]), "tts")
|
|
594
|
+
if bad:
|
|
595
|
+
_check(checks, "tts", "fail", "%d/%d 个音频不可用" % (bad, len(files)))
|
|
596
|
+
else:
|
|
597
|
+
_check(checks, "tts", "pass", "%d 个 mp3 均可解析(时长 %.2f–%.2f s)"
|
|
598
|
+
% (len(files),
|
|
599
|
+
min(durs.values()) if durs else 0.0,
|
|
600
|
+
max(durs.values()) if durs else 0.0))
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
# ---------------------------------------------------------------- 检查:web 页面
|
|
604
|
+
def _web_entry(script):
|
|
605
|
+
"""engine=web 的入口页:film.json 的 web.entry,或 <剧本目录>/web/index.html。
|
|
606
|
+
|
|
607
|
+
与 render.py 的 _web_entry 同口径(web.entry 按当前目录 resolve)。
|
|
608
|
+
"""
|
|
609
|
+
web = script.get("web") if isinstance(script.get("web"), dict) else {}
|
|
610
|
+
entry = str(web.get("entry") or "").strip()
|
|
611
|
+
if entry:
|
|
612
|
+
return os.path.abspath(entry)
|
|
613
|
+
base = str(script.get("_dir") or os.getcwd())
|
|
614
|
+
return os.path.abspath(os.path.join(base, "web", "index.html"))
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _skill_web_template():
|
|
618
|
+
"""技能自带的 web 模板页(skills/video-studio/assets/web/index.html);找不到返回 ""。
|
|
619
|
+
|
|
620
|
+
preflight.py 在 scripts/ 下,模板资产在兄弟目录 assets/web/。它的用途只有一句:
|
|
621
|
+
engine=web 缺页面时告诉用户"模板在哪",而不是自己拿模板当项目页面(render 不会用它)。
|
|
622
|
+
"""
|
|
623
|
+
p = os.path.normpath(os.path.join(HERE, os.pardir, "assets", "web", "index.html"))
|
|
624
|
+
return p if os.path.isfile(p) else ""
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _check_web_page(script, engine, errors, warnings, checks):
|
|
628
|
+
"""engine=web 时入口页必须存在;engine=pil 时本项不适用 = skipped。"""
|
|
629
|
+
if engine not in ("pil", "web"):
|
|
630
|
+
_check(checks, "web_page", "skipped", "engine=%r 非法,未检查" % (engine,))
|
|
631
|
+
return
|
|
632
|
+
if engine == "pil":
|
|
633
|
+
_check(checks, "web_page", "skipped", "engine=pil,不适用")
|
|
634
|
+
return
|
|
635
|
+
entry = _web_entry(script)
|
|
636
|
+
if not os.path.isfile(entry):
|
|
637
|
+
tpl = _skill_web_template()
|
|
638
|
+
hint = ("把技能自带的模板页整目录拷到 %s(模板:%s),或写 film.json 的 web.entry "
|
|
639
|
+
"指向你自己的页面" % (os.path.dirname(entry), tpl)) if tpl else \
|
|
640
|
+
"把 <SKILL_DIR>/assets/web 拷到项目 web/,或写 film.json 的 web.entry"
|
|
641
|
+
_err(errors, "web_page_missing", "engine=web 但入口页不存在:%s(%s)"
|
|
642
|
+
% (entry, hint), "web_page")
|
|
643
|
+
_check(checks, "web_page", "fail", entry)
|
|
644
|
+
return
|
|
645
|
+
try:
|
|
646
|
+
head = open(entry, "rb").read(512).decode("utf-8", "replace")
|
|
647
|
+
except OSError as e: # noqa: BLE001
|
|
648
|
+
_err(errors, "web_page_unreadable", "入口页读不了:%s(%s)" % (entry, e), "web_page")
|
|
649
|
+
_check(checks, "web_page", "fail", entry)
|
|
650
|
+
return
|
|
651
|
+
if "<html" not in head.lower() and "<!doctype" not in head.lower():
|
|
652
|
+
_warn(warnings, "web_page_not_html",
|
|
653
|
+
"入口页开头不像 HTML(没看到 <html / <!doctype):%s" % entry, "web_page")
|
|
654
|
+
_check(checks, "web_page", "pass", entry)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
# ---------------------------------------------------------------- 总入口
|
|
658
|
+
def _timeline_hint(script, timeline, warnings):
|
|
659
|
+
"""timeline 与剧本对不上时告警(layout 变了没重排轴,采信旧 timeline 会错帧)。"""
|
|
660
|
+
if not isinstance(timeline, dict):
|
|
661
|
+
return
|
|
662
|
+
scenes = timeline.get("scenes")
|
|
663
|
+
if not isinstance(scenes, list):
|
|
664
|
+
return
|
|
665
|
+
ids = {str(sc.get("id")) for sc in scenes if isinstance(sc, dict)}
|
|
666
|
+
lines = script.get("lines") if isinstance(script.get("lines"), list) else []
|
|
667
|
+
missing = [str(ln.get("id")) for ln in lines
|
|
668
|
+
if isinstance(ln, dict) and str(ln.get("id")) not in ids]
|
|
669
|
+
if missing:
|
|
670
|
+
_warn(warnings, "timeline_stale",
|
|
671
|
+
"timeline 里缺 %d 句(%s),很可能已经过时,请先重跑 --stage layout"
|
|
672
|
+
% (len(missing), " / ".join(missing[:6])), "timeline")
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def check(script, *, work=None, engine="pil", timeline=None) -> dict:
|
|
676
|
+
"""渲染前资源闸门:返回 {"ok", "errors", "warnings", "checks", "engine"}。
|
|
677
|
+
|
|
678
|
+
纯函数:不打印、不落盘、不改入参。ok = 没有 errors(warnings / skipped 不影响 ok)。
|
|
679
|
+
"""
|
|
680
|
+
errors, warnings, checks = [], [], {}
|
|
681
|
+
eng = str(engine or "pil").strip().lower()
|
|
682
|
+
n_load_errors = len(errors)
|
|
683
|
+
script_obj = _load_script(script, errors)
|
|
684
|
+
n_script_errors = len(errors) - n_load_errors
|
|
685
|
+
if script_obj is None:
|
|
686
|
+
for cid in CHECK_IDS:
|
|
687
|
+
_check(checks, cid, "skipped", "剧本不可用,未执行")
|
|
688
|
+
return dict(ok=False, errors=errors, warnings=warnings, checks=checks, engine=eng)
|
|
689
|
+
tl = _load_timeline(timeline, warnings)
|
|
690
|
+
if script_obj.get("lines"):
|
|
691
|
+
lines = script_obj["lines"]
|
|
692
|
+
if n_script_errors:
|
|
693
|
+
_check(checks, "script", "fail",
|
|
694
|
+
"%d 行,但结构有 %d 处问题(见 errors)" % (len(lines), n_script_errors))
|
|
695
|
+
else:
|
|
696
|
+
_check(checks, "script", "pass", "%d 行 / id 唯一" % len(lines))
|
|
697
|
+
else:
|
|
698
|
+
_check(checks, "script", "fail", "lines 不可用")
|
|
699
|
+
if eng not in ("pil", "web"):
|
|
700
|
+
_err(errors, "bad_engine", "engine 只能是 pil 或 web(收到 %r)" % (engine,), "engine")
|
|
701
|
+
_check_glyphs(script_obj, tl, errors, warnings, checks)
|
|
702
|
+
_check_tts(script_obj, work, errors, warnings, checks)
|
|
703
|
+
_check_fonts(errors, warnings, checks)
|
|
704
|
+
_check_web_page(script_obj, eng, errors, warnings, checks)
|
|
705
|
+
_timeline_hint(script_obj, tl, warnings)
|
|
706
|
+
return dict(ok=not errors, errors=errors, warnings=warnings, checks=checks,
|
|
707
|
+
engine=eng, script=str(script_obj.get("_path") or ""),
|
|
708
|
+
work=str(work) if work else "")
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
# ---------------------------------------------------------------- CLI
|
|
712
|
+
def _print_human(res):
|
|
713
|
+
"""给人看的输出:检查矩阵 + 每条 error 的定位(字符 / 字段 / 句 id)。"""
|
|
714
|
+
order = {"pass": "PASS", "fail": "FAIL", "skipped": "SKIP"}
|
|
715
|
+
print("== preflight(engine=%s)" % res.get("engine"))
|
|
716
|
+
for cid in CHECK_IDS:
|
|
717
|
+
c = res["checks"].get(cid) or {}
|
|
718
|
+
print(" %-4s %-8s %s" % (order.get(c.get("status"), "?"), cid,
|
|
719
|
+
c.get("detail") or ""))
|
|
720
|
+
for w in res.get("warnings") or []:
|
|
721
|
+
print(" [WARN] %s %s" % (w.get("where") or "", w.get("msg") or ""))
|
|
722
|
+
if res.get("errors"):
|
|
723
|
+
print(" [FAIL] %d 处问题(渲染前必须修):" % len(res["errors"]))
|
|
724
|
+
for e in res["errors"]:
|
|
725
|
+
print(" - %-18s %s" % (e.get("code"), e.get("msg")))
|
|
726
|
+
if e.get("where"):
|
|
727
|
+
print(" where: %s" % e["where"])
|
|
728
|
+
n_skip = sum(1 for c in res["checks"].values() if c.get("status") == "skipped")
|
|
729
|
+
if n_skip:
|
|
730
|
+
print(" 注意:有 %d 项本次**没有检查**(skipped != 通过)" % n_skip)
|
|
731
|
+
print(" 结论:%s" % ("FAIL" if res.get("errors") else "PASS"))
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _script_engine(script_path):
|
|
735
|
+
"""从 film.json 读 engine 字段(读不到按 "pil"):CLI 不传 --engine 时与 render.py 一致。"""
|
|
736
|
+
try:
|
|
737
|
+
with open(str(script_path), encoding="utf-8") as fh:
|
|
738
|
+
data = json.load(fh)
|
|
739
|
+
eng = str((data or {}).get("engine") or "pil").strip().lower()
|
|
740
|
+
return eng or "pil"
|
|
741
|
+
except Exception: # noqa: BLE001 —— 剧本问题由 check() 统一报
|
|
742
|
+
return "pil"
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def main(argv=None):
|
|
746
|
+
_setup_stdio()
|
|
747
|
+
ap = argparse.ArgumentParser(description="渲染前资源闸门(缺字形 / 坏 TTS / 字体 / web 页面)")
|
|
748
|
+
ap.add_argument("--script", required=True, help="film.json 路径")
|
|
749
|
+
ap.add_argument("--work", default="", help="work 目录(有 work/tts/*.mp3 才校验音频)")
|
|
750
|
+
ap.add_argument("--engine", default="", choices=("", "pil", "web"),
|
|
751
|
+
help="渲染引擎;缺省读 film.json 的 engine 字段(再缺省 pil)")
|
|
752
|
+
ap.add_argument("--timeline", default="", help="work/timeline.json(可选;补扫场景文本/查过时)")
|
|
753
|
+
ap.add_argument("--json", action="store_true", help="stdout 只输出 JSON(机读)")
|
|
754
|
+
ap.add_argument("--strict", action="store_true",
|
|
755
|
+
help="skipped 也算失败(对齐 render.py --strict-gates 语义)")
|
|
756
|
+
a = ap.parse_args(argv)
|
|
757
|
+
engine = a.engine or _script_engine(a.script)
|
|
758
|
+
res = check(a.script, work=(a.work or None), engine=engine,
|
|
759
|
+
timeline=(a.timeline or None))
|
|
760
|
+
if a.json:
|
|
761
|
+
json.dump(res, sys.stdout, ensure_ascii=False, indent=1)
|
|
762
|
+
sys.stdout.write("\n")
|
|
763
|
+
else:
|
|
764
|
+
_print_human(res)
|
|
765
|
+
n_skip = sum(1 for c in res["checks"].values() if c.get("status") == "skipped")
|
|
766
|
+
if a.strict and n_skip and not res["errors"]:
|
|
767
|
+
if not a.json:
|
|
768
|
+
print(" [FAIL] --strict:%d 项没检查算失败(skipped != 通过)" % n_skip)
|
|
769
|
+
return 1
|
|
770
|
+
return 0 if res.get("ok") else 1
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
if __name__ == "__main__":
|
|
774
|
+
raise SystemExit(main())
|