@dsh-gis/dsh-video-studio-skill 0.9.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +424 -0
  2. package/README.md +287 -327
  3. package/THIRD_PARTY_NOTICES.md +1 -1
  4. package/package.json +9 -10
  5. package/schemas/design.schema.json +722 -0
  6. package/schemas/film.schema.json +4 -15
  7. package/schemas/proposal.schema.json +133 -0
  8. package/skills/video-studio/SKILL.md +1316 -1123
  9. package/skills/video-studio/assets/design_fixture.json +141 -0
  10. package/skills/video-studio/assets/film.json +1 -2
  11. package/skills/video-studio/assets/golden_frames.json +11 -8
  12. package/skills/video-studio/assets/scenes.py +67 -22
  13. package/skills/video-studio/assets/voice_loudness.json +16 -0
  14. package/skills/video-studio/references/audio-variation.md +15 -11
  15. package/skills/video-studio/references/audio.md +36 -34
  16. package/skills/video-studio/references/change-impact.md +3 -3
  17. package/skills/video-studio/references/frame-qa.md +2 -2
  18. package/skills/video-studio/references/proposal.md +177 -0
  19. package/skills/video-studio/references/quality-gates.md +1 -1
  20. package/skills/video-studio/references/scene-grammar.md +28 -23
  21. package/skills/video-studio/references/sfx-design.md +1 -1
  22. package/skills/video-studio/references/style-packs.md +95 -257
  23. package/skills/video-studio/references/v08-quality.md +8 -8
  24. package/skills/video-studio/references/web-engine.md +1 -1
  25. package/skills/video-studio/references/word-timeline.md +2 -2
  26. package/skills/video-studio/scripts/designkit.py +2120 -0
  27. package/skills/video-studio/scripts/filmcheck.py +7 -33
  28. package/skills/video-studio/scripts/filmkit.py +153 -78
  29. package/skills/video-studio/scripts/grammar.py +67 -7
  30. package/skills/video-studio/scripts/musicgen.py +2043 -0
  31. package/skills/video-studio/scripts/preflight.py +10 -3
  32. package/skills/video-studio/scripts/previewkit.py +849 -0
  33. package/skills/video-studio/scripts/propose.py +825 -0
  34. package/skills/video-studio/scripts/qa_frames.py +45 -10
  35. package/skills/video-studio/scripts/qavmaf.py +77 -11
  36. package/skills/video-studio/scripts/render.py +472 -154
  37. package/skills/video-studio/scripts/scorekit.py +50 -1748
  38. package/skills/video-studio/scripts/selfcheck.py +658 -131
  39. package/skills/video-studio/scripts/stagewatch.py +21 -5
  40. package/skills/video-studio/scripts/stylekit.py +265 -741
  41. package/skills/video-studio/scripts/voicekit.py +502 -380
  42. package/skills/video-studio/scripts/webengine.py +15 -14
  43. package/tools/audio_similarity.py +57 -30
  44. package/tools/ci_check.py +7 -1
  45. package/tools/estimate.py +14 -8
  46. package/tools/golden_frames.py +56 -11
  47. package/tools/silhouette_qa.py +521 -168
  48. package/docs/demo-pil-sheet.png +0 -0
  49. package/docs/styles/demo-results.json +0 -76
  50. package/docs/styles/sheet-8-styles.png +0 -0
  51. package/docs/styles/sheet-ai.png +0 -0
  52. package/docs/styles/sheet-before-after.png +0 -0
  53. package/docs/styles/sheet-craft.png +0 -0
  54. package/docs/styles/sheet-night.png +0 -0
  55. package/skills/video-studio/assets/score_demo.json +0 -307
@@ -0,0 +1,849 @@
1
+ # -*- coding: utf-8 -*-
2
+ """previewkit.py — 离线方案预览(v1.0):先看 3 个方案,再出片。
3
+
4
+ 解决什么问题
5
+ -------------
6
+ 用户要"先看 3 个方案再出片",可现有的 ``render.py --stage stills`` 在缺 work/tts.json 时
7
+ 会**自动联网合成旁白**(render.py:2529-2537)——"看一眼大概长什么样"这一步不该有网络、
8
+ 不该有合成成本、也不该等几十秒。
9
+
10
+ 本模块把预览做成**纯本地、纯确定性**的两件事:
11
+ 1. 用**合成时间轴**(每句旁白假装 ``fake_voice_dur`` 秒,默认 2.0)排 timeline,
12
+ 不读 tts.json、不合成语音。思路照抄 tools/golden_frames.py:32-35 与 build_timeline;
13
+ 2. 按 grammar.py 选出的真实构图(scene_type)渲染若干静帧 + 一张联系表。
14
+
15
+ 因此本文件**不联网、不做旁白合成、不跑视频编码器**(不调用 filmkit.tts / filmkit 的
16
+ 编码函数)。产出的 PNG 与真实成片的差别只在"哪一帧是哪一句",不在"这一帧画成什么样"——
17
+ 后者正是方案预览要回答的问题。
18
+
19
+ 12 秒样音(v1.1)
20
+ -----------------
21
+ ``render_audio_preview`` 是本文件唯一会"出声"的地方:按设计里的 bpm/key/scale/mood/seed
22
+ 生成 12 秒配乐,回答"这一版的情绪对不对"。优先走 musicgen(参数化作曲器);它没就绪时
23
+ 退回 scorekit 的 legacy 路径(v1.0.1 起 scorekit 没有风格表,那条路只保证"还能出声",
24
+ 音乐固定为 Dm/120,不能当作方案预览)。两条都不在就返回 ok=False。**样音失败绝不
25
+ 打断提案**:提案是决策环节,不该因为配乐引擎没装好就整条断掉。
26
+
27
+ 接口
28
+ ----
29
+ synthetic_timeline(script, fake_voice_dur=2.0, fps=30) -> dict
30
+ render_preview_frames(plan, script, times, outdir, theme=None) -> list[str]
31
+ contact_sheet(paths, out_png, cols=None) -> str
32
+ preview_candidate(design, script, outdir, n_frames=4, with_audio=False) -> dict
33
+ render_audio_preview(design, out_dir, seconds=12.0) -> dict
34
+ main(argv=None) -> int
35
+
36
+ times 的语义:**全片绝对秒**(0 <= t <= total)。也接受 ``[scene_id, 段内秒]`` 两元组
37
+ (与 render.py --stills 的 id:local 写法同义),但绝不把"绝对秒"当成"段内秒"。
38
+
39
+ 三条硬规矩
40
+ ----------
41
+ 1. **scene_type 必须来自 grammar.plan_scenes**。scenes.draw_frame 在 scene_type 为空时会
42
+ 按 kind 回退(_KIND_TO_TYPE),那样看到的不是真实构图(hook->hero、body->list…),
43
+ "预览"就成了另一种片子。本模块每次都显式调一次 plan_scenes 再画。
44
+ 2. **每帧必须真的是 1920x1080**。draw_frame 与 draw_subtitle 之后各校验一次,不符直接
45
+ raise——静默输出一张 1280x720 的"预览"比报错更糟。
46
+ 3. **designkit 由并行任务实现,本模块不自己实现它**。这里只依赖它的
47
+ ``design_to_plan(design) -> plan`` 一个函数;designkit 还没就绪(import 失败或缺该函数)
48
+ 时,``design_to_plan`` 会用清晰的中文报错说明"缺什么、怎么绕过",绝不静默回落到某一套
49
+ 内置风格(静默回落 = 所有候选又长得一样,正是这次改造要根除的毛病)。
50
+
51
+ 性能实测(本机,Python 3.12 / Pillow 13 / numpy 2.5,模板片 4 段)
52
+ --------------------------------------------------------------
53
+ 单候选 4 帧 1080p + 联系表:冷启动 ~1.2 s(其中 import 依赖 ~0.21 s,首帧烘焙背景 ~0.16 s),
54
+ 同进程热缓存 ~0.9 s。瓶颈是 PNG 落盘:默认 compress_level=6 要 0.6 s/帧,预览图用
55
+ ``compress_level=1``(0.10 s/帧,体积 +17%)换 5 倍速度——预览是**临时证据**,不是交付物。
56
+ """
57
+ from __future__ import annotations
58
+
59
+ __version__ = "1.1.0"
60
+
61
+ import argparse
62
+ import hashlib
63
+ import importlib.util
64
+ import json
65
+ import os
66
+ import re
67
+ import sys
68
+ import time
69
+ import wave
70
+
71
+ HERE = os.path.dirname(os.path.abspath(__file__))
72
+ ASSETS = os.path.normpath(os.path.join(HERE, os.pardir, "assets"))
73
+
74
+
75
+ def _default_scenes():
76
+ """默认 scenes.py:**先看同目录**,再回落到 <skill>/assets/。
77
+
78
+ 为什么不能只写死 assets/:SKILL.md 教用户把 scripts/*.py 拷到工作目录
79
+ (myfilm/ 下有 film.json、scenes.py、各脚本),此时本模块位于 myfilm/,
80
+ 而 <myfilm>/../assets/scenes.py 根本不存在 —— 实测报
81
+ "scenes.py 不存在:<myfilm>/../assets/scenes.py",提案直接跑不起来。
82
+ 仓库内部使用(脚本仍在 <skill>/scripts/)时,同目录没有 scenes.py,回落到 assets/。
83
+ """
84
+ for cand in (os.path.join(HERE, "scenes.py"),
85
+ os.path.join(ASSETS, "scenes.py")):
86
+ if os.path.isfile(cand):
87
+ return cand
88
+ return os.path.join(ASSETS, "scenes.py")
89
+
90
+
91
+ def _default_script():
92
+ """默认 film.json:同目录优先(工作目录里就是剧本),否则回落 assets/。"""
93
+ for cand in (os.path.join(HERE, "film.json"),
94
+ os.path.join(ASSETS, "film.json")):
95
+ if os.path.isfile(cand):
96
+ return cand
97
+ return os.path.join(ASSETS, "film.json")
98
+
99
+
100
+ DEFAULT_SCENES = _default_scenes()
101
+ DEFAULT_SCRIPT = _default_script()
102
+ DEFAULT_OUTDIR = "preview"
103
+ # 样音的交付口径固定下来:48 kHz 立体声、12 秒。参数不一致的两份 WAV 没法并排比较。
104
+ AUDIO_PREVIEW_NAME = "audio_preview.wav"
105
+ AUDIO_PREVIEW_SECONDS = 12.0
106
+ AUDIO_SR = 48000
107
+
108
+ # 预览固定 1080p:方案比较的是"构图/配色/排版",缩到 720p 会让字号与留白判断失真。
109
+ CANVAS = (1920, 1080)
110
+ # 假语音时长:**从 2.0 提到 2.8**。
111
+ # 为什么:段长 = lead + 假语音 + tail ≈ 2.0 + 1.4 = 3.4 s,而 chart 要求 ≥4.5 s、
112
+ # compare/process 要求 ≥4.0 s —— 于是预览里这些构图**必然被 grammar 降级成 list**。
113
+ # 实测后果:一份真片的 chart 段在预览里画成了要点列表(条形一根都没画),
114
+ # 用户看到的是"数据条没做出来",而成片里其实是正常的。预览骗人比预览粗糙严重得多。
115
+ # 3.2 s 让段长约 4.6 s,能盖住 chart 的 4.5 s 门槛(2.8 s 时 4.2 s 仍差一点,
116
+ # 实测 chart 段还是被降级成 list)。假时长只是"排轴用的常数",调大不会增加成本,
117
+ # 但它决定"预览能不能画出真实构图",宁可偏大。
118
+ DEFAULT_FAKE_VOICE_DUR = 3.2
119
+ DEFAULT_FRAMES = 4
120
+ # 预览图落盘用快档(理由见模块文档的实测数据);交付物仍由 render.py 走默认档。
121
+ PNG_COMPRESS_LEVEL = 1
122
+ CAND_IDS = "ABCDEFGH"
123
+
124
+ _MODS = None # (filmkit, stylekit, grammar) 惰性装载缓存
125
+ _SCENES = None # (path, module) 惰性装载缓存
126
+
127
+
128
+ # ---------------------------------------------------------------- 基础工具
129
+ def _setup_stdio():
130
+ """输出被重定向时切 UTF-8(Windows 控制台默认 GBK,中文会乱码)。与 render.py 同口径。"""
131
+ for stream in (sys.stdout, sys.stderr):
132
+ try:
133
+ if stream is not None and not stream.isatty():
134
+ stream.reconfigure(encoding="utf-8", errors="replace")
135
+ except Exception: # noqa: BLE001
136
+ pass
137
+
138
+
139
+ def _ensure_paths():
140
+ """把 scripts/ 与 assets/ 补进 sys.path。
141
+
142
+ scenes.py 自己会 ``import filmkit`` / ``import stylekit``,两个目录都得在路径上,
143
+ 否则按路径装载 scenes.py 时会 ModuleNotFoundError(tools/golden_frames.py 同处理)。
144
+ """
145
+ for p in (HERE, ASSETS):
146
+ if p not in sys.path:
147
+ sys.path.insert(0, p)
148
+
149
+
150
+ def _modules():
151
+ """惰性装载 filmkit / stylekit / grammar(只装一次)。"""
152
+ global _MODS
153
+ if _MODS is not None:
154
+ return _MODS
155
+ _ensure_paths()
156
+ try:
157
+ import filmkit as K
158
+ import stylekit as ST
159
+ import grammar as G
160
+ except Exception as e: # noqa: BLE001
161
+ raise RuntimeError("装载 filmkit/stylekit/grammar 失败(%s):%s" % (HERE, e)) from e
162
+ _MODS = (K, ST, G)
163
+ return _MODS
164
+
165
+
166
+ def _scenes_module(path=None):
167
+ """按路径装载 scenes.py(独立模块名,避免与站点包里的同名模块串档)。"""
168
+ global _SCENES
169
+ path = os.path.abspath(str(path or DEFAULT_SCENES))
170
+ if _SCENES is not None and _SCENES[0] == path:
171
+ return _SCENES[1]
172
+ if not os.path.isfile(path):
173
+ raise RuntimeError("scenes.py 不存在:%s" % path)
174
+ _ensure_paths()
175
+ spec = importlib.util.spec_from_file_location("preview_scenes", path)
176
+ if spec is None or spec.loader is None:
177
+ raise RuntimeError("scenes.py 无法作为模块加载:%s" % path)
178
+ mod = importlib.util.module_from_spec(spec)
179
+ sys.modules["preview_scenes"] = mod
180
+ spec.loader.exec_module(mod)
181
+ if not callable(getattr(mod, "draw_frame", None)):
182
+ raise RuntimeError("scenes.py 缺少 draw_frame(scene, t_local, dur, t_global, "
183
+ "script, frame) 契约:%s" % path)
184
+ _SCENES = (path, mod)
185
+ return mod
186
+
187
+
188
+ def _designkit():
189
+ """惰性 import 并行开发的 designkit;不可用返回 None(由调用方给出可照做的报错)。
190
+
191
+ designkit.py 可能还没写完(本轮由另一人并行实现),所以这里**只 import**,
192
+ 绝不在这里复制一份设计 DSL——那会导致两个"真源"。
193
+ """
194
+ _ensure_paths()
195
+ try:
196
+ import designkit
197
+ return designkit
198
+ except Exception: # noqa: BLE001 —— 未就绪不炸 import 期
199
+ return None
200
+
201
+
202
+ # ---------------------------------------------------------------- 参数校验
203
+ def _check_plan(plan):
204
+ """校验这是一份"能喂给 scenes.draw_frame"的风格计划(fail-closed)。"""
205
+ if not isinstance(plan, dict):
206
+ raise TypeError("plan 必须是 designkit.design_to_plan(design) 返回的 dict,收到 %s"
207
+ % type(plan).__name__)
208
+ if plan.get("legacy"):
209
+ return plan
210
+ if not isinstance(plan.get("theme"), dict) and not plan.get("theme_id"):
211
+ raise ValueError("plan 不是 stylekit 形状的风格计划:缺 theme 与 theme_id"
212
+ "(现有键:%s)" % ", ".join(sorted(plan)[:12]))
213
+ return plan
214
+
215
+
216
+ def _fps_of(script, plan=None):
217
+ """画幅帧率:优先设计里的 video.fps / fps,否则跟随剧本(filmkit.video_plan)。"""
218
+ K, _ST, _G = _modules()
219
+ plan = plan if isinstance(plan, dict) else {}
220
+ got = None
221
+ video = plan.get("video")
222
+ if isinstance(video, dict) and video.get("fps"):
223
+ got = video.get("fps")
224
+ elif plan.get("fps"):
225
+ got = plan.get("fps")
226
+ if got is not None:
227
+ fps = int(got)
228
+ allowed = tuple(getattr(K, "VIDEO_FPS_ALLOWED", (24, 25, 30, 50, 60)))
229
+ if fps not in allowed:
230
+ raise ValueError("plan 里的 fps=%r 不支持(可用:%s)"
231
+ % (got, "/".join(str(x) for x in allowed)))
232
+ return fps
233
+ return int(K.video_plan(script)["fps"])
234
+
235
+
236
+ def _scene_at(scenes, t):
237
+ """全片时刻 -> (scene, 段内时刻),与 render._render_range 的查找方式一致。"""
238
+ idx = 0
239
+ while idx + 1 < len(scenes) and t >= scenes[idx + 1]["start"]:
240
+ idx += 1
241
+ s = scenes[idx]
242
+ return s, float(t) - float(s["start"])
243
+
244
+
245
+ def _require_canvas(img, scene, t, stage):
246
+ """尺寸硬校验:方案预览必须是 1920x1080(不缩、不裁、不拉伸)。"""
247
+ if img is None:
248
+ raise RuntimeError("%s:场景 %s 在 t=%.3fs 返回 None(必须返回 PIL.Image)"
249
+ % (stage, scene.get("id"), t))
250
+ size = tuple(int(v) for v in getattr(img, "size", (0, 0)))
251
+ if size != CANVAS:
252
+ raise RuntimeError("%s:场景 %s 在 t=%.3fs 渲染出 %dx%d,期望 %dx%d"
253
+ "(方案预览固定 1080p,尺寸不符说明绘制器/主题有问题)"
254
+ % (stage, scene.get("id"), t, size[0], size[1],
255
+ CANVAS[0], CANVAS[1]))
256
+
257
+
258
+ # ---------------------------------------------------------------- 合成时间轴
259
+ def synthetic_timeline(script, fake_voice_dur=DEFAULT_FAKE_VOICE_DUR, fps=30):
260
+ """用"假语音时长"排一条确定性时间轴(不联网、不读 TTS 产物)。
261
+
262
+ 每句 dur 固定为 ``fake_voice_dur``,其余(lead/tail/min_dur/hold/bar)照常走
263
+ filmkit.build_timeline;lead/tail/min_dur 从 script 取,缺省值与
264
+ tools/golden_frames.py:183-216 一致(0.55 / 0.85 / 3.0)。
265
+
266
+ 返回 filmkit.build_timeline 的**原样结构**:{total, scenes, fps, frames}
267
+ (每段带 id/kind/start/end/dur/voice_start/voice_end 与剧本原字段)。
268
+
269
+ 注意:这不是成片时间轴。真实旁白时长不同 -> 段时长不同 -> 同一个"段内时刻"对应的
270
+ 画面进度略有差异(缓动/入场动画的百分比)。所以本函数只用于"看一眼构图与配色",
271
+ 不用它反推成片时长。
272
+ """
273
+ K, _ST, _G = _modules()
274
+ if not isinstance(script, dict):
275
+ raise TypeError("script 必须是 dict(film.json 的内容),收到 %s" % type(script).__name__)
276
+ try:
277
+ fake = float(fake_voice_dur)
278
+ except (TypeError, ValueError):
279
+ raise ValueError("fake_voice_dur 必须是数字,收到 %r" % (fake_voice_dur,))
280
+ if fake <= 0:
281
+ raise ValueError("fake_voice_dur 必须 > 0,收到 %r" % (fake_voice_dur,))
282
+
283
+ lines = []
284
+ for i, ln in enumerate(script.get("lines") or []):
285
+ if not isinstance(ln, dict):
286
+ continue
287
+ row = dict(ln)
288
+ row.setdefault("id", "l%02d" % (i + 1))
289
+ row["dur"] = fake
290
+ lines.append(row)
291
+ if not lines:
292
+ raise ValueError("剧本 lines 为空,无法排时间轴(预览至少需要一段内容)")
293
+
294
+ return K.build_timeline(
295
+ lines,
296
+ lead=float(script.get("lead", 0.55)),
297
+ tail=float(script.get("tail", 0.85)),
298
+ min_dur=float(script.get("min_dur", 3.0)),
299
+ tail_map=script.get("tail_map"),
300
+ lead_map=script.get("lead_map"),
301
+ hold=float(script.get("hold", 0.0)),
302
+ max_dur=script.get("max_dur"),
303
+ fps=int(fps),
304
+ )
305
+
306
+
307
+ def default_times(tl, n_frames=DEFAULT_FRAMES):
308
+ """默认取样时刻:在全部段上均匀取 n 段,各取段内中点(段少时就是每段一张)。
309
+
310
+ 为什么不取"前 4 段":钩子/正文/收尾的构图差得最远,只取前 4 段会漏掉 outro,
311
+ 用户看不到"结尾长什么样"。均匀取点让 4 张图覆盖全片节奏。
312
+ 段内偏移用 golden_frames 的同一条钳制(>=0.55s 且 <= dur-0.8s),避开片头淡入淡出。
313
+ """
314
+ scenes = list(tl.get("scenes") or [])
315
+ if not scenes:
316
+ raise ValueError("时间轴里没有段,无法取样")
317
+ total = float(tl.get("total") or 0.0)
318
+ n = len(scenes)
319
+ want = max(1, min(int(n_frames), n))
320
+ if want == 1:
321
+ idxs = [n // 2]
322
+ else:
323
+ idxs = sorted({int(round(i * (n - 1) / float(want - 1))) for i in range(want)})
324
+ out = []
325
+ for i in idxs:
326
+ s = scenes[i]
327
+ dur = float(s.get("dur") or 0.0)
328
+ local = max(0.55, min(dur * 0.5, max(0.56, dur - 0.8)))
329
+ t = float(s.get("start") or 0.0) + local
330
+ out.append(round(min(max(t, 0.0), max(0.0, total - 0.001)), 3))
331
+ return out
332
+
333
+
334
+ # ---------------------------------------------------------------- 渲染
335
+ def _resolve_time(tl, spec, by_id=None):
336
+ """取样规格 -> (scene, 段内秒)。支持 全片绝对秒 / [id, 段内秒] / "id:段内秒"。"""
337
+ scenes = tl["scenes"]
338
+ if isinstance(spec, str):
339
+ sid, _, local = spec.partition(":")
340
+ spec = [sid, float(local or 0.0)]
341
+ if isinstance(spec, (list, tuple)) and len(spec) == 2:
342
+ by_id = by_id or {s.get("id"): s for s in scenes}
343
+ s = by_id.get(str(spec[0]))
344
+ if s is None:
345
+ raise KeyError("时间轴里没有 id=%r 的段(有:%s)"
346
+ % (spec[0], " / ".join(str(x.get("id")) for x in scenes)))
347
+ local = float(spec[1])
348
+ else:
349
+ t = float(spec)
350
+ s, local = _scene_at(scenes, min(max(t, 0.0), max(0.0, float(tl["total"]) - 0.001)))
351
+ local = max(0.0, min(float(local), max(0.0, float(s["dur"]) - 0.05)))
352
+ return s, local
353
+
354
+
355
+ def render_preview_frames(plan, script, times, outdir, theme=None):
356
+ """对给定 plan 渲染指定时刻的静帧,返回落盘的 PNG 路径列表(绝对路径)。
357
+
358
+ - scene_type 由 **grammar.plan_scenes** 就地写进时间轴(不从 kind 猜);
359
+ - 每帧 = scenes.draw_frame(...) + filmkit.draw_subtitle(...)(与 render.py 同序);
360
+ - draw_frame 之后与 draw_subtitle 之后各校验一次尺寸,不是 1920x1080 就 raise。
361
+
362
+ plan / script 不会被修改:内部用一份浅拷贝承载 ``_style`` / ``_total`` 等渲染期私有字段。
363
+ """
364
+ K, _ST, G = _modules()
365
+ plan = _check_plan(plan)
366
+ if not isinstance(script, dict):
367
+ raise TypeError("script 必须是 dict(film.json 的内容),收到 %s" % type(script).__name__)
368
+ if (int(K.W), int(K.H)) != CANVAS:
369
+ raise RuntimeError("filmkit 画布 %dx%d 与本模块期望的 %dx%d 不一致:方案预览固定 1080p"
370
+ % (K.W, K.H, CANVAS[0], CANVAS[1]))
371
+ specs = list(times or [])
372
+ if not specs:
373
+ raise ValueError("times 为空:至少要给一个取样时刻(全片绝对秒,或 [段id, 段内秒])")
374
+
375
+ outdir = os.path.abspath(str(outdir))
376
+ os.makedirs(outdir, exist_ok=True)
377
+
378
+ tl = synthetic_timeline(script, fps=_fps_of(script, plan))
379
+ # 唯一 scene_type 来源。legacy 计划下 draw_frame 走 _LEGACY_DRAWERS(只读 kind),
380
+ # 这里的字段不会被用到,调一次也无害——但"预览"绝不允许 scene_type 为空。
381
+ G.plan_scenes(tl["scenes"], script, plan)
382
+ missing = [s.get("id") for s in tl["scenes"] if not s.get("scene_type")]
383
+ if missing:
384
+ raise RuntimeError("grammar.plan_scenes 没有给这些段补 scene_type:%s"
385
+ % " / ".join(str(x) for x in missing[:6]))
386
+ mod = _scenes_module(script.get("_scenes"))
387
+
388
+ draw_script = dict(script)
389
+ draw_script["_style"] = plan
390
+ draw_script["_total"] = tl["total"]
391
+ th = theme if isinstance(theme, dict) else (None if plan.get("legacy") else plan.get("theme"))
392
+ hold = script.get("sub_max_hold")
393
+ by_id = {s.get("id"): s for s in tl["scenes"]}
394
+
395
+ made = []
396
+ for i, spec in enumerate(specs):
397
+ scene, local = _resolve_time(tl, spec, by_id)
398
+ t = float(scene["start"]) + float(local)
399
+ frame_no = int(round(t * float(tl["fps"])))
400
+ frame_no = max(0, min(int(tl["frames"]) - 1, frame_no))
401
+ img = mod.draw_frame(scene, local, float(scene["dur"]), t, draw_script, frame_no)
402
+ _require_canvas(img, scene, t, "draw_frame")
403
+ img = K.draw_subtitle(img, K.sub_at(tl["scenes"], t, hold), theme=th,
404
+ words=K.sub_words_at(tl["scenes"], t, hold))
405
+ _require_canvas(img, scene, t, "draw_subtitle")
406
+ path = os.path.join(outdir, "%02d_%s_%05.2f.png" % (i, scene.get("id") or "s", t))
407
+ img.save(path, compress_level=PNG_COMPRESS_LEVEL)
408
+ made.append(path)
409
+ return made
410
+
411
+
412
+ def contact_sheet(paths, out_png, cols=None):
413
+ """把多张静帧并排拼成一张联系表(复用 filmkit.contact_sheet),返回输出路径。"""
414
+ K, _ST, _G = _modules()
415
+ paths = [str(p) for p in (paths or [])]
416
+ if not paths:
417
+ raise ValueError("contact_sheet: paths 为空,没有可拼的帧")
418
+ missing = [p for p in paths if not os.path.isfile(p)]
419
+ if missing:
420
+ raise FileNotFoundError("contact_sheet: 这些帧不存在:%s" % "、".join(missing[:4]))
421
+ n = len(paths)
422
+ c = int(cols) if cols else min(4, n)
423
+ if c <= 0:
424
+ raise ValueError("contact_sheet: cols 必须 >= 1,收到 %r" % (cols,))
425
+ out = K.contact_sheet(paths, os.path.abspath(str(out_png)), cols=c, thumb_w=480)
426
+ if not out or not os.path.isfile(out):
427
+ raise RuntimeError("contact_sheet: 联系表没有落盘(%s)" % out_png)
428
+ return out
429
+
430
+
431
+ # ---------------------------------------------------------------- 设计 -> 计划
432
+ def design_to_plan(design):
433
+ """把 design dict 交给 designkit.design_to_plan 解析(本模块**不实现**设计 DSL)。
434
+
435
+ designkit.py 由并行任务实现,可能还没就绪:import 失败、或文件在但没有
436
+ ``design_to_plan`` 时,这里抛一条能照着做的中文错误,并指明绕过方式
437
+ (直接给 render_preview_frames 传一份 stylekit 形状的 plan)。
438
+ """
439
+ DK = _designkit()
440
+ fn = getattr(DK, "design_to_plan", None) if DK is not None else None
441
+ if not callable(fn):
442
+ raise RuntimeError(
443
+ "designkit.design_to_plan(design) 不可用:designkit.py 尚未就绪(缺该函数)。"
444
+ "previewkit 不自己实现设计 DSL(会造出第二份真源);"
445
+ "要立刻看画面,可先调 render_preview_frames(plan, ...) 直接喂一份 "
446
+ "stylekit.resolve_script_style(script) 形状的 plan。")
447
+ return _check_plan(fn(design))
448
+
449
+
450
+ def design_fingerprint(design):
451
+ """设计指纹:优先 designkit.design_fingerprint;没有就按规范化 JSON 取 sha1 前 16 位。
452
+
453
+ 只用于"这份候选是不是同一版",不参与像素/门禁判断。
454
+ """
455
+ DK = _designkit()
456
+ fn = getattr(DK, "design_fingerprint", None) if DK is not None else None
457
+ if callable(fn):
458
+ try:
459
+ return str(fn(design))
460
+ except Exception: # noqa: BLE001 —— 指纹实现坏了不毁预览
461
+ pass
462
+ payload = json.dumps(design, ensure_ascii=False, sort_keys=True,
463
+ separators=(",", ":"), default=str)
464
+ return hashlib.sha1(payload.encode("utf-8")).hexdigest()[:16]
465
+
466
+
467
+ # ---------------------------------------------------------------- 12 秒样音
468
+ def _musicgen():
469
+ """惰性 import 并行开发的 musicgen;没就绪就让异常往上走,由调用方降级。"""
470
+ _ensure_paths()
471
+ import musicgen
472
+ for fn in ("render", "save"):
473
+ if not callable(getattr(musicgen, fn, None)):
474
+ raise RuntimeError("musicgen 缺 %s()(接口:render(duration, bpm=, key=, "
475
+ "scale=, mood=, seed=) -> (samples, report);save(path, samples))"
476
+ % fn)
477
+ return musicgen
478
+
479
+
480
+ def _scorekit():
481
+ """惰性 import scorekit:它拉起 numpy,import 期就要几百毫秒,默认不该付这笔钱。
482
+
483
+ v1.0.1 起 scorekit 的 9 套配乐风格表已删除(零模板),它只剩 legacy 逐样本兼容
484
+ 路径(固定 Dm/120、无 style/mood 参数)。这里保留它只是"兜底还能出声":
485
+ musicgen 没就绪时不至于整条样音链路断掉。
486
+ """
487
+ _ensure_paths()
488
+ import scorekit
489
+ return scorekit
490
+
491
+
492
+ def _audio_params(design):
493
+ """设计里的配乐参数 -> 样音参数(缺什么就用模块里唯一的那份缺省值)。"""
494
+ mu = {}
495
+ au = design.get("audio") if isinstance(design, dict) else None
496
+ if isinstance(au, dict) and isinstance(au.get("music"), dict):
497
+ mu = au["music"]
498
+ try:
499
+ bpm = float(mu.get("bpm"))
500
+ except (TypeError, ValueError):
501
+ bpm = 104.0
502
+ try:
503
+ seed = int(mu.get("seed"))
504
+ except (TypeError, ValueError):
505
+ seed = 7
506
+ return {
507
+ "bpm": bpm,
508
+ "key": str(mu.get("key") or "Dm"),
509
+ "scale": str(mu.get("scale") or "minor"),
510
+ "mood": str(mu.get("mood") or "steady"),
511
+ "seed": seed,
512
+ }
513
+
514
+
515
+ def _render_musicgen(params, path, secs):
516
+ MG = _musicgen()
517
+ samples, report = MG.render(secs, bpm=params["bpm"], key=params["key"],
518
+ scale=params["scale"], mood=params["mood"],
519
+ seed=params["seed"])
520
+ MG.save(path, samples)
521
+ return report
522
+
523
+
524
+ def _render_scorekit(params, path, secs):
525
+ """兜底:scorekit 的 legacy 路径(v1.0.1 起没有 style/mood 参数,别传)。"""
526
+ K = _scorekit()
527
+ samples, report = K.render(secs, bpm=params["bpm"], key=params["key"],
528
+ seed=params["seed"])
529
+ K.save(path, samples)
530
+ return report
531
+
532
+
533
+ _ENGINES = (("musicgen", _render_musicgen), ("scorekit", _render_scorekit))
534
+
535
+
536
+ def _probe_wav(path):
537
+ """读 WAV 头:声道数 / 采样率 / 帧数 / 位宽(只读头,不读样本)。"""
538
+ with wave.open(str(path), "rb") as w:
539
+ return {"channels": int(w.getnchannels()), "rate": int(w.getframerate()),
540
+ "frames": int(w.getnframes()), "width": int(w.getsampwidth())}
541
+
542
+
543
+ def _read_pcm(path):
544
+ """WAV -> (numpy 数组 [n,2] 的立体声, 原采样率)。8/16/24/32 位都认。"""
545
+ import numpy as np
546
+ got = _probe_wav(path)
547
+ with wave.open(str(path), "rb") as w:
548
+ raw = w.readframes(got["frames"])
549
+ width, ch = got["width"], got["channels"]
550
+ if width == 1:
551
+ a = (np.frombuffer(raw, np.uint8).astype(np.float64) - 128.0) / 128.0
552
+ elif width == 2:
553
+ a = np.frombuffer(raw, "<i2").astype(np.float64) / 32768.0
554
+ elif width == 3:
555
+ b = np.frombuffer(raw, np.uint8).reshape(-1, 3).astype(np.int32)
556
+ v = b[:, 0] | (b[:, 1] << 8) | (b[:, 2] << 16)
557
+ a = ((v ^ (1 << 23)) - (1 << 23)).astype(np.float64) / 8388608.0
558
+ elif width == 4:
559
+ a = np.frombuffer(raw, "<i4").astype(np.float64) / 2147483648.0
560
+ else:
561
+ raise ValueError("不支持的位深:%d bit" % (width * 8))
562
+ a = a[: (len(a) // ch) * ch].reshape(-1, ch)
563
+ if ch == 1:
564
+ a = np.stack([a[:, 0], a[:, 0]], axis=1)
565
+ elif ch > 2:
566
+ a = a[:, :2] # 多声道只取前两路(样音不做混音)
567
+ return a, got["rate"]
568
+
569
+
570
+ def _rewrite_wav(path, secs):
571
+ """把 WAV 改写成 48 kHz / 立体声 / 正好 secs 秒(16-bit 落盘)。"""
572
+ import numpy as np
573
+ a, rate = _read_pcm(path)
574
+ n = int(round(float(secs) * AUDIO_SR))
575
+ if rate != AUDIO_SR and len(a):
576
+ src = np.linspace(0.0, 1.0, num=len(a), endpoint=False)
577
+ dst = np.linspace(0.0, 1.0, num=n, endpoint=False)
578
+ a = np.stack([np.interp(dst, src, a[:, c]) for c in range(2)], axis=1)
579
+ if len(a) < n:
580
+ a = np.vstack([a, np.zeros((n - len(a), 2))])
581
+ pcm = np.rint(np.clip(a[:n], -1.0, 1.0) * 32767.0).astype("<i2")
582
+ tmp = path + ".part"
583
+ with wave.open(tmp, "wb") as w:
584
+ w.setnchannels(2)
585
+ w.setsampwidth(2)
586
+ w.setframerate(AUDIO_SR)
587
+ w.writeframes(pcm.tobytes())
588
+ os.replace(tmp, path) # 原子替换:读的人不会看到半个文件
589
+
590
+
591
+ def _ensure_wav_contract(path, secs):
592
+ """核一遍"48 kHz / 立体声 / 正好 secs 秒",不合就地重写。返回核对后的头信息。
593
+
594
+ 为什么要兜这一道:musicgen 是另一路并行开发的模块,它的输出口径本模块不能假设;
595
+ 而三件套不齐的 WAV 没法当作"同一份证据"去比较(时长差 0.3 秒就听不出接缝在哪)。
596
+ """
597
+ got = _probe_wav(path)
598
+ if (got["channels"] == 2 and got["rate"] == AUDIO_SR
599
+ and abs(got["frames"] / float(got["rate"]) - float(secs)) <= 0.05):
600
+ return got
601
+ _rewrite_wav(path, secs)
602
+ return _probe_wav(path)
603
+
604
+
605
+ def render_audio_preview(design, out_dir, seconds=AUDIO_PREVIEW_SECONDS):
606
+ """生成一个候选的 12 秒样音(48 kHz 立体声),返回结果 dict,**绝不抛异常**。
607
+
608
+ 为什么优先 musicgen:它直接吃设计里的 bpm/key/scale/mood/seed,12 秒一次合成;
609
+ 没就绪就退回 scorekit 的 legacy 路径(v1.0.1 起它没有风格表,出的是固定 Dm/120),
610
+ engine 字段会写明是谁出的,调用方据此知道这条样音能不能代表方案。
611
+ 两条都不可用时返回 {"ok": False, "reason": ...}——提案是决策环节,配乐引擎缺位不该
612
+ 让整条提案断掉,但也不许假装成功(ok=True 却指着一个不存在的文件比报错更糟)。
613
+
614
+ 返回 {"ok", "path", "secs", "engine", "report"}(secs = 生成耗时);
615
+ 降级时多一个 "reason" 说明为什么换了引擎。
616
+ """
617
+ t0 = time.perf_counter()
618
+ try:
619
+ secs = float(seconds)
620
+ if not secs > 0:
621
+ return {"ok": False, "reason": "seconds 必须 > 0,收到 %r" % (seconds,)}
622
+ out_dir = os.path.abspath(str(out_dir))
623
+ os.makedirs(out_dir, exist_ok=True)
624
+ except Exception as e: # noqa: BLE001
625
+ return {"ok": False, "reason": "参数不合法:%s: %s" % (type(e).__name__, e)}
626
+
627
+ path = os.path.join(out_dir, AUDIO_PREVIEW_NAME)
628
+ params = _audio_params(design)
629
+ why, report, engine = "", None, ""
630
+ for name, fn in _ENGINES:
631
+ try:
632
+ report = fn(params, path, secs)
633
+ _ensure_wav_contract(path, secs)
634
+ except Exception as e: # noqa: BLE001 —— 失败只降级,不外抛
635
+ why += (";" if why else "") + "%s 不可用(%s: %s)" % (name, type(e).__name__, e)
636
+ continue
637
+ engine = name
638
+ break
639
+ if not engine:
640
+ return {"ok": False, "reason": why or "没有可用的配乐引擎"}
641
+
642
+ out = {"ok": True, "path": path, "secs": round(time.perf_counter() - t0, 3),
643
+ "engine": engine, "report": report, "audio_secs": secs}
644
+ if engine != _ENGINES[0][0]:
645
+ out["reason"] = why # 降级原因留痕,别让"为什么不是 musicgen"成谜
646
+ return out
647
+
648
+
649
+ # ---------------------------------------------------------------- 单候选预览
650
+ def preview_candidate(design, script, outdir, n_frames=DEFAULT_FRAMES, with_audio=False):
651
+ """单个候选:design -> plan -> 分镜 -> 取默认时刻 -> 出图 + 联系表。
652
+
653
+ 返回 {"id", "sheet", "frames", "secs", "design_fp"}:
654
+ id 候选 id(design["id"],缺省 "cand")
655
+ sheet 联系表 PNG(绝对路径)
656
+ frames 静帧 PNG 列表(绝对路径,单张 1920x1080)
657
+ secs 本次耗时(秒,含 design_to_plan、全部落盘;开了样音时也含样音)
658
+ design_fp 设计指纹(designkit 有就用它的,没有就用规范化 JSON 的 sha1)
659
+
660
+ with_audio=True 时多给 audio(样音 WAV 绝对路径,失败为 "")、audio_engine、
661
+ audio_secs(**生成耗时**,不是音频长度),失败时再加 audio_error。
662
+ 默认不开:既有调用方只要静帧,白等 1~2 秒没道理。
663
+
664
+ 全程不联网、不合成旁白:时间轴来自 synthetic_timeline。
665
+ """
666
+ t0 = time.perf_counter()
667
+ if not isinstance(design, dict):
668
+ raise TypeError("design 必须是 dict,收到 %s" % type(design).__name__)
669
+ plan = design_to_plan(design)
670
+
671
+ script = dict(script or {})
672
+ tl = synthetic_timeline(script, fps=_fps_of(script, plan))
673
+ # 显式调一次:要求"先定分镜再取样"(同时把 scene_type 写进 tl["scenes"],
674
+ # 下面的 render_preview_frames 会用同一条确定性规则再算一遍,代价是微秒级)。
675
+ _G = _modules()[2]
676
+ sb = _G.plan_scenes(tl["scenes"], script, plan)
677
+ # **降级必须可见**:剧本写了 type=chart 却被降成 list,预览里画出来的就不是 chart。
678
+ # 这是"预览与成片不一致"最难察觉的一种(不是时长差异,是构图整个换了),
679
+ # 所以把降级信息放进返回值,让人读输出和 proposal.json 都能看到。
680
+ downgrades = []
681
+ req = {}
682
+ for ln in (script.get("lines") or []):
683
+ if isinstance(ln, dict) and ln.get("type"):
684
+ req[str(ln.get("id") or "")] = str(ln["type"])
685
+ for row in (sb.get("scenes") or []):
686
+ want = req.get(str(row.get("id") or ""))
687
+ if want and want != row.get("type"):
688
+ downgrades.append({"id": row.get("id"), "want": want,
689
+ "got": row.get("type"),
690
+ "dur": row.get("dur"),
691
+ "reason": ";".join(str(x) for x in (row.get("reason") or [])[:2])})
692
+
693
+ times = default_times(tl, n_frames)
694
+ outdir = os.path.abspath(str(outdir))
695
+ frames = render_preview_frames(plan, script, times, outdir)
696
+ sheet = contact_sheet(frames, os.path.join(outdir, "_sheet.png"), cols=len(frames))
697
+ row = {
698
+ "id": str(design.get("id") or "cand"),
699
+ "sheet": sheet,
700
+ "frames": frames,
701
+ "secs": round(time.perf_counter() - t0, 3),
702
+ "design_fp": design_fingerprint(design),
703
+ # 被降级的段(剧本写了 type 但段太短画不出来):预览必须自曝这一点,
704
+ # 否则用户会以为"数据条没做出来",而成片里是有的。
705
+ "downgrades": downgrades,
706
+ }
707
+ if with_audio:
708
+ au = render_audio_preview(design, outdir)
709
+ row["audio"] = str(au.get("path") or "") if au.get("ok") else ""
710
+ row["audio_engine"] = str(au.get("engine") or "")
711
+ row["audio_secs"] = au.get("secs")
712
+ if not au.get("ok"):
713
+ # 失败原因必须留在行里:上层要能说"这一版的样音没出来,因为……",
714
+ # 而不是只看到一个空字符串去猜。
715
+ row["audio_error"] = str(au.get("reason") or "未知原因")
716
+ row["secs"] = round(time.perf_counter() - t0, 3)
717
+ return row
718
+
719
+
720
+ # ---------------------------------------------------------------- CLI
721
+ def _load_json(path):
722
+ path = os.path.abspath(str(path))
723
+ if not os.path.isfile(path):
724
+ raise SystemExit("[preview] 文件不存在:%s" % path)
725
+ try:
726
+ with open(path, encoding="utf-8") as fh:
727
+ return json.load(fh)
728
+ except ValueError as e:
729
+ raise SystemExit("[preview] %s 不是合法 JSON:%s" % (path, e)) from e
730
+
731
+
732
+ def _load_script(path):
733
+ script = _load_json(path)
734
+ if not isinstance(script, dict) or not isinstance(script.get("lines"), list):
735
+ raise SystemExit("[preview] 剧本结构不对(需要 dict 且 lines 是数组):%s" % path)
736
+ if not script["lines"]:
737
+ raise SystemExit("[preview] 剧本 lines 为空,预览没有内容可画:%s" % path)
738
+ script["_path"] = os.path.abspath(str(path))
739
+ script["_dir"] = os.path.dirname(script["_path"])
740
+ return script
741
+
742
+
743
+ def _designs_from(payload, path):
744
+ """design.json 可以是单个设计 / 设计数组 / {"designs":[...]} / {"candidates":[...]}。"""
745
+ if isinstance(payload, list):
746
+ designs = payload
747
+ elif isinstance(payload, dict):
748
+ designs = None
749
+ for key in ("designs", "candidates"):
750
+ if isinstance(payload.get(key), list):
751
+ designs = payload[key]
752
+ break
753
+ if designs is None:
754
+ designs = [payload]
755
+ else:
756
+ raise SystemExit("[preview] %s 必须是设计对象或设计数组" % path)
757
+ bad = [i for i, d in enumerate(designs) if not isinstance(d, dict)]
758
+ if bad:
759
+ raise SystemExit("[preview] %s 里第 %s 项不是对象(设计必须是 dict)"
760
+ % (path, ", ".join(str(i + 1) for i in bad[:4])))
761
+ if not designs:
762
+ raise SystemExit("[preview] %s 里没有任何设计" % path)
763
+ return designs
764
+
765
+
766
+ def _candidate_ids(designs):
767
+ """候选 id:优先设计自带的 id,否则按 A/B/C 编号;同时做成文件名安全的唯一串。"""
768
+ ids, used = [], set()
769
+ for i, d in enumerate(designs):
770
+ raw = d.get("id")
771
+ if not raw:
772
+ raw = CAND_IDS[i] if i < len(CAND_IDS) else "C%d" % (i + 1)
773
+ # 保留 Unicode 字母(中文设计 id 仍能当目录名);只把路径分隔符/标点换成 "-"
774
+ base = re.sub(r"[^\w.-]+", "-", str(raw), flags=re.UNICODE).strip("-._") or "cand"
775
+ cid, k = base, 2
776
+ while cid in used:
777
+ cid = "%s-%d" % (base, k)
778
+ k += 1
779
+ used.add(cid)
780
+ ids.append(cid)
781
+ return ids
782
+
783
+
784
+ def _human(rows, wall, outdir):
785
+ print("== previewkit(离线预览:合成时间轴,不合成旁白、不联网)")
786
+ for r in rows:
787
+ print(" 候选 %-10s %2d 帧 %6.2fs %s"
788
+ % (r["id"], len(r["frames"]), r["secs"], r["sheet"]))
789
+ for d in (r.get("downgrades") or []):
790
+ print(" [降级] %s:剧本要求 %s,但因段长 %.1fs 不足画成了 %s"
791
+ % (d.get("id"), d.get("want"), float(d.get("dur") or 0), d.get("got")))
792
+ print(" 合计 %.2fs -> %s(%d 个候选)" % (wall, outdir, len(rows)))
793
+ if any(r.get("downgrades") for r in rows):
794
+ print(" 注意:上面标了 [降级] 的段**预览与成片不一致**(成片段长由真实 TTS 决定,"
795
+ "通常更长、不会被降级)。看那几段时请以 --stage stills 或成片为准。")
796
+
797
+
798
+ def main(argv=None):
799
+ _setup_stdio()
800
+ ap = argparse.ArgumentParser(
801
+ description="离线方案预览:设计 -> 合成时间轴 -> 静帧 + 联系表(不联网)")
802
+ ap.add_argument("--design", required=True,
803
+ help="design.json:单个设计 / 设计数组 / {\"designs\":[...]}")
804
+ ap.add_argument("--script", default=DEFAULT_SCRIPT, help="film.json(默认模板片)")
805
+ ap.add_argument("--outdir", default=DEFAULT_OUTDIR, help="输出目录(默认 ./preview)")
806
+ ap.add_argument("--n-frames", type=int, default=DEFAULT_FRAMES,
807
+ help="每个候选取几帧(默认 4)")
808
+ ap.add_argument("--json", action="store_true", help="stdout 只输出 JSON(机读)")
809
+ a = ap.parse_args(argv)
810
+
811
+ rows, err = [], None
812
+ t_all = time.perf_counter()
813
+ designs = []
814
+ try:
815
+ if a.n_frames < 1:
816
+ raise SystemExit("[preview] --n-frames 必须 >= 1,收到 %d" % a.n_frames)
817
+ designs = _designs_from(_load_json(a.design), a.design)
818
+ script = _load_script(a.script)
819
+ ids = _candidate_ids(designs)
820
+ outdir = os.path.abspath(str(a.outdir))
821
+ os.makedirs(outdir, exist_ok=True)
822
+ for cid, design in zip(ids, designs):
823
+ cdir = outdir if len(designs) == 1 else os.path.join(outdir, cid)
824
+ row = preview_candidate(design, script, cdir, n_frames=a.n_frames)
825
+ row["id"] = cid
826
+ rows.append(row)
827
+ except SystemExit as e: # noqa: BLE001 —— --json 也必须只吐 JSON
828
+ err = str(e.code) if e.code not in (None, 0) else "已中止"
829
+ except Exception as e: # noqa: BLE001
830
+ err = "%s: %s" % (type(e).__name__, e)
831
+ wall = round(time.perf_counter() - t_all, 3)
832
+ ok = err is None and bool(rows) and len(rows) == len(designs)
833
+
834
+ if a.json:
835
+ out = {"ok": ok, "candidates": rows, "wall_s": wall}
836
+ if err is not None:
837
+ out["error"] = err
838
+ json.dump(out, sys.stdout, ensure_ascii=False, indent=1)
839
+ sys.stdout.write("\n")
840
+ else:
841
+ if err is not None:
842
+ print("[preview] 失败:%s" % err, file=sys.stderr)
843
+ else:
844
+ _human(rows, wall, os.path.abspath(str(a.outdir)))
845
+ return 0 if ok else 1
846
+
847
+
848
+ if __name__ == "__main__":
849
+ raise SystemExit(main())