@dsh-gis/dsh-video-studio-skill 0.4.0 → 0.6.0
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/README.md +62 -12
- package/docs/styles/demo-results.json +76 -0
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-ai.png +0 -0
- package/docs/styles/sheet-craft.png +0 -0
- package/docs/styles/sheet-night.png +0 -0
- package/package.json +11 -3
- package/schemas/film.schema.json +528 -0
- package/skills/video-studio/SKILL.md +166 -14
- package/skills/video-studio/assets/film.json +84 -23
- package/skills/video-studio/assets/scenes.py +892 -34
- package/skills/video-studio/references/audio.md +29 -1
- package/skills/video-studio/references/change-impact.md +54 -0
- package/skills/video-studio/references/quality-gates.md +1 -1
- package/skills/video-studio/references/style-packs.md +178 -0
- package/skills/video-studio/requirements.txt +11 -0
- package/skills/video-studio/scripts/filmcheck.py +656 -0
- package/skills/video-studio/scripts/filmkit.py +682 -58
- package/skills/video-studio/scripts/gates.py +446 -0
- package/skills/video-studio/scripts/render.py +562 -34
- package/skills/video-studio/scripts/scorekit.py +1642 -25
- package/skills/video-studio/scripts/selfcheck.py +346 -0
- package/skills/video-studio/scripts/stagewatch.py +295 -0
- package/skills/video-studio/scripts/stylekit.py +924 -0
- package/skills/video-studio/scripts/voicekit.py +872 -0
- package/tools/ci_check.py +339 -0
- package/tools/estimate.py +578 -0
|
@@ -48,32 +48,216 @@ CUE_ALIASES = {
|
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
# ---------------------------------------------------------------- ffmpeg
|
|
51
|
+
# ffmpeg 探测必须 fail-closed:候选"文件存在"不等于"能用"。
|
|
52
|
+
# 本机曾静默选中 C:\Program Files\EVCapture\ffmpeg.exe(2019 旧版,没有 xfade),
|
|
53
|
+
# 一路跑到滤镜阶段才挂,报错离原因太远。现在每个候选都必须真的跑通
|
|
54
|
+
# "ffmpeg -version" 且 returncode == 0 才算可用(超时/异常一律视为不可用)。
|
|
55
|
+
_FFMPEG_ENV = "FILMKIT_FFMPEG"
|
|
56
|
+
_FFMPEG_STRICT_ENV = "FILMKIT_FFMPEG_STRICT"
|
|
57
|
+
_FFMPEG_UP_LEVELS = 5 # 从 cwd 向上最多找 5 层父目录的便携布局
|
|
58
|
+
_FFMPEG_NAMES = ("ffmpeg.exe", "ffmpeg") if os.name == "nt" else ("ffmpeg", "ffmpeg.exe")
|
|
59
|
+
|
|
60
|
+
# 来源键 -> 给人看的说明(ffmpeg_source() 用;backend 回显 / 日志都靠它)
|
|
61
|
+
FFMPEG_SOURCE_LABELS = {
|
|
62
|
+
"env": "显式指定(FILMKIT_FFMPEG)",
|
|
63
|
+
"portable": "向上搜索(tools/ffmpeg)",
|
|
64
|
+
"hardcoded": "硬编码候选(WinGet/系统路径)",
|
|
65
|
+
"path": "PATH(shutil.which)",
|
|
66
|
+
"fallback": "未找到(回退裸命令,默认宽松模式)",
|
|
67
|
+
}
|
|
68
|
+
_FFMPEG_CACHE = {} # {候选集合 tuple: (可执行路径, 来源键)}:同一候选集合只探测一次
|
|
69
|
+
_FFPROBE_CACHE = {} # {ffmpeg 路径: ffprobe 路径}:ffprobe 定位也不重复做
|
|
70
|
+
|
|
71
|
+
_FFMPEG_STRICT_HINT = (
|
|
72
|
+
"找不到可用的 ffmpeg:FILMKIT_FFMPEG、从当前目录向上 %d 层的 tools/ffmpeg、"
|
|
73
|
+
"WinGet/系统固定路径、PATH 里都没有能跑通 \"ffmpeg -version\"(returncode=0)的可执行文件。"
|
|
74
|
+
"装了没有?winget install Gyan.FFmpeg 可以装;装在哪?便携版放到 <项目>/tools/ffmpeg/ffmpeg.exe "
|
|
75
|
+
"即可被自动发现;怎么指?设置环境变量 FILMKIT_FFMPEG=D:/tools/ffmpeg.exe。"
|
|
76
|
+
"(当前 FILMKIT_FFMPEG_STRICT=1,所以直接退出;去掉该变量会回退成裸命令 ffmpeg。)"
|
|
77
|
+
% _FFMPEG_UP_LEVELS
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _probe_ffmpeg(path):
|
|
82
|
+
"""候选是否真的可用:返回可执行路径,不可用返回 None(fail-closed)。
|
|
83
|
+
|
|
84
|
+
必须跑通 "-version";文件不存在 / 不是可执行文件 / 权限拒绝 / 超时 / 非 0 退出,
|
|
85
|
+
一律视为不可用。异常吞掉继续试下一个候选——探测阶段不应该把 import 炸掉。
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
if not path:
|
|
89
|
+
return None
|
|
90
|
+
exe = str(path)
|
|
91
|
+
if not os.path.isfile(exe):
|
|
92
|
+
exe = shutil.which(exe) or "" # 允许 FILMKIT_FFMPEG=ffmpeg 这种裸名字
|
|
93
|
+
if not exe:
|
|
94
|
+
return None
|
|
95
|
+
elif not os.path.isabs(exe):
|
|
96
|
+
exe = os.path.abspath(exe) # 相对路径候选按 cwd 解析,别让 execvp 去 PATH 里找
|
|
97
|
+
p = subprocess.run([exe, "-version"], capture_output=True, timeout=10)
|
|
98
|
+
if p.returncode != 0:
|
|
99
|
+
return None
|
|
100
|
+
return exe
|
|
101
|
+
except Exception: # noqa: BLE001 —— 探测失败=候选不可用,换下一个
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _iter_up_dirs(levels=None):
|
|
106
|
+
"""cwd + 向上 levels 层父目录(含 cwd 共 levels+1 个)。cwd 被删时安全退化。"""
|
|
107
|
+
try:
|
|
108
|
+
d = os.path.abspath(os.getcwd())
|
|
109
|
+
except Exception: # noqa: BLE001
|
|
110
|
+
return
|
|
111
|
+
yield d
|
|
112
|
+
for _ in range(int(_FFMPEG_UP_LEVELS if levels is None else levels)):
|
|
113
|
+
parent = os.path.dirname(d)
|
|
114
|
+
if parent == d: # 到盘符根 / 文件系统根,停
|
|
115
|
+
break
|
|
116
|
+
d = parent
|
|
117
|
+
yield d
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _portable_ffmpeg_candidates():
|
|
121
|
+
"""便携布局:<目录>/tools/ffmpeg/ffmpeg(.exe) 与 <目录>/tools/ffmpeg(.exe)。"""
|
|
122
|
+
out = []
|
|
123
|
+
for d in _iter_up_dirs():
|
|
124
|
+
for name in _FFMPEG_NAMES:
|
|
125
|
+
out.append(os.path.join(d, "tools", "ffmpeg", name))
|
|
126
|
+
for name in _FFMPEG_NAMES:
|
|
127
|
+
out.append(os.path.join(d, "tools", name))
|
|
128
|
+
return out
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _hardcoded_ffmpeg_candidates():
|
|
132
|
+
"""原有硬编码候选(WinGet Links / Unix 常见路径)。顺序保持原样,不新增 EVCapture 之类的意外构建。"""
|
|
133
|
+
out = []
|
|
134
|
+
local = os.environ.get("LOCALAPPDATA", "")
|
|
135
|
+
user = os.environ.get("USERPROFILE", "")
|
|
136
|
+
if local:
|
|
137
|
+
out.append(os.path.join(local, "Microsoft", "WinGet", "Links", "ffmpeg.exe"))
|
|
138
|
+
if user:
|
|
139
|
+
out.append(os.path.join(user, "AppData", "Local", "Microsoft", "WinGet",
|
|
140
|
+
"Links", "ffmpeg.exe"))
|
|
141
|
+
out += ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"]
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _ffmpeg_candidates():
|
|
146
|
+
"""按优先级返回 [(候选路径, 来源键)]:显式指定 -> 向上搜索 -> 硬编码 -> PATH。"""
|
|
147
|
+
cands, seen = [], set()
|
|
148
|
+
|
|
149
|
+
def add(path, src):
|
|
150
|
+
path = (path or "").strip()
|
|
151
|
+
if len(path) >= 2 and path[0] == path[-1] and path[0] in "\"'":
|
|
152
|
+
path = path[1:-1].strip() # 容忍用户把路径连引号一起粘进环境变量
|
|
153
|
+
if not path:
|
|
154
|
+
return
|
|
155
|
+
key = os.path.normcase(os.path.abspath(path))
|
|
156
|
+
if key in seen:
|
|
157
|
+
return
|
|
158
|
+
seen.add(key)
|
|
159
|
+
cands.append((path, src))
|
|
160
|
+
|
|
161
|
+
add(os.environ.get(_FFMPEG_ENV), "env")
|
|
162
|
+
for p in _portable_ffmpeg_candidates():
|
|
163
|
+
add(p, "portable")
|
|
164
|
+
for p in _hardcoded_ffmpeg_candidates():
|
|
165
|
+
add(p, "hardcoded")
|
|
166
|
+
add(shutil.which("ffmpeg"), "path")
|
|
167
|
+
return cands
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _resolve_ffmpeg():
|
|
171
|
+
"""探测并缓存 ffmpeg,返回 (可执行路径, 来源键)。
|
|
172
|
+
|
|
173
|
+
默认宽松:一个候选都不可用时回退裸命令 "ffmpeg"(让真正调用 subprocess 的地方自己报错);
|
|
174
|
+
FILMKIT_FFMPEG_STRICT=1 时改为 raise SystemExit,并在消息里说清怎么装/放哪/怎么指。
|
|
175
|
+
"""
|
|
176
|
+
cands = _ffmpeg_candidates()
|
|
177
|
+
key = tuple((os.path.normcase(os.path.abspath(p)), s) for p, s in cands)
|
|
178
|
+
hit = _FFMPEG_CACHE.get(key)
|
|
179
|
+
if hit is not None:
|
|
180
|
+
# 回退结果命中缓存时也要按"当前"STRICT 再判一次:改完 env 不 reset 也不能漏掉 fail-closed
|
|
181
|
+
if hit[1] != "fallback" or (os.environ.get(_FFMPEG_STRICT_ENV) or "").strip() != "1":
|
|
182
|
+
return hit
|
|
183
|
+
raise SystemExit(_FFMPEG_STRICT_HINT)
|
|
184
|
+
for cand, src in cands:
|
|
185
|
+
exe = _probe_ffmpeg(cand)
|
|
186
|
+
if exe:
|
|
187
|
+
res = (exe if isinstance(exe, str) else cand, src)
|
|
188
|
+
_FFMPEG_CACHE[key] = res
|
|
189
|
+
return res
|
|
190
|
+
if (os.environ.get(_FFMPEG_STRICT_ENV) or "").strip() == "1":
|
|
191
|
+
raise SystemExit(_FFMPEG_STRICT_HINT)
|
|
192
|
+
res = ("ffmpeg", "fallback")
|
|
193
|
+
_FFMPEG_CACHE[key] = res
|
|
194
|
+
return res
|
|
195
|
+
|
|
196
|
+
|
|
51
197
|
def _find_ffmpeg() -> str:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
198
|
+
"""旧内部接口保留:返回探测到的 ffmpeg 可执行路径。"""
|
|
199
|
+
return _resolve_ffmpeg()[0]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _reset_ffmpeg_cache():
|
|
203
|
+
"""清空探测缓存并立即重探(测试改过 os.environ / cwd 后必须调它)。
|
|
204
|
+
|
|
205
|
+
同时刷新模块级 FFMPEG,保证 filmkit.FFMPEG、ffprobe_path()、向上搜索布局
|
|
206
|
+
在同一个进程里改环境后能拿到新结果。
|
|
207
|
+
"""
|
|
208
|
+
global FFMPEG
|
|
209
|
+
_FFMPEG_CACHE.clear()
|
|
210
|
+
_FFPROBE_CACHE.clear()
|
|
211
|
+
FFMPEG = _find_ffmpeg()
|
|
212
|
+
return FFMPEG
|
|
65
213
|
|
|
66
214
|
|
|
67
215
|
FFMPEG = _find_ffmpeg()
|
|
68
216
|
PY = sys.executable
|
|
69
217
|
|
|
70
218
|
|
|
219
|
+
def _find_ffprobe(ffmpeg=None) -> str:
|
|
220
|
+
"""ffprobe 定位:先找 ffmpeg 同目录的 ffprobe(.exe),再用 shutil.which 兜底。
|
|
221
|
+
|
|
222
|
+
不再做 FFMPEG.replace("ffmpeg", "ffprobe"):ffmpeg 被改名(如 ffmpeg-full.exe)时
|
|
223
|
+
字符串替换出来的路径根本不存在,调用要到远处才炸。
|
|
224
|
+
"""
|
|
225
|
+
ff = str(ffmpeg or FFMPEG)
|
|
226
|
+
hit = _FFPROBE_CACHE.get(ff)
|
|
227
|
+
if hit:
|
|
228
|
+
return hit
|
|
229
|
+
cand = ""
|
|
230
|
+
looks_pathy = os.path.isabs(ff) or os.sep in ff or bool(os.altsep and os.altsep in ff)
|
|
231
|
+
if looks_pathy:
|
|
232
|
+
d = os.path.dirname(os.path.abspath(ff))
|
|
233
|
+
names = ("ffprobe.exe", "ffprobe") if os.name == "nt" else ("ffprobe", "ffprobe.exe")
|
|
234
|
+
for name in names:
|
|
235
|
+
p = os.path.join(d, name)
|
|
236
|
+
if os.path.exists(p):
|
|
237
|
+
cand = p
|
|
238
|
+
break
|
|
239
|
+
if not cand:
|
|
240
|
+
cand = shutil.which("ffprobe") or "ffprobe"
|
|
241
|
+
_FFPROBE_CACHE[ff] = cand
|
|
242
|
+
return cand
|
|
243
|
+
|
|
244
|
+
|
|
71
245
|
def ffprobe_path() -> str:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
246
|
+
"""ffprobe 可执行文件路径(按 ffmpeg 路径缓存;_reset_ffmpeg_cache() 可清)。"""
|
|
247
|
+
return _find_ffprobe()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def ffmpeg_source() -> str:
|
|
251
|
+
"""可读的 ffmpeg 来源回显,如 "显式指定(FILMKIT_FFMPEG): D:/tools/ffmpeg.exe"。
|
|
252
|
+
|
|
253
|
+
供 backend / 日志说明"到底用了哪个 ffmpeg、它从哪来的"。
|
|
254
|
+
"""
|
|
255
|
+
path, src = _resolve_ffmpeg()
|
|
256
|
+
txt = "%s: %s" % (FFMPEG_SOURCE_LABELS.get(src, src), path)
|
|
257
|
+
env = (os.environ.get(_FFMPEG_ENV) or "").strip()
|
|
258
|
+
if env and src != "env":
|
|
259
|
+
txt += ";注意 %s=%r 不可用,已跳过" % (_FFMPEG_ENV, env)
|
|
260
|
+
return txt
|
|
77
261
|
|
|
78
262
|
|
|
79
263
|
def _run(cmd, **kw):
|
|
@@ -96,11 +280,55 @@ FONT_CANDIDATES = {
|
|
|
96
280
|
}
|
|
97
281
|
|
|
98
282
|
|
|
99
|
-
|
|
100
|
-
|
|
283
|
+
# ---------------------------------------------------------------- 字体族(v0.5.0)
|
|
284
|
+
# 换片要换气质,字体是第一眼。但"换字体族"必须同时换**所有**缓存键,
|
|
285
|
+
# 否则会出现"用 A 字体量宽度、用 B 字体绘制"——文字直接溢出安全区。
|
|
286
|
+
# 每一族都给中文回退:找不到 serif/kai 时退到同族 reg 再退 sans,**绝不退到 mono**(无中文字形)。
|
|
287
|
+
FONT_FAMILIES = {
|
|
288
|
+
"sans": { # 现状:微软雅黑一族
|
|
289
|
+
"bold": [r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\simhei.ttf",
|
|
290
|
+
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc"],
|
|
291
|
+
"reg": [r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simhei.ttf",
|
|
292
|
+
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"],
|
|
293
|
+
"light": [r"C:\Windows\Fonts\msyhl.ttc", r"C:\Windows\Fonts\msyh.ttc"],
|
|
294
|
+
},
|
|
295
|
+
"serif": { # 杂志 / 史诗:宋体系
|
|
296
|
+
"bold": [r"C:\Windows\Fonts\NotoSerifSC-VF.ttf", r"C:\Windows\Fonts\simsun.ttc",
|
|
297
|
+
r"C:\Windows\Fonts\msyhbd.ttc"],
|
|
298
|
+
"reg": [r"C:\Windows\Fonts\NotoSerifSC-VF.ttf", r"C:\Windows\Fonts\simsun.ttc",
|
|
299
|
+
r"C:\Windows\Fonts\msyh.ttc"],
|
|
300
|
+
"light": [r"C:\Windows\Fonts\NotoSerifSC-VF.ttf", r"C:\Windows\Fonts\simsun.ttc",
|
|
301
|
+
r"C:\Windows\Fonts\msyh.ttc"],
|
|
302
|
+
},
|
|
303
|
+
"kai": { # 手绘纸感:楷体
|
|
304
|
+
"bold": [r"C:\Windows\Fonts\simkai.ttf", r"C:\Windows\Fonts\simfang.ttf",
|
|
305
|
+
r"C:\Windows\Fonts\msyhbd.ttc"],
|
|
306
|
+
"reg": [r"C:\Windows\Fonts\simkai.ttf", r"C:\Windows\Fonts\simfang.ttf",
|
|
307
|
+
r"C:\Windows\Fonts\msyh.ttc"],
|
|
308
|
+
"light": [r"C:\Windows\Fonts\simkai.ttf", r"C:\Windows\Fonts\msyh.ttc"],
|
|
309
|
+
},
|
|
310
|
+
"round": { # 圆润:等线 / 雅黑回退
|
|
311
|
+
"bold": [r"C:\Windows\Fonts\Dengb.ttf", r"C:\Windows\Fonts\msyhbd.ttc"],
|
|
312
|
+
"reg": [r"C:\Windows\Fonts\Deng.ttf", r"C:\Windows\Fonts\msyh.ttc"],
|
|
313
|
+
"light": [r"C:\Windows\Fonts\Dengl.ttf", r"C:\Windows\Fonts\msyhl.ttc"],
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
FAMILY_IDS = tuple(FONT_FAMILIES.keys())
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def font(size: int, weight: str = "bold", family: str = "sans") -> ImageFont.FreeTypeFont:
|
|
320
|
+
"""取字体。family 缺省 sans = v0.4 行为(老调用 font(46, "reg") 输出不变)。"""
|
|
321
|
+
fam = str(family or "sans")
|
|
322
|
+
if fam not in FONT_FAMILIES:
|
|
323
|
+
fam = "sans" # 未知族名退到 sans,不让拼写错误炸掉渲染
|
|
324
|
+
key = (int(size), weight, fam)
|
|
101
325
|
if key in _font_cache:
|
|
102
326
|
return _font_cache[key]
|
|
103
|
-
|
|
327
|
+
chain = list(FONT_FAMILIES[fam].get(weight) or [])
|
|
328
|
+
chain += list(FONT_FAMILIES[fam]["reg"])
|
|
329
|
+
chain += list(FONT_CANDIDATES.get(weight) or [])
|
|
330
|
+
chain += list(FONT_CANDIDATES["reg"])
|
|
331
|
+
for path in chain:
|
|
104
332
|
if path and os.path.exists(path):
|
|
105
333
|
try:
|
|
106
334
|
f = ImageFont.truetype(path, int(size), index=0)
|
|
@@ -257,24 +485,25 @@ _wrap_cache = {}
|
|
|
257
485
|
_tw_cache = {}
|
|
258
486
|
|
|
259
487
|
|
|
260
|
-
def wrap_cn_cached(s: str, size: int, max_w: float, weight: str = "reg"
|
|
488
|
+
def wrap_cn_cached(s: str, size: int, max_w: float, weight: str = "reg",
|
|
489
|
+
family: str = "sans"):
|
|
261
490
|
"""带缓存的换行。场景函数每帧都要调用,务必用这个版本。
|
|
262
491
|
|
|
263
492
|
实测:每帧重新排版(12 张卡片 x 2 个 wrap_cn)会让单帧从 4.6ms 涨到 12ms。
|
|
264
493
|
"""
|
|
265
|
-
key = (s, int(size), int(max_w), weight)
|
|
494
|
+
key = (s, int(size), int(max_w), weight, str(family or "sans"))
|
|
266
495
|
r = _wrap_cache.get(key)
|
|
267
496
|
if r is None:
|
|
268
|
-
r = wrap_cn(s, font(size, weight), max_w)
|
|
497
|
+
r = wrap_cn(s, font(size, weight, family), max_w)
|
|
269
498
|
_wrap_cache[key] = r
|
|
270
499
|
return r
|
|
271
500
|
|
|
272
501
|
|
|
273
|
-
def text_w_cached(s: str, size: int, weight: str = "reg") -> float:
|
|
274
|
-
key = (s, int(size), weight)
|
|
502
|
+
def text_w_cached(s: str, size: int, weight: str = "reg", family: str = "sans") -> float:
|
|
503
|
+
key = (s, int(size), weight, str(family or "sans"))
|
|
275
504
|
r = _tw_cache.get(key)
|
|
276
505
|
if r is None:
|
|
277
|
-
r = text_w(s, font(size, weight))
|
|
506
|
+
r = text_w(s, font(size, weight, family))
|
|
278
507
|
_tw_cache[key] = r
|
|
279
508
|
return r
|
|
280
509
|
|
|
@@ -306,23 +535,29 @@ def check_bbox(box, safe=SAFE, tag=""):
|
|
|
306
535
|
return []
|
|
307
536
|
|
|
308
537
|
|
|
309
|
-
def fit_sub_lines(text: str, max_lines=2, max_w=1560, size=46, weight="reg"
|
|
538
|
+
def fit_sub_lines(text: str, max_lines=2, max_w=1560, size=46, weight="reg",
|
|
539
|
+
family="sans"):
|
|
310
540
|
"""字幕折行:优先两行,超宽逐级缩字号。返回 (lines, font)。"""
|
|
311
541
|
for sz in (size, int(size * 0.92), int(size * 0.84), int(size * 0.76)):
|
|
312
|
-
f = font(sz, weight)
|
|
542
|
+
f = font(sz, weight, family)
|
|
313
543
|
lines = wrap_cn(text, f, max_w)
|
|
314
544
|
if len(lines) <= max_lines:
|
|
315
545
|
return lines, f
|
|
316
|
-
f = font(int(size * 0.76), weight)
|
|
546
|
+
f = font(int(size * 0.76), weight, family)
|
|
317
547
|
return wrap_cn(text, f, max_w), f
|
|
318
548
|
|
|
319
549
|
|
|
320
|
-
def draw_subtitle(img, text: str, alpha=1.0, size=46, y=None, band_w=None
|
|
550
|
+
def draw_subtitle(img, text: str, alpha=1.0, size=46, y=None, band_w=None,
|
|
551
|
+
theme=None, family=None):
|
|
552
|
+
"""烧字幕。v0.5.0 加 theme(stylekit 主题)与 family(字体族);
|
|
553
|
+
theme=None 时与 v0.4 逐像素一致。"""
|
|
321
554
|
if not text or alpha <= 0.01:
|
|
322
555
|
return img
|
|
323
556
|
if band_w is None:
|
|
324
557
|
band_w = SUB_BAND[2] - SUB_BAND[0]
|
|
325
|
-
|
|
558
|
+
th = theme if isinstance(theme, dict) else None
|
|
559
|
+
fam = family or (th or {}).get("family") or "sans"
|
|
560
|
+
lines, f = fit_sub_lines(text, max_w=band_w, size=size, family=fam)
|
|
326
561
|
lines = lines[:2] # 硬约束:最多两行
|
|
327
562
|
lh = int(f.size * 1.32)
|
|
328
563
|
cx = (SUB_BAND[0] + SUB_BAND[2]) // 2
|
|
@@ -335,10 +570,17 @@ def draw_subtitle(img, text: str, alpha=1.0, size=46, y=None, band_w=None):
|
|
|
335
570
|
bx1 = cx + widest / 2 + 34
|
|
336
571
|
by0 = base_y - 16
|
|
337
572
|
by1 = base_y + total_h + 16
|
|
338
|
-
|
|
573
|
+
if th is None:
|
|
574
|
+
plate = (0, 0, 0, int(150 * alpha))
|
|
575
|
+
ink = (245, 248, 252, int(255 * alpha))
|
|
576
|
+
else:
|
|
577
|
+
# 浅色主题不能"黑底白字":背板取 bg2 半透,字取 ink
|
|
578
|
+
plate = tuple(th["bg2"]) + (int(170 * alpha),)
|
|
579
|
+
ink = tuple(th["ink"]) + (int(255 * alpha),)
|
|
580
|
+
d.rounded_rectangle([bx0, by0, bx1, by1], radius=10, fill=plate)
|
|
339
581
|
for i, ln in enumerate(lines):
|
|
340
582
|
lw = text_w(ln, f)
|
|
341
|
-
d.text((cx - lw / 2, base_y + i * lh), ln, font=f, fill=
|
|
583
|
+
d.text((cx - lw / 2, base_y + i * lh), ln, font=f, fill=ink)
|
|
342
584
|
return img
|
|
343
585
|
|
|
344
586
|
|
|
@@ -848,7 +1090,9 @@ def music_plan(script, has_music_file: bool = False):
|
|
|
848
1090
|
"""
|
|
849
1091
|
mus = script.get("music") or {}
|
|
850
1092
|
raw = str(mus.get("mode") or "").strip().lower()
|
|
851
|
-
bpm
|
|
1093
|
+
# v0.5.0:bpm 可以是 "auto"(由 stylekit 按风格梯度和内容指纹决定)。
|
|
1094
|
+
# 这里只判断"有没有 bpm 这个概念",具体数值由 st_score 解析后传入 scorekit。
|
|
1095
|
+
bpm = mus.get("bpm", script.get("bpm"))
|
|
852
1096
|
if raw == "auto":
|
|
853
1097
|
raw = ""
|
|
854
1098
|
if raw in ("off", "none", "mute"):
|
|
@@ -879,6 +1123,82 @@ def music_plan(script, has_music_file: bool = False):
|
|
|
879
1123
|
return "none", "缺省配乐:没有 bpm / music.chords / work/music.wav,本片无配乐"
|
|
880
1124
|
|
|
881
1125
|
|
|
1126
|
+
# ---------------------------------------------------------------- 风格 / 主题(v0.5.0)
|
|
1127
|
+
def _stylekit():
|
|
1128
|
+
"""惰性 import stylekit(同目录;缺了就明确报错,而不是静默回落到单一样式)。"""
|
|
1129
|
+
try:
|
|
1130
|
+
import stylekit as ST
|
|
1131
|
+
return ST
|
|
1132
|
+
except Exception as e: # noqa: BLE001
|
|
1133
|
+
raise RuntimeError("找不到 stylekit.py(v0.5.0 起 scenes/render 都依赖它,"
|
|
1134
|
+
"请与 filmkit.py 放在同一目录):%s" % e)
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def resolve_theme(script, scene=None):
|
|
1138
|
+
"""给场景取主题字典。
|
|
1139
|
+
|
|
1140
|
+
优先级:scene["theme"](同明暗族才允许) > script 里解析出的风格主题。
|
|
1141
|
+
stylekit 不在时退回内置 tech-deep(只为了让老工作目录少一个文件也能跑)。
|
|
1142
|
+
"""
|
|
1143
|
+
try:
|
|
1144
|
+
ST = _stylekit()
|
|
1145
|
+
plan = script.get("_style")
|
|
1146
|
+
if not isinstance(plan, dict):
|
|
1147
|
+
plan = ST.resolve_script_style(script)
|
|
1148
|
+
th = dict(ST.THEMES[plan["theme_id"]])
|
|
1149
|
+
sid = (scene or {}).get("theme")
|
|
1150
|
+
if sid and sid in ST.THEMES:
|
|
1151
|
+
alt = ST.THEMES[sid]
|
|
1152
|
+
if ST.family_compatible(th, alt):
|
|
1153
|
+
th = dict(alt)
|
|
1154
|
+
return th
|
|
1155
|
+
except Exception: # noqa: BLE001
|
|
1156
|
+
return _FALLBACK_THEME
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def style_plan(script):
|
|
1160
|
+
"""解析剧本的风格计划(含 legacy 判定)。stylekit 缺失时返回 legacy 计划。"""
|
|
1161
|
+
try:
|
|
1162
|
+
ST = _stylekit()
|
|
1163
|
+
plan = script.get("_style")
|
|
1164
|
+
return plan if isinstance(plan, dict) else ST.resolve_script_style(script)
|
|
1165
|
+
except Exception: # noqa: BLE001
|
|
1166
|
+
return {"legacy": True, "style": "legacy", "theme_id": "tech-deep",
|
|
1167
|
+
"music": {"style": "legacy", "bpm": script.get("bpm") or 120.0,
|
|
1168
|
+
"key": (script.get("music") or {}).get("key") or "Dm",
|
|
1169
|
+
"mood": "steady",
|
|
1170
|
+
"seed_effective": int((script.get("music") or {}).get("seed", 7) or 7)},
|
|
1171
|
+
"voice": {"map": {}, "policy": {"auto": False, "jitter": 0.0, "max_distinct": 2}},
|
|
1172
|
+
"pacing": {"lead": float(script.get("lead", 0.62)),
|
|
1173
|
+
"tail": float(script.get("tail", 0.92)),
|
|
1174
|
+
"min_dur": float(script.get("min_dur", 3.2)),
|
|
1175
|
+
"hold": float(script.get("hold", 0.0)),
|
|
1176
|
+
"item_stagger": 0.32}}
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
# v0.4 的深蓝橙,作为"没装 stylekit 也能出片"的兜底(与 assets/scenes.py 的老 THEME 同值)
|
|
1180
|
+
_FALLBACK_THEME = {
|
|
1181
|
+
"id": "tech-deep", "label": "科技深蓝", "keywords": (),
|
|
1182
|
+
"bg": (7, 14, 26), "bg2": (17, 33, 54), "surface": (26, 42, 64),
|
|
1183
|
+
"ink": (240, 245, 252), "muted": (150, 174, 200), "dim": (120, 142, 168),
|
|
1184
|
+
"accent": (243, 132, 60), "accent2": (86, 170, 232),
|
|
1185
|
+
"good": (108, 178, 110), "bad": (226, 96, 92), "rule": (255, 255, 255),
|
|
1186
|
+
"vignette": 0.40, "vignette_tint": (0, 0, 0), "grain": 5.0, "grain_freq": 0.31,
|
|
1187
|
+
"texture": "dots", "texture_alpha": 0.045,
|
|
1188
|
+
"family": "sans",
|
|
1189
|
+
"scale": {"display": (96, "bold"), "heading": (64, "bold"), "sub": (38, "light"),
|
|
1190
|
+
"body": (34, "reg"), "kicker": (28, "bold"), "chip": (27, "reg"),
|
|
1191
|
+
"brand": (23, "reg")},
|
|
1192
|
+
"line_gap": 1.30, "tracking": 0.0,
|
|
1193
|
+
"layout": "left-stack",
|
|
1194
|
+
"card": {"radius": 14, "fill_a": 16, "outline_a": 28, "pad": 34, "h": 96, "gap": 118},
|
|
1195
|
+
"safe_bias": 0,
|
|
1196
|
+
"ease": "out3", "entrance_dx": 0, "entrance_dy": 26, "push": 0.04,
|
|
1197
|
+
"item_stagger": 0.32, "transition": "black", "hud": "chip-left",
|
|
1198
|
+
"qa": {"luma_max": 0.030, "flicker_thresh": 6.0, "dark": True},
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
|
|
882
1202
|
# ---------------------------------------------------------------- 程序化音源
|
|
883
1203
|
def tone(freq, dur, sr=SR, amp=0.2, kind="sine", decay=2.2, harmonics=(1.0,)):
|
|
884
1204
|
"""单音衰减。kind 参数保留以兼容 v0.1(目前只实现 sine)。"""
|
|
@@ -934,16 +1254,32 @@ def whoosh(dur=0.45, sr=SR, amp=0.06, seed=3):
|
|
|
934
1254
|
|
|
935
1255
|
# ---------------------------------------------------------------- TTS
|
|
936
1256
|
def tts_key(text, voice, rate, pitch) -> str:
|
|
1257
|
+
"""v1 缓存键:4 参签名保持不变(selfcheck 与用户脚本都在用)。"""
|
|
937
1258
|
h = hashlib.sha1(("%s|%s|%s|%s" % (text, voice, rate, pitch)).encode("utf-8"))
|
|
938
1259
|
return h.hexdigest()[:16]
|
|
939
1260
|
|
|
940
1261
|
|
|
1262
|
+
def tts_key_v2(text, voice, rate, pitch, volume="+0%", trim=True, trim_db=-42.0,
|
|
1263
|
+
keep_ms=None) -> str:
|
|
1264
|
+
"""v2 缓存键:把 volume / trim 开关也纳入。
|
|
1265
|
+
|
|
1266
|
+
为什么必须升 v2(v0.4 的真 bug):trim 不进键,用户把 trim_silence 改成 false 之后,
|
|
1267
|
+
老 meta 里 trimmed=True 仍会命中第一条分支,返回**裁剪版**——"关了裁剪其实没关"。
|
|
1268
|
+
"""
|
|
1269
|
+
payload = "%s|%s|%s|%s|%s|%s|%s|%s" % ("v2", text, voice, rate, pitch,
|
|
1270
|
+
volume, int(bool(trim)), round(float(trim_db), 3))
|
|
1271
|
+
return hashlib.sha1(payload.encode("utf-8")).hexdigest()[:16]
|
|
1272
|
+
|
|
1273
|
+
|
|
941
1274
|
def tts(text, out_mp3, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
|
|
942
|
-
cache=True, retries=4, trim=True, trim_db=-42.0
|
|
1275
|
+
cache=True, retries=4, trim=True, trim_db=-42.0, volume="+0%",
|
|
1276
|
+
persona=None, tone=None):
|
|
943
1277
|
"""逐句合成旁白(带输入校验的缓存 + 网络重试 + 可选尾静音裁剪)。
|
|
944
1278
|
|
|
945
1279
|
v0.1 的缓存只判断文件是否存在,改一句台词会静默复用旧音频;
|
|
946
1280
|
v0.2 起把 text/voice/rate/pitch 的哈希写进 meta,变了就重新合成。
|
|
1281
|
+
v0.5.0:加 volume(edge-tts 原生支持,之前一直没传)+ persona/tone 审计字段;
|
|
1282
|
+
缓存键升 v2(把 volume/trim 也纳入,修掉"关了裁剪其实没关"的静默 bug)。
|
|
947
1283
|
trim=True 时另外落一份裁剪后的 wav 并在 meta.path 指向它,dur 为裁剪后实测时长。
|
|
948
1284
|
"""
|
|
949
1285
|
out_mp3 = str(out_mp3)
|
|
@@ -951,16 +1287,26 @@ def tts(text, out_mp3, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
|
|
|
951
1287
|
if d:
|
|
952
1288
|
os.makedirs(d, exist_ok=True)
|
|
953
1289
|
meta_path = out_mp3 + ".json"
|
|
954
|
-
|
|
1290
|
+
vol = str(volume or "+0%")
|
|
1291
|
+
key = tts_key_v2(text, voice, rate, pitch, volume=vol, trim=trim, trim_db=trim_db)
|
|
1292
|
+
legacy_key = tts_key(text, voice, rate, pitch)
|
|
955
1293
|
|
|
956
1294
|
if cache and os.path.exists(out_mp3) and os.path.getsize(out_mp3) > 2000:
|
|
957
1295
|
try:
|
|
958
1296
|
m = json.load(open(meta_path, encoding="utf-8"))
|
|
1297
|
+
# v2 命中:key 已经含 volume/trim,不需要再分叉判断 trimmed
|
|
959
1298
|
if m.get("key") == key and m.get("text") == text:
|
|
960
1299
|
if m.get("trimmed") and m.get("path") and os.path.exists(m["path"]):
|
|
961
1300
|
return m
|
|
962
1301
|
if not m.get("trimmed") and not trim:
|
|
963
1302
|
return m
|
|
1303
|
+
# v1 老缓存(升级前合成的)只有在参数完全等于 v1 语义时才复用,
|
|
1304
|
+
# 避免"改了一个 volume 却拿到老音频"这种升级期的静默错配。
|
|
1305
|
+
elif (legacy_key == tts_key(text, voice, rate, pitch)
|
|
1306
|
+
and str(m.get("key") or "") == legacy_key and m.get("text") == text
|
|
1307
|
+
and vol in ("+0%", "") and m.get("voice") == voice):
|
|
1308
|
+
if m.get("trimmed") and m.get("path") and os.path.exists(m["path"]):
|
|
1309
|
+
return m
|
|
964
1310
|
except Exception:
|
|
965
1311
|
pass
|
|
966
1312
|
|
|
@@ -972,7 +1318,7 @@ def tts(text, out_mp3, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
|
|
|
972
1318
|
import edge_tts
|
|
973
1319
|
|
|
974
1320
|
async def go():
|
|
975
|
-
c = edge_tts.Communicate(text, voice, rate=rate, pitch=pitch)
|
|
1321
|
+
c = edge_tts.Communicate(text, voice, rate=rate, pitch=pitch, volume=vol)
|
|
976
1322
|
await c.save(out_mp3)
|
|
977
1323
|
|
|
978
1324
|
asyncio.run(go())
|
|
@@ -995,23 +1341,42 @@ def tts(text, out_mp3, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
|
|
|
995
1341
|
dur = len(pcm) / float(SR)
|
|
996
1342
|
|
|
997
1343
|
m = dict(path=used, mp3=out_mp3, text=text, voice=voice, rate=rate, pitch=pitch,
|
|
998
|
-
key=key, dur=dur, dur_raw=dur_raw,
|
|
1344
|
+
volume=vol, key=key, spec_version=2, dur=dur, dur_raw=dur_raw,
|
|
1345
|
+
trimmed=bool(trim), trim_db=float(trim_db))
|
|
1346
|
+
if persona:
|
|
1347
|
+
m["persona"] = str(persona)
|
|
1348
|
+
if tone:
|
|
1349
|
+
m["tone"] = str(tone)
|
|
999
1350
|
json.dump(m, open(meta_path, "w", encoding="utf-8"), ensure_ascii=False)
|
|
1000
1351
|
return m
|
|
1001
1352
|
|
|
1002
1353
|
|
|
1003
1354
|
def tts_batch(lines, workdir, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
|
|
1004
|
-
workers=4, trim=True):
|
|
1005
|
-
"""并发逐句合成(并发只影响墙钟,不影响产物:每句独立缓存)。
|
|
1355
|
+
workers=4, trim=True, plan=None, trim_db=-42.0):
|
|
1356
|
+
"""并发逐句合成(并发只影响墙钟,不影响产物:每句独立缓存)。
|
|
1357
|
+
|
|
1358
|
+
v0.5.0:plan=[{voice,rate,pitch,volume,persona,tone}, ...](与 lines 等长)时
|
|
1359
|
+
逐句用各自的声线/语气;plan=None 时行为与 v0.4 完全一致。
|
|
1360
|
+
"""
|
|
1006
1361
|
from concurrent.futures import ThreadPoolExecutor
|
|
1007
1362
|
|
|
1008
1363
|
tts_dir = os.path.join(workdir, "tts")
|
|
1009
1364
|
os.makedirs(tts_dir, exist_ok=True)
|
|
1010
1365
|
results = [None] * len(lines)
|
|
1366
|
+
specs = list(plan) if plan is not None else [None] * len(lines)
|
|
1367
|
+
if len(specs) != len(lines):
|
|
1368
|
+
raise ValueError("plan 长度 %d 与 lines 长度 %d 不一致" % (len(specs), len(lines)))
|
|
1011
1369
|
|
|
1012
1370
|
def one(i, ln):
|
|
1371
|
+
spec = specs[i] or {}
|
|
1013
1372
|
mp3 = os.path.join(tts_dir, str(ln["id"]) + ".mp3")
|
|
1014
|
-
m = tts(ln["text"], mp3,
|
|
1373
|
+
m = tts(ln["text"], mp3,
|
|
1374
|
+
voice=spec.get("voice") or voice,
|
|
1375
|
+
rate=spec.get("rate") or rate,
|
|
1376
|
+
pitch=spec.get("pitch") or pitch,
|
|
1377
|
+
volume=spec.get("volume") or "+0%",
|
|
1378
|
+
trim=trim, trim_db=trim_db,
|
|
1379
|
+
persona=spec.get("persona"), tone=spec.get("tone"))
|
|
1015
1380
|
row = dict(ln)
|
|
1016
1381
|
row.update(m)
|
|
1017
1382
|
print(" tts %-10s %6.2fs %s" % (ln["id"], m["dur"], ln["text"][:34]), flush=True)
|
|
@@ -1027,7 +1392,7 @@ def tts_batch(lines, workdir, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0
|
|
|
1027
1392
|
|
|
1028
1393
|
# ---------------------------------------------------------------- 时间轴
|
|
1029
1394
|
def build_timeline(lines, lead=0.55, tail=0.85, min_dur=3.0, tail_map=None, max_dur=None,
|
|
1030
|
-
hold=0.0, hold_map=None, bar=0.0):
|
|
1395
|
+
hold=0.0, hold_map=None, bar=0.0, lead_map=None):
|
|
1031
1396
|
"""由实测语音时长反推时间轴。第一段 start 必须严格为 0。
|
|
1032
1397
|
|
|
1033
1398
|
hold / hold_map(v0.3.0):语音结束后额外停留的秒数——这就是 ai-history-film 的"停留窗口":
|
|
@@ -1039,14 +1404,18 @@ def build_timeline(lines, lead=0.55, tail=0.85, min_dur=3.0, tail_map=None, max_
|
|
|
1039
1404
|
for ln in lines:
|
|
1040
1405
|
voice = float(ln.get("dur") or 0.0)
|
|
1041
1406
|
extra = float(ln.get("hold", (hold_map or {}).get(ln["id"], hold)) or 0.0)
|
|
1042
|
-
|
|
1407
|
+
# v0.5.0:lead 也支持逐句(行内 lead > lead_map > 全局),
|
|
1408
|
+
# 与 tail_map 同构 —— 不同语气档的"句前呼吸"本来就不该一样长。
|
|
1409
|
+
lead_i = float(ln.get("lead", (lead_map or {}).get(ln["id"], lead)))
|
|
1410
|
+
tail_i = float((tail_map or {}).get(ln["id"], tail))
|
|
1411
|
+
d = max(float(min_dur), lead_i + voice + tail_i + extra)
|
|
1043
1412
|
if max_dur and d > float(max_dur):
|
|
1044
1413
|
d = float(max_dur)
|
|
1045
1414
|
if bar and float(bar) > 0:
|
|
1046
1415
|
d = math.ceil(d / float(bar) - 1e-9) * float(bar)
|
|
1047
1416
|
scenes.append(dict(ln, start=round(t, 3), end=round(t + d, 3), dur=round(d, 3),
|
|
1048
|
-
voice_start=round(t +
|
|
1049
|
-
voice_end=round(t +
|
|
1417
|
+
voice_start=round(t + lead_i, 3),
|
|
1418
|
+
voice_end=round(t + lead_i + voice, 3)))
|
|
1050
1419
|
t += d
|
|
1051
1420
|
if scenes:
|
|
1052
1421
|
scenes[0]["start"] = 0.0
|
|
@@ -1097,7 +1466,8 @@ def write_srt(scenes, path, max_hold=None):
|
|
|
1097
1466
|
return len(rows)
|
|
1098
1467
|
|
|
1099
1468
|
|
|
1100
|
-
def write_ass(scenes, path, max_hold=None, font_name="Microsoft YaHei", size=52
|
|
1469
|
+
def write_ass(scenes, path, max_hold=None, font_name="Microsoft YaHei", size=52,
|
|
1470
|
+
theme=None):
|
|
1101
1471
|
"""直接写 ASS,并在头里声明 PlayResX/Y=1920/1080。
|
|
1102
1472
|
|
|
1103
1473
|
这样交 libass 不会按 PlayResY=288 反推,避免"字号小得看不见"
|
|
@@ -1110,6 +1480,18 @@ def write_ass(scenes, path, max_hold=None, font_name="Microsoft YaHei", size=52)
|
|
|
1110
1480
|
s, cs = cs // 100, cs % 100
|
|
1111
1481
|
return "%d:%02d:%02d.%02d" % (h, m, s, cs)
|
|
1112
1482
|
|
|
1483
|
+
primary, outline, back = "&H00FCF8F5", "&H00101010", "&H96000000"
|
|
1484
|
+
if isinstance(theme, dict):
|
|
1485
|
+
# ASS 的颜色是 &HAABBGGRR(BGR 顺序 + alpha 在前,alpha 0=不透明)
|
|
1486
|
+
def _ass(rgb, alpha=0):
|
|
1487
|
+
r, g, b = rgb
|
|
1488
|
+
return "&H%02X%02X%02X%02X" % (int(alpha), int(b), int(g), int(r))
|
|
1489
|
+
primary = _ass(theme["ink"])
|
|
1490
|
+
outline = _ass(theme["bg2"])
|
|
1491
|
+
back = _ass(theme["surface"], 0x60)
|
|
1492
|
+
fam = str(theme.get("family") or "sans")
|
|
1493
|
+
# 字体族 -> Windows 上的实际字体名(libass 认 name 不认路径)
|
|
1494
|
+
font_name = {"serif": "SimSun", "kai": "KaiTi", "round": "DengXian"}.get(fam, "Microsoft YaHei")
|
|
1113
1495
|
head = [
|
|
1114
1496
|
"[Script Info]",
|
|
1115
1497
|
"ScriptType: v4.00+",
|
|
@@ -1122,8 +1504,8 @@ def write_ass(scenes, path, max_hold=None, font_name="Microsoft YaHei", size=52)
|
|
|
1122
1504
|
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, "
|
|
1123
1505
|
"BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, "
|
|
1124
1506
|
"BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
|
|
1125
|
-
"Style: Default,%s,%d,&
|
|
1126
|
-
"1,3,1,2,60,60,56,1" % (font_name, int(size)),
|
|
1507
|
+
"Style: Default,%s,%d,%s,&H000000FF,%s,%s,-1,0,0,0,100,100,0,0,"
|
|
1508
|
+
"1,3,1,2,60,60,56,1" % (font_name, int(size), primary, outline, back),
|
|
1127
1509
|
"",
|
|
1128
1510
|
"[Events]",
|
|
1129
1511
|
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
|
|
@@ -1427,8 +1809,224 @@ def contact_sheet(frames, out_png, cols=4, thumb_w=480, labels=None):
|
|
|
1427
1809
|
return out_png
|
|
1428
1810
|
|
|
1429
1811
|
|
|
1812
|
+
# ---------------------------------------------------------------- 拼接
|
|
1813
|
+
# 事故背景:混 fps 的片段以前直接 -c copy,拼出半速成片还不报错。
|
|
1814
|
+
# 现在拼前先查参数一致性;不一致或 copy 失败都自动降级重编码,只有重编码也失败才 raise。
|
|
1815
|
+
_LAST_CONCAT = {}
|
|
1816
|
+
|
|
1817
|
+
|
|
1818
|
+
def concat_last_state():
|
|
1819
|
+
"""最近一次 concat_segments 的结果(mode/reencoded/issues/out/concat_txt)。
|
|
1820
|
+
|
|
1821
|
+
返回副本;没跑过返回 {}。给测试和 backend 回显判断"这次到底有没有重编码"用,
|
|
1822
|
+
不改变 concat_segments 的返回值(仍然是 str(out))。
|
|
1823
|
+
"""
|
|
1824
|
+
return dict(_LAST_CONCAT)
|
|
1825
|
+
|
|
1826
|
+
|
|
1827
|
+
def _set_concat_state(**kw):
|
|
1828
|
+
_LAST_CONCAT.clear()
|
|
1829
|
+
_LAST_CONCAT.update(kw)
|
|
1830
|
+
|
|
1831
|
+
|
|
1832
|
+
def _stderr_brief(raw, limit=300):
|
|
1833
|
+
"""ffmpeg stderr 摘要:真正的错误通常在一堆行里的最后几条,取末尾三行拼成一句。"""
|
|
1834
|
+
txt = (raw or b"").decode("utf-8", "replace").strip()
|
|
1835
|
+
lines = [ln.strip() for ln in txt.splitlines() if ln.strip()]
|
|
1836
|
+
if not lines:
|
|
1837
|
+
return "(ffmpeg 没有输出错误信息)"
|
|
1838
|
+
return " | ".join(lines[-3:])[:limit]
|
|
1839
|
+
|
|
1840
|
+
|
|
1841
|
+
def _concat_quote(path):
|
|
1842
|
+
"""生成 ffmpeg concat 清单的一行:绝对路径 + 单引号转义。
|
|
1843
|
+
|
|
1844
|
+
concat demuxer 的引号规则照抄 shell:单引号内不能再出现单引号,
|
|
1845
|
+
标准写法是把 ' 变成 '\''(先闭合、转义一个引号、再打开)。
|
|
1846
|
+
路径里真的含单引号(Windows 文件名允许)也不会坏。
|
|
1847
|
+
"""
|
|
1848
|
+
p = os.path.abspath(str(path)).replace("\\", "/")
|
|
1849
|
+
return "file '%s'\n" % p.replace("'", "'\\''")
|
|
1850
|
+
|
|
1851
|
+
|
|
1852
|
+
def _probe_concat_part(path):
|
|
1853
|
+
"""单段探测(纯读):返回 dict,ok=False 时 why 说明原因。"""
|
|
1854
|
+
p = str(path)
|
|
1855
|
+
if not os.path.isfile(p):
|
|
1856
|
+
return dict(ok=False, path=p, why="文件不存在")
|
|
1857
|
+
pr = probe(p)
|
|
1858
|
+
streams = pr.get("streams") or []
|
|
1859
|
+
v = next((s for s in streams if s.get("codec_type") == "video"), None)
|
|
1860
|
+
if v is None:
|
|
1861
|
+
return dict(ok=False, path=p, why="ffprobe 读不出视频流(文件损坏或不是视频)")
|
|
1862
|
+
rate = str(v.get("r_frame_rate") or "0/0")
|
|
1863
|
+
fps = 0.0
|
|
1864
|
+
try:
|
|
1865
|
+
num, _, den = rate.partition("/")
|
|
1866
|
+
fps = float(num) / float(den or 1)
|
|
1867
|
+
except Exception: # noqa: BLE001 —— 脏帧率不崩,交给诊断
|
|
1868
|
+
fps = 0.0
|
|
1869
|
+
dur = 0.0
|
|
1870
|
+
try:
|
|
1871
|
+
dur = float((pr.get("format") or {}).get("duration") or 0)
|
|
1872
|
+
except Exception: # noqa: BLE001
|
|
1873
|
+
dur = 0.0
|
|
1874
|
+
a = next((s for s in streams if s.get("codec_type") == "audio"), None)
|
|
1875
|
+
return dict(ok=True, path=p, width=int(v.get("width") or 0),
|
|
1876
|
+
height=int(v.get("height") or 0), fps=round(fps, 3),
|
|
1877
|
+
fps_text=rate, codec=str(v.get("codec_name") or ""),
|
|
1878
|
+
audio=bool(a), dur=dur)
|
|
1879
|
+
|
|
1880
|
+
|
|
1881
|
+
def _fmt_fps(info):
|
|
1882
|
+
"""帧率说人话:30/1 -> 30;30000/1001 -> 29.97;读不出来 -> 原始文本。"""
|
|
1883
|
+
try:
|
|
1884
|
+
fps = float(info.get("fps") or 0.0)
|
|
1885
|
+
except Exception: # noqa: BLE001
|
|
1886
|
+
fps = 0.0
|
|
1887
|
+
if fps <= 0:
|
|
1888
|
+
return str(info.get("fps_text") or "未知")
|
|
1889
|
+
if abs(fps - round(fps)) < 0.01:
|
|
1890
|
+
return "%d" % int(round(fps))
|
|
1891
|
+
return "%.2f" % fps
|
|
1892
|
+
|
|
1893
|
+
|
|
1894
|
+
def _concat_issues(parts):
|
|
1895
|
+
"""逐段探测并与参照段比对(纯读)。返回 (infos, issues, ref_index)。
|
|
1896
|
+
|
|
1897
|
+
参照段 = 第一个能读出来的段(正常就是第 1 段);每条 issue 都写清"哪段、差在哪"。
|
|
1898
|
+
"""
|
|
1899
|
+
infos = [_probe_concat_part(p) for p in parts]
|
|
1900
|
+
issues = []
|
|
1901
|
+
ref = None
|
|
1902
|
+
for i, inf in enumerate(infos):
|
|
1903
|
+
if not inf.get("ok"):
|
|
1904
|
+
issues.append("第 %d 段%s:%s" % (i + 1, inf.get("why", "探测失败"),
|
|
1905
|
+
inf.get("path", parts[i])))
|
|
1906
|
+
continue
|
|
1907
|
+
if ref is None:
|
|
1908
|
+
ref = i
|
|
1909
|
+
continue
|
|
1910
|
+
r = infos[ref]
|
|
1911
|
+
who, base = "第 %d 段" % (i + 1), "第 %d 段" % (ref + 1)
|
|
1912
|
+
if (inf["width"], inf["height"]) != (r["width"], r["height"]):
|
|
1913
|
+
issues.append("%s分辨率 %dx%d != %s %dx%d"
|
|
1914
|
+
% (who, inf["width"], inf["height"], base, r["width"], r["height"]))
|
|
1915
|
+
if abs(float(inf["fps"]) - float(r["fps"])) > 0.01:
|
|
1916
|
+
issues.append("%s fps %s != %s fps %s"
|
|
1917
|
+
% (who, _fmt_fps(inf), base, _fmt_fps(r)))
|
|
1918
|
+
if inf["codec"] != r["codec"]:
|
|
1919
|
+
issues.append("%s视频编码 %s != %s %s"
|
|
1920
|
+
% (who, inf["codec"] or "?", base, r["codec"] or "?"))
|
|
1921
|
+
if bool(inf["audio"]) != bool(r["audio"]):
|
|
1922
|
+
issues.append("%s%s音轨,%s%s音轨"
|
|
1923
|
+
% (who, "有" if inf["audio"] else "无",
|
|
1924
|
+
base, "有" if r["audio"] else "无"))
|
|
1925
|
+
return infos, issues, ref
|
|
1926
|
+
|
|
1927
|
+
|
|
1928
|
+
def concat_preflight(parts):
|
|
1929
|
+
"""拼接前的参数一致性检查(纯读:不写文件、不改全局状态、不碰 out)。
|
|
1930
|
+
|
|
1931
|
+
逐段 ffprobe 比对 width / height / r_frame_rate / codec_name(外加有无音轨),
|
|
1932
|
+
以第一个能读出来的段为参照。返回 (ok, issues):
|
|
1933
|
+
ok=False 表示不能直接 -c copy,调用方应降级重编码;issues 是可直接打印的中文诊断。
|
|
1934
|
+
注意:这里只回答"能不能 copy",不检查画面内容 / 时间轴是否正确。
|
|
1935
|
+
"""
|
|
1936
|
+
parts = [str(p) for p in parts]
|
|
1937
|
+
if not parts:
|
|
1938
|
+
return False, ["parts 为空(没有可拼接的片段)"]
|
|
1939
|
+
_infos, issues, _ref = _concat_issues(parts)
|
|
1940
|
+
return (not issues), issues
|
|
1941
|
+
|
|
1942
|
+
|
|
1943
|
+
def _concat_reencode(parts, out, infos, ref, lst):
|
|
1944
|
+
"""降级路径:重编码后再拼(libx264 crf20 + aac 192k),返回 str(out)。
|
|
1945
|
+
|
|
1946
|
+
不用"concat demuxer + 重编码":那仍然要求各段分辨率/像素格式一致,混分辨率时
|
|
1947
|
+
会花屏或直接失败。这里用 concat 滤镜,逐段把画面统一到参照段的宽高/帧率
|
|
1948
|
+
(等比缩放 + 补边,不拉伸),音轨统一 48kHz 立体声;某段没音轨就补等长静音,
|
|
1949
|
+
避免 v=1:a=1 的 concat 滤镜拒收。只有所有段都没音轨时才输出纯视频。
|
|
1950
|
+
"""
|
|
1951
|
+
if ref is None:
|
|
1952
|
+
raise RuntimeError(
|
|
1953
|
+
"[concat] 降级失败:%d 段全都读不出视频流。先逐段跑 ffprobe -show_streams "
|
|
1954
|
+
"确认文件没坏;concat.txt 保留在:%s" % (len(parts), lst))
|
|
1955
|
+
r = infos[ref]
|
|
1956
|
+
tw, th = int(r.get("width") or 0), int(r.get("height") or 0)
|
|
1957
|
+
if tw <= 0 or th <= 0:
|
|
1958
|
+
tw, th = W, H
|
|
1959
|
+
# yuv420p 要求偶数宽高(参照段是奇数尺寸时裁掉 1px,别让重编码挂在最后一步)
|
|
1960
|
+
tw, th = max(2, tw - tw % 2), max(2, th - th % 2)
|
|
1961
|
+
fps_arg = str(r.get("fps_text") or "")
|
|
1962
|
+
if fps_arg in ("", "0/0"):
|
|
1963
|
+
fps_arg = "%g/1" % (float(r.get("fps") or 0) or float(FPS))
|
|
1964
|
+
any_audio = any(inf.get("ok") and inf.get("audio") for inf in infos)
|
|
1965
|
+
ref_dur = max(float(r.get("dur") or 0), 0.0)
|
|
1966
|
+
fc = []
|
|
1967
|
+
for i, inf in enumerate(infos):
|
|
1968
|
+
fc.append("[%d:v]fps=%s,scale=%d:%d:force_original_aspect_ratio=decrease,"
|
|
1969
|
+
"pad=%d:%d:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p[v%d]"
|
|
1970
|
+
% (i, fps_arg, tw, th, tw, th, i))
|
|
1971
|
+
if not any_audio:
|
|
1972
|
+
continue
|
|
1973
|
+
if inf.get("ok") and inf.get("audio"):
|
|
1974
|
+
fc.append("[%d:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo[a%d]"
|
|
1975
|
+
% (i, i))
|
|
1976
|
+
else:
|
|
1977
|
+
dur = max(float(inf.get("dur") or 0), ref_dur, 0.1)
|
|
1978
|
+
fc.append("anullsrc=r=48000:cl=stereo,atrim=duration=%.3f,asetpts=PTS-STARTPTS[a%d]"
|
|
1979
|
+
% (dur, i))
|
|
1980
|
+
labels = "".join("[v%d]%s" % (i, "[a%d]" % i if any_audio else "")
|
|
1981
|
+
for i in range(len(parts)))
|
|
1982
|
+
fc.append("%sconcat=n=%d:v=1:a=%d[v]%s"
|
|
1983
|
+
% (labels, len(parts), 1 if any_audio else 0, "[a]" if any_audio else ""))
|
|
1984
|
+
fc_text = ";".join(fc)
|
|
1985
|
+
|
|
1986
|
+
cmd = [FFMPEG, "-y", "-v", "error"]
|
|
1987
|
+
for src in parts:
|
|
1988
|
+
cmd += ["-i", str(src)]
|
|
1989
|
+
script_path = None
|
|
1990
|
+
try:
|
|
1991
|
+
if len(fc_text) > 8000:
|
|
1992
|
+
# 段数多时滤镜串会撞 Windows 命令行长度上限:落成临时脚本再引用
|
|
1993
|
+
fd, script_path = tempfile.mkstemp(prefix="filmkit_concat_", suffix=".txt")
|
|
1994
|
+
os.close(fd)
|
|
1995
|
+
with open(script_path, "w", encoding="utf-8") as fh:
|
|
1996
|
+
fh.write(fc_text)
|
|
1997
|
+
cmd += ["-filter_complex_script", script_path]
|
|
1998
|
+
else:
|
|
1999
|
+
cmd += ["-filter_complex", fc_text]
|
|
2000
|
+
cmd += ["-map", "[v]"]
|
|
2001
|
+
if any_audio:
|
|
2002
|
+
cmd += ["-map", "[a]", "-c:a", "aac", "-b:a", "192k"]
|
|
2003
|
+
cmd += ["-c:v", "libx264", "-preset", "medium", "-crf", "20",
|
|
2004
|
+
"-pix_fmt", "yuv420p", "-movflags", "+faststart", str(out)]
|
|
2005
|
+
proc = _run(cmd)
|
|
2006
|
+
finally:
|
|
2007
|
+
if script_path:
|
|
2008
|
+
try:
|
|
2009
|
+
os.remove(script_path)
|
|
2010
|
+
except OSError:
|
|
2011
|
+
pass
|
|
2012
|
+
if proc.returncode != 0:
|
|
2013
|
+
raise RuntimeError(
|
|
2014
|
+
"[concat] 重编码拼接也失败:%d 段既不能 -c copy,concat 滤镜重编码也没成。\n"
|
|
2015
|
+
" 先逐段跑 ffprobe -show_streams 找坏段/缺流;concat.txt 保留在:%s\n"
|
|
2016
|
+
" ffmpeg: %s" % (len(parts), lst, _stderr_brief(proc.stderr)))
|
|
2017
|
+
return str(out)
|
|
2018
|
+
|
|
2019
|
+
|
|
1430
2020
|
def concat_segments(parts, out, workdir=None):
|
|
1431
|
-
"""
|
|
2021
|
+
"""分段拼接:参数一致走 -c copy(快、无损),不一致或 copy 失败自动降级重编码。
|
|
2022
|
+
|
|
2023
|
+
向后兼容:签名和返回值不变(返回 str(out))。降级发生时打印 [concat] 诊断,
|
|
2024
|
+
并把 mode/issues/reencoded 记到 concat_last_state(),供调用方和测试判断。
|
|
2025
|
+
"""
|
|
2026
|
+
parts = [str(p) for p in parts]
|
|
2027
|
+
if not parts:
|
|
2028
|
+
raise RuntimeError("concat_segments: parts 为空(没有可拼接的片段)")
|
|
2029
|
+
_LAST_CONCAT.clear() # 失败时别让上一次的 mode 误导调用方
|
|
1432
2030
|
lst = os.path.join(workdir or os.path.dirname(str(out)), "concat.txt")
|
|
1433
2031
|
d = os.path.dirname(os.path.abspath(lst))
|
|
1434
2032
|
if d:
|
|
@@ -1436,19 +2034,38 @@ def concat_segments(parts, out, workdir=None):
|
|
|
1436
2034
|
# 必须写绝对路径:concat 文件里的相对路径按 ffmpeg 的 cwd 解析,
|
|
1437
2035
|
# 而不是按 concat.txt 所在目录(实测会拼成 work2/work2/... 打不开)。
|
|
1438
2036
|
with open(lst, "w", encoding="utf-8") as f:
|
|
1439
|
-
for
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
2037
|
+
for src in parts:
|
|
2038
|
+
f.write(_concat_quote(src))
|
|
2039
|
+
infos, issues, ref = _concat_issues(parts)
|
|
2040
|
+
base_state = dict(out=str(out), parts=len(parts), issues=list(issues), concat_txt=lst)
|
|
2041
|
+
if not issues:
|
|
2042
|
+
proc = _run([FFMPEG, "-y", "-v", "error", "-f", "concat", "-safe", "0",
|
|
2043
|
+
"-i", lst, "-c", "copy", str(out)])
|
|
2044
|
+
if proc.returncode == 0:
|
|
2045
|
+
_set_concat_state(mode="copy", reencoded=False, **base_state)
|
|
2046
|
+
return str(out)
|
|
2047
|
+
detail = _stderr_brief(proc.stderr)
|
|
2048
|
+
print("[concat] -c copy 失败(%s),降级为重编码拼接(libx264 crf20 + aac 192k)"
|
|
2049
|
+
% detail, flush=True)
|
|
2050
|
+
base_state = dict(base_state, copy_error=detail)
|
|
2051
|
+
else:
|
|
2052
|
+
print("[concat] 拼接前检查发现 %d 处参数不一致:" % len(issues), flush=True)
|
|
2053
|
+
for it in issues:
|
|
2054
|
+
print(" [concat] " + it, flush=True)
|
|
2055
|
+
summ = ";".join(issues[:3])
|
|
2056
|
+
if len(issues) > 3:
|
|
2057
|
+
summ += ";等 %d 处" % len(issues)
|
|
2058
|
+
print("[concat] 参数不一致(%s),已降级为重编码(libx264 crf20 + aac 192k)"
|
|
2059
|
+
% summ, flush=True)
|
|
2060
|
+
print(" [concat] concat.txt 保留供排查:%s" % lst, flush=True)
|
|
2061
|
+
_concat_reencode(parts, out, infos, ref, lst)
|
|
2062
|
+
_set_concat_state(mode="reencode", reencoded=True, **base_state)
|
|
1446
2063
|
return str(out)
|
|
1447
2064
|
|
|
1448
2065
|
|
|
1449
2066
|
# ---------------------------------------------------------------- 总质检
|
|
1450
2067
|
def qa_report(path, expect_frames=None, ref_png=None, ref_sec=None, ref_img=None,
|
|
1451
|
-
expected_subtitles=None, min_bitrate=0, notes=None):
|
|
2068
|
+
expected_subtitles=None, min_bitrate=0, notes=None, theme=None):
|
|
1452
2069
|
"""分档质检:errors 必须修,warnings 看情况。
|
|
1453
2070
|
|
|
1454
2071
|
v0.2 修正:
|
|
@@ -1510,6 +2127,13 @@ def qa_report(path, expect_frames=None, ref_png=None, ref_sec=None, ref_img=None
|
|
|
1510
2127
|
warns.append("首尾之外检测到 %d 段长黑帧" % nb)
|
|
1511
2128
|
if detect_silence(path) > 0:
|
|
1512
2129
|
warns.append("检测到长静音段(确认是否有意留白)")
|
|
2130
|
+
if isinstance(theme, dict):
|
|
2131
|
+
# 主题体检的结论带进报告:这样"深色主题必然贴空窗阈值"这种事实不是靠嘴说
|
|
2132
|
+
lmax = float(theme.get("qa", {}).get("luma_max", 0.045))
|
|
2133
|
+
warns_t = ["主题 %s:空窗阈值 luma_max=%.3f、闪烁阈值 %.1f"
|
|
2134
|
+
% (theme.get("label") or theme.get("id"), lmax,
|
|
2135
|
+
float(theme.get("qa", {}).get("flicker_thresh", 6.0)))]
|
|
2136
|
+
warns.extend(warns_t)
|
|
1513
2137
|
quality = None
|
|
1514
2138
|
if ref_img is not None or (ref_png and os.path.exists(ref_png)):
|
|
1515
2139
|
q = encode_quality(path, ref_png, ref_sec, ref_img=ref_img)
|