@remixmate/cli 0.9.72 → 0.9.74

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 (179) hide show
  1. package/LICENSE +45 -21
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/dist/argv.js +1 -51
  5. package/dist/auth/auth-lock.js +1 -100
  6. package/dist/auth/auto-login.js +7 -75
  7. package/dist/auth/commands.js +40 -290
  8. package/dist/auth/credential-store.js +1 -143
  9. package/dist/auth/device-flow-runner.js +13 -110
  10. package/dist/auth/device-flow.js +1 -132
  11. package/dist/auth/ensure.js +1 -62
  12. package/dist/auth/environment.js +1 -69
  13. package/dist/auth/pending-store.js +1 -45
  14. package/dist/auth/resolve.js +1 -44
  15. package/dist/billing.js +3 -76
  16. package/dist/capabilities.js +1 -75
  17. package/dist/cli.js +39 -211
  18. package/dist/diagnostics.js +2 -70
  19. package/dist/doctor.js +9 -188
  20. package/dist/errors.js +1 -33
  21. package/dist/exec.js +4 -47
  22. package/dist/handlers/gen-digital-human.js +33 -264
  23. package/dist/handlers/gen-image.js +9 -113
  24. package/dist/handlers/gen-post.js +15 -192
  25. package/dist/handlers/gen-video.js +7 -185
  26. package/dist/handlers/gen-voice.js +19 -133
  27. package/dist/handlers/index.js +1 -20
  28. package/dist/handlers/shared.js +1 -66
  29. package/dist/handlers/template-init.js +1 -21
  30. package/dist/http.js +3 -155
  31. package/dist/index.js +1 -8
  32. package/dist/install.js +40 -448
  33. package/dist/manifest.json +44 -3
  34. package/dist/progress.js +2 -15
  35. package/dist/project/commands.js +34 -156
  36. package/dist/project/host.js +1 -151
  37. package/dist/project/resolve.js +1 -65
  38. package/dist/project/store.js +2 -97
  39. package/dist/project/take.js +1 -106
  40. package/dist/project/trace.js +5 -278
  41. package/dist/registry.js +3 -70
  42. package/dist/runner.js +7 -225
  43. package/dist/skill-schema.js +1 -194
  44. package/dist/template/args.js +1 -69
  45. package/dist/template/check.js +2 -30
  46. package/dist/template/commands.js +34 -163
  47. package/dist/template/constants.js +1 -46
  48. package/dist/template/init.js +5 -133
  49. package/dist/template/list.js +1 -46
  50. package/dist/template/preview.js +7 -318
  51. package/dist/template/proc.js +1 -43
  52. package/dist/template/project.js +6 -161
  53. package/dist/template/publish.js +5 -185
  54. package/dist/template/render-client.js +2 -79
  55. package/dist/template/upgrade.js +4 -123
  56. package/dist/text.js +1 -15
  57. package/package.json +2 -35
  58. package/skills/doc-parse/scripts/extract_via_render.py +32 -32
  59. package/skills/export-jianying/scripts/gen_jianying_draft.py +188 -188
  60. package/skills/gen-script/SKILL.md +23 -1
  61. package/skills/gen-script/scripts/gen_script.py +644 -551
  62. package/skills/gen-script/skill.json +12 -0
  63. package/skills/gen-script/version.json +1 -1
  64. package/skills/prepare-video-assets/scripts/prepare_video_assets.py +21 -21
  65. package/skills/render-video/scripts/_chrome_vendor.py +17 -17
  66. package/skills/render-video/scripts/_digital_human_segments.py +34 -34
  67. package/skills/render-video/scripts/_video_probe.py +33 -33
  68. package/skills/render-video/scripts/_vod_polling.py +13 -13
  69. package/skills/render-video/scripts/remote_renderer_client.py +42 -42
  70. package/skills/render-video/scripts/render_video.py +639 -639
  71. package/skills/render-video/scripts/required_props.py +37 -37
  72. package/skills/render-video/scripts/upload_video.py +35 -35
  73. package/skills/render-video/version.json +1 -1
  74. package/skills/template-registry/SKILL.md +16 -0
  75. package/skills/template-registry/scripts/countdown_spec.py +92 -92
  76. package/skills/template-registry/scripts/doc_extract.py +40 -40
  77. package/skills/template-registry/scripts/list_templates.py +303 -191
  78. package/skills/template-registry/scripts/log_diagnostics.py +1 -1
  79. package/skills/template-registry/scripts/match_template.py +87 -87
  80. package/skills/template-registry/scripts/payload_hydrator.py +89 -89
  81. package/skills/template-registry/scripts/registry_loader.py +159 -159
  82. package/skills/template-registry/scripts/render_job_client.py +75 -75
  83. package/skills/template-registry/scripts/showcase_loader.py +44 -44
  84. package/skills/template-registry/skill.json +4 -1
  85. package/skills/template-registry/version.json +1 -1
  86. package/skills/template-registry/video_dsl/runtime/__init__.py +8 -8
  87. package/skills/template-registry/video_dsl/runtime/dsl_validator.py +329 -329
  88. package/skills/template-registry/video_dsl/runtime/narration_budget.py +36 -36
  89. package/skills/template-registry/video_dsl/runtime/payload_contract.py +7 -7
  90. package/skills/template-registry/video_dsl/runtime/prompt_enhancer.py +98 -98
  91. package/skills/template-registry/video_dsl/runtime/timeline_compiler.py +127 -127
  92. package/skills/template-registry/video_dsl/schema/video-dsl-v1alpha1.json +14 -1
  93. package/skills/video-parser/scripts/analyze_video.py +173 -173
  94. package/skills/video-parser/scripts/deconstruct_video.py +91 -91
  95. package/skills/video-parser/scripts/parse_via_render.py +21 -21
  96. package/skills/web-screenshot/scripts/_media_screenshot/__init__.py +4 -4
  97. package/skills/web-screenshot/scripts/_media_screenshot/bootstrap.py +7 -7
  98. package/skills/web-screenshot/scripts/_media_screenshot/browser.py +4 -4
  99. package/skills/web-screenshot/scripts/_media_screenshot/cli_args.py +4 -4
  100. package/skills/web-screenshot/scripts/_media_screenshot/js_loader.py +2 -2
  101. package/skills/web-screenshot/scripts/_media_screenshot/overlay.py +1 -1
  102. package/skills/web-screenshot/scripts/_media_screenshot/reader.py +40 -40
  103. package/skills/web-screenshot/scripts/_media_screenshot/recording.py +11 -11
  104. package/skills/web-screenshot/scripts/_media_screenshot/scenes/__init__.py +8 -8
  105. package/skills/web-screenshot/scripts/_media_screenshot/scenes/arrow.py +2 -2
  106. package/skills/web-screenshot/scripts/_media_screenshot/scenes/caption.py +1 -1
  107. package/skills/web-screenshot/scripts/_media_screenshot/scenes/click.py +1 -1
  108. package/skills/web-screenshot/scripts/_media_screenshot/scenes/code_lines.py +5 -5
  109. package/skills/web-screenshot/scripts/_media_screenshot/scenes/focus.py +1 -1
  110. package/skills/web-screenshot/scripts/_media_screenshot/scenes/highlight.py +3 -3
  111. package/skills/web-screenshot/scripts/_media_screenshot/scenes/hover.py +1 -1
  112. package/skills/web-screenshot/scripts/_media_screenshot/scenes/redact.py +1 -1
  113. package/skills/web-screenshot/scripts/_media_screenshot/scenes/scroll.py +1 -1
  114. package/skills/web-screenshot/scripts/_media_screenshot/scenes/sequence.py +1 -1
  115. package/skills/web-screenshot/scripts/_media_screenshot/scenes/title_card.py +1 -1
  116. package/skills/web-screenshot/scripts/_media_screenshot/scenes/type.py +1 -1
  117. package/skills/web-screenshot/scripts/_media_screenshot/scenes/wait.py +1 -1
  118. package/skills/web-screenshot/scripts/_media_screenshot/scenes/zoom.py +2 -2
  119. package/skills/web-screenshot/scripts/_media_screenshot/screenshot.py +15 -15
  120. package/skills/web-screenshot/scripts/_media_screenshot/storyboard.py +2 -2
  121. package/skills/web-screenshot/scripts/_media_screenshot/template.py +11 -11
  122. package/skills/web-screenshot/scripts/_media_screenshot/trim.py +10 -10
  123. package/skills/web-screenshot/scripts/_media_screenshot/urlguard.py +22 -22
  124. package/skills/web-screenshot/scripts/_media_upload.py +33 -33
  125. package/skills/web-screenshot/scripts/read_page.py +15 -15
  126. package/skills/web-screenshot/scripts/record.py +26 -26
  127. package/skills/web-screenshot/scripts/screenshot.py +5 -5
  128. package/dist/argv.d.ts +0 -15
  129. package/dist/auth/auth-lock.d.ts +0 -26
  130. package/dist/auth/auto-login.d.ts +0 -20
  131. package/dist/auth/commands.d.ts +0 -8
  132. package/dist/auth/credential-store.d.ts +0 -60
  133. package/dist/auth/device-flow-runner.d.ts +0 -58
  134. package/dist/auth/device-flow.d.ts +0 -52
  135. package/dist/auth/ensure.d.ts +0 -55
  136. package/dist/auth/environment.d.ts +0 -38
  137. package/dist/auth/pending-store.d.ts +0 -33
  138. package/dist/auth/resolve.d.ts +0 -30
  139. package/dist/billing.d.ts +0 -44
  140. package/dist/capabilities.d.ts +0 -63
  141. package/dist/cli.d.ts +0 -13
  142. package/dist/diagnostics.d.ts +0 -24
  143. package/dist/doctor.d.ts +0 -22
  144. package/dist/errors.d.ts +0 -29
  145. package/dist/exec.d.ts +0 -18
  146. package/dist/handlers/gen-digital-human.d.ts +0 -17
  147. package/dist/handlers/gen-image.d.ts +0 -20
  148. package/dist/handlers/gen-post.d.ts +0 -22
  149. package/dist/handlers/gen-video.d.ts +0 -19
  150. package/dist/handlers/gen-voice.d.ts +0 -26
  151. package/dist/handlers/index.d.ts +0 -20
  152. package/dist/handlers/shared.d.ts +0 -26
  153. package/dist/handlers/template-init.d.ts +0 -10
  154. package/dist/http.d.ts +0 -72
  155. package/dist/index.d.ts +0 -9
  156. package/dist/install.d.ts +0 -71
  157. package/dist/progress.d.ts +0 -23
  158. package/dist/project/commands.d.ts +0 -19
  159. package/dist/project/host.d.ts +0 -91
  160. package/dist/project/resolve.d.ts +0 -43
  161. package/dist/project/store.d.ts +0 -31
  162. package/dist/project/take.d.ts +0 -50
  163. package/dist/project/trace.d.ts +0 -98
  164. package/dist/registry.d.ts +0 -30
  165. package/dist/runner.d.ts +0 -39
  166. package/dist/skill-schema.d.ts +0 -138
  167. package/dist/template/args.d.ts +0 -25
  168. package/dist/template/check.d.ts +0 -23
  169. package/dist/template/commands.d.ts +0 -25
  170. package/dist/template/constants.d.ts +0 -39
  171. package/dist/template/init.d.ts +0 -28
  172. package/dist/template/list.d.ts +0 -23
  173. package/dist/template/preview.d.ts +0 -60
  174. package/dist/template/proc.d.ts +0 -26
  175. package/dist/template/project.d.ts +0 -47
  176. package/dist/template/publish.d.ts +0 -43
  177. package/dist/template/render-client.d.ts +0 -39
  178. package/dist/template/upgrade.d.ts +0 -44
  179. package/dist/text.d.ts +0 -8
@@ -1,24 +1,24 @@
1
- """
2
- DSL 校验器 - 对 Video DSL v1alpha1 做结构校验与标准化。
3
-
4
- 模块结构(T6 重构后):
5
-
6
- * 细粒度 ``_check_*`` 检查器:每个函数检查一个独立维度,返回
7
- ``list[ValidationError]``。
8
- * 三个 preset 聚合器,对应历史上 gen-script / render-video / 完整版三种
9
- 使用场景:
10
- - ``validate_structural(dsl)`` ⇐ gen-script 历史调用点
11
- - ``validate_integrity(dsl)`` ⇐ render-video 历史调用点
12
- - ``validate_dsl(dsl)`` ⇐ 完整版(含 capabilities-driven 语言约束、字段枚举等)
13
- 历史的两个 caller 现在 thin-wrap 对应 preset,**所检规则与历史完全等价**,
14
- 以保证不引入任何"原本能过、现在被拒"的行为变化。
15
-
16
- 模块以前同时存在三份算法(在 gen_script.py、render_video.py 与本文件内),
17
- ``lineSyncSlides`` 注册表查询、items 数对齐、模板专属语言约束等代码在三份
18
- 之间已经静默漂移。统一到本文件后,所有维度只有一份实现。所有模板差异通过
19
- ``template.json`` 的 ``capabilities`` 字段声明,本文件读取并应用 —— 新增需要特殊
20
- 处理的模板时**不需要再改 remixmate 代码**。
21
- """
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
22
 
23
23
  import difflib
24
24
  import re
@@ -30,25 +30,25 @@ VALID_RESOLUTIONS = {"480p", "720p", "1080p", "4k"}
30
30
  VALID_PURPOSES = {"opening", "intro", "point", "example", "explanation", "transition", "highlight", "cta", "ending"}
31
31
  VALID_LAYOUTS = {"full-visual", "split-left-right", "split-top-bottom", "picture-in-picture", "text-overlay", "avatar-with-bg", "kenburns"}
32
32
 
33
- # 场景数上限的兜底值。叙事弧类模板的合理上界——20 段以上的短视频必然过碎。
34
- # "一页素材一个场景"的模板(capabilities.payloadHydration)场景数由源文件页数决定,应当用
35
- # ``capabilities.maxScenes`` 抬高;这个常量只是没声明时的默认。
33
+
34
+
35
+
36
36
  DEFAULT_MAX_SCENES = 20
37
37
 
38
- # 声明式上界的上界。模板可以抬高 maxScenes,但不能抬到无限——一次请求排上千个场景
39
- # 会在下游(TTS 逐场景合成、Remotion 逐场景渲染)变成一次谁也杀不掉的长任务。
38
+
39
+
40
40
  MAX_SCENES_CEILING = 200
41
41
 
42
42
 
43
43
  def max_scenes_for_template(template_config: Optional[dict]) -> int:
44
- """模板声明的场景数上限,缺省 ``DEFAULT_MAX_SCENES``。
45
44
 
46
- 单一实现:gen-script 规划场景时用它决定 clamp 到几,dsl_validator 用它做门禁。
47
- 两边各写一份 ``min(n, 20)`` 正是这个仓库反复栽的那种跟头。
48
45
 
49
- 宽容读取:非正整数、非数字、capabilities 缺失一律回落到默认值,不抛异常——
50
- 一个写坏的 template.json 不该让所有模板的场景规划一起失败。
51
- """
46
+
47
+
48
+
49
+
50
+
51
+
52
52
  caps = (template_config or {}).get("capabilities")
53
53
  if not isinstance(caps, dict):
54
54
  return DEFAULT_MAX_SCENES
@@ -62,24 +62,24 @@ VALID_ASSET_TYPES = {"image", "video", "audio", "avatar", "subtitle", "bgm"}
62
62
  VALID_ASSET_SOURCES = {"existing", "gen-image", "gen-video", "gen-voice", "gen-digital-human"}
63
63
 
64
64
  def _frame_level_keys(tpl: dict) -> frozenset:
65
- """模板声明的 frame 级字段(``capabilities.frameLevelKeys.keys``)。
66
65
 
67
- 这些字段由 Composition 消费、不属于任何 slide 载荷;「整包态」binder 会把它们
68
- 混进 templateData,出现在那里是正当的,不该被当成未声明字段。名单是模板的事实,
69
- CLI 不保留副本 —— 没声明的模板就是空集。``highlightMap`` 这类跨版式字段走
70
- ``slideSchemas._shared``,不在这里。
71
- """
66
+
67
+
68
+
69
+
70
+
71
+
72
72
  spec = (tpl.get("capabilities") or {}).get("frameLevelKeys")
73
73
  keys = spec.get("keys") if isinstance(spec, dict) else None
74
74
  return frozenset(k for k in keys if isinstance(k, str)) if isinstance(keys, list) else frozenset()
75
75
 
76
- # CJK 汉字范围(简繁通用)+ 常见中文标点
76
+
77
77
  _CJK_PATTERN = re.compile(r"[一-鿿㐀-䶿＀-￯ -〿]")
78
78
 
79
79
 
80
- # ════════════════════════════════════════════════════════════════════════════
81
- # Error type
82
- # ════════════════════════════════════════════════════════════════════════════
80
+
81
+
82
+
83
83
 
84
84
 
85
85
  class ValidationError:
@@ -96,28 +96,28 @@ class ValidationError:
96
96
 
97
97
 
98
98
  def errors_as_strings(errors: Iterable[ValidationError]) -> list[str]:
99
- """Convert ValidationError objects to ``"path: message"`` strings.
100
99
 
101
- Designed for the two historical callers (gen_script / render_video) that
102
- print ``f" - {err}"`` after a header line. We drop the ``[ERROR]`` /
103
- ``[WARNING]`` prefix here because the prior callers' output didn't have
104
- it; the call site already announced "validation failed".
105
- """
100
+
101
+
102
+
103
+
104
+
105
+
106
106
  return [f"{e.path}: {e.message}" for e in errors]
107
107
 
108
108
 
109
- # ════════════════════════════════════════════════════════════════════════════
110
- # Helpers
111
- # ════════════════════════════════════════════════════════════════════════════
109
+
110
+
111
+
112
112
 
113
113
 
114
114
  def _pick_template_id(dsl: dict) -> Optional[str]:
115
- """Pick the templateId from DSL meta (single source of truth).
116
115
 
117
- ``renderHints.templatePreference`` was the legacy location and is no
118
- longer generated by gen_script. We still tolerate it during a
119
- transition period, but new authors should write ``meta.templateId``.
120
- """
116
+
117
+
118
+
119
+
120
+
121
121
  meta = dsl.get("meta") or {}
122
122
  if isinstance(meta, dict):
123
123
  tid = meta.get("templateId")
@@ -131,33 +131,33 @@ def _pick_template_id(dsl: dict) -> Optional[str]:
131
131
 
132
132
 
133
133
  def _line_sync_slides_for_template(template_id: Optional[str]) -> dict[str, str]:
134
- """Look up ``lineSyncSlides`` for a template from registry.json.
135
134
 
136
- 新增多项卡片幻灯片只需在 template.json 加一行 ``lineSyncSlides[slideId] = key``,
137
- 不再需要改 remixmate。registry 读取失败(环境缺 registry_loader、HTTP 不通等)
138
- 时返回空 dict,调用方将跳过相关校验。
139
135
 
140
- O(1) lookup via ``registry_loader.get_template``; safe to call once per
141
- scene at 100+ templates without scanning the full list.
142
- """
136
+
137
+
138
+
139
+
140
+
141
+
142
+
143
143
  return line_sync_slides(_get_template_cfg(template_id))
144
144
 
145
145
 
146
146
  def line_sync_slides(tpl: Optional[dict]) -> dict[str, str]:
147
- """模板的 ``lineSyncSlides`` 声明:slideId → templateData 里卡片数组的键。
148
147
 
149
- 校验层(narration.items 数对齐)与 render-video(自动 highlightMap 的上限裁剪)
150
- 读的是同一份声明,CLI 不保留任何卡片键表的副本。
151
- """
148
+
149
+
150
+
151
+
152
152
  spec = (tpl or {}).get("lineSyncSlides") or {}
153
153
  return {k: v for k, v in spec.items() if not k.startswith("_") and isinstance(v, str)}
154
154
 
155
155
 
156
156
  def step_alignment_field(tpl: Optional[dict]) -> Optional[str]:
157
- """模板 ``capabilities.stepAlignment`` 声明的动作数组字段;未声明返回 None。
158
157
 
159
- 只认 ``alignTo`` 缺省或 ``narration-sentences``(第 i 个动作 = 第 i 句旁白)。
160
- """
158
+
159
+
160
+
161
161
  caps = (tpl or {}).get("capabilities")
162
162
  spec = caps.get("stepAlignment") if isinstance(caps, dict) else None
163
163
  if not isinstance(spec, dict):
@@ -169,23 +169,23 @@ def step_alignment_field(tpl: Optional[dict]) -> Optional[str]:
169
169
 
170
170
 
171
171
  def _get_template_cfg(template_id: Optional[str]) -> Optional[dict]:
172
- """Indexed registry lookup with the legacy import fallback.
173
-
174
- Returns None silently when:
175
- - ``template_id`` is empty
176
- - ``registry_loader`` cannot be imported (e.g. unit tests outside the
177
- shared-lib sys.path)
178
- - the registry can't be loaded (network/file/etc.)
179
- - the template isn't in the visible set (e.g. beta gate hides it)
180
-
181
- 最后那一条是**静默跳过整套模板契约校验**的入口 —— payload 必填字段、版式字段名、
182
- 逐句对齐全部形同不存在。它由 ``_check_template_status_gate`` 单独报一条错来兜底,
183
- 所以这里保持返回 None 不变(否则每个 ``_check_*`` 都会为同一件事各报一遍)。
184
- """
172
+
173
+
174
+
175
+
176
+
177
+
178
+
179
+
180
+
181
+
182
+
183
+
184
+
185
185
  if not template_id:
186
186
  return None
187
187
  try:
188
- from registry_loader import get_template # type: ignore
188
+ from registry_loader import get_template
189
189
  except ImportError:
190
190
  return None
191
191
  try:
@@ -195,25 +195,25 @@ def _get_template_cfg(template_id: Optional[str]) -> Optional[dict]:
195
195
 
196
196
 
197
197
  def _check_template_status_gate(dsl: dict) -> list[ValidationError]:
198
- """模板存在、却被状态门控挡在可见集合外 → 一条 error。
199
-
200
- 为什么必须报错而不是继续:下面所有读 ``_get_template_cfg`` 的检查(payload 必填、
201
- slideSchemas 字段名、lineSyncSlides)拿到 None 之后都是 ``return []`` —— 于是一个
202
- beta 模板的 DSL 能"零错误"通过校验,再一路烧掉 TTS 与渲染积分产出空壳画面。
203
- contracts 测试之所以没发现这一点,是因为它们 patch 掉了 ``_get_template_cfg``,
204
- 生产里的门控在测试里从不生效。
205
- """
198
+
199
+
200
+
201
+
202
+
203
+
204
+
205
+
206
206
  template_id = _pick_template_id(dsl)
207
207
  if not template_id:
208
208
  return []
209
209
  try:
210
- from registry_loader import gated_status # type: ignore
210
+ from registry_loader import gated_status
211
211
  except ImportError:
212
212
  return []
213
213
  try:
214
214
  status = gated_status(template_id)
215
215
  except Exception:
216
- # registry 不可达等问题由各自的加载路径报,这里不重复成第二条噪声。
216
+
217
217
  return []
218
218
  if not status:
219
219
  return []
@@ -231,9 +231,9 @@ def _contains_cjk(text: str) -> bool:
231
231
  return bool(text) and bool(_CJK_PATTERN.search(text))
232
232
 
233
233
 
234
- # ════════════════════════════════════════════════════════════════════════════
235
- # Granular checks
236
- # ════════════════════════════════════════════════════════════════════════════
234
+
235
+
236
+
237
237
 
238
238
 
239
239
  def _check_version(dsl: dict) -> list[ValidationError]:
@@ -246,7 +246,7 @@ def _check_version(dsl: dict) -> list[ValidationError]:
246
246
 
247
247
 
248
248
  def _check_meta_presence(dsl: dict) -> list[ValidationError]:
249
- """gen-script 历史:meta 必须存在且至少有 title。"""
249
+
250
250
  errors: list[ValidationError] = []
251
251
  meta = dsl.get("meta")
252
252
  if not isinstance(meta, dict):
@@ -258,14 +258,14 @@ def _check_meta_presence(dsl: dict) -> list[ValidationError]:
258
258
 
259
259
 
260
260
  def _check_global_presence(dsl: dict) -> list[ValidationError]:
261
- """gen-script 历史:global 必须存在(只检查存在性)。"""
261
+
262
262
  if not isinstance(dsl.get("global"), dict):
263
263
  return [ValidationError("global", "required and must be an object")]
264
264
  return []
265
265
 
266
266
 
267
267
  def _check_global_enums(dsl: dict) -> list[ValidationError]:
268
- """完整版额外:global.aspectRatio / resolution 必须是已知枚举。"""
268
+
269
269
  errors: list[ValidationError] = []
270
270
  g = dsl.get("global")
271
271
  if isinstance(g, dict):
@@ -283,7 +283,7 @@ def _check_global_enums(dsl: dict) -> list[ValidationError]:
283
283
 
284
284
 
285
285
  def _check_scenes_min(dsl: dict) -> list[ValidationError]:
286
- """Scenes 至少 1。两个 preset 共用。"""
286
+
287
287
  scenes = dsl.get("scenes", [])
288
288
  if not isinstance(scenes, list) or len(scenes) == 0:
289
289
  return [ValidationError("scenes", "must contain at least 1 scene")]
@@ -291,17 +291,17 @@ def _check_scenes_min(dsl: dict) -> list[ValidationError]:
291
291
 
292
292
 
293
293
  def _check_scenes_max(dsl: dict) -> list[ValidationError]:
294
- """Scenes 上限。仅 gen-script 历史规则集 + 完整版用——render-video
295
- 历史不查上限,合并时若同时启用此检查会"原本能过的 DSL 现在被拒",
296
- 故只在 ``validate_structural`` / ``validate_dsl`` 启用。
297
-
298
- 上限由模板的 ``capabilities.maxScenes`` 声明,缺省 ``DEFAULT_MAX_SCENES``(20)。
299
- 20 这个数字是给"叙事弧"类模板定的——一条 60 秒短视频排到 20 段已经过碎。但
300
- "一页素材一个场景"的模板(逐页讲解一份源文件)的场景数由源文件页数决定,
301
- 跟叙事节奏无关:2026-09-23 线上一份 23 页 PPT 就是在这里被砍到 20 页,
302
- 而且砍得毫无声响,直到下游报 ``customPayload.slide (required)`` ×20 才暴露。
303
- 所以上限改成模板自己声明,并且这里的报错要说清它是谁定的。
304
- """
294
+
295
+
296
+
297
+
298
+
299
+
300
+
301
+
302
+
303
+
304
+
305
305
  scenes = dsl.get("scenes", [])
306
306
  if not isinstance(scenes, list):
307
307
  return []
@@ -320,7 +320,7 @@ def _check_scenes_max(dsl: dict) -> list[ValidationError]:
320
320
 
321
321
 
322
322
  def _check_scene_required_fields(dsl: dict) -> list[ValidationError]:
323
- """gen-script 历史:每个 scene 必须有 id 和 purpose。"""
323
+
324
324
  errors: list[ValidationError] = []
325
325
  for i, scene in enumerate(dsl.get("scenes", []) or []):
326
326
  if not scene.get("id"):
@@ -331,7 +331,7 @@ def _check_scene_required_fields(dsl: dict) -> list[ValidationError]:
331
331
 
332
332
 
333
333
  def _check_scene_id_uniqueness(dsl: dict) -> list[ValidationError]:
334
- """完整版额外:scene.id 唯一性。"""
334
+
335
335
  errors: list[ValidationError] = []
336
336
  seen: set = set()
337
337
  for i, scene in enumerate(dsl.get("scenes", []) or []):
@@ -346,7 +346,7 @@ def _check_scene_id_uniqueness(dsl: dict) -> list[ValidationError]:
346
346
 
347
347
 
348
348
  def _check_scene_enum_warnings(dsl: dict) -> list[ValidationError]:
349
- """完整版额外:scene.purpose / scene.layout 枚举(severity=warning)。"""
349
+
350
350
  errors: list[ValidationError] = []
351
351
  for i, scene in enumerate(dsl.get("scenes", []) or []):
352
352
  purpose = scene.get("purpose")
@@ -365,7 +365,7 @@ def _check_scene_enum_warnings(dsl: dict) -> list[ValidationError]:
365
365
 
366
366
 
367
367
  def _check_asset_duplicates(dsl: dict) -> list[ValidationError]:
368
- """render-video 历史:assets 数组里 assetId 重复检查。"""
368
+
369
369
  errors: list[ValidationError] = []
370
370
  counts: dict[str, int] = {}
371
371
  for a in dsl.get("assets", []) or []:
@@ -380,7 +380,7 @@ def _check_asset_duplicates(dsl: dict) -> list[ValidationError]:
380
380
 
381
381
 
382
382
  def _check_asset_enums(dsl: dict) -> list[ValidationError]:
383
- """完整版额外:asset.type / asset.source 枚举。"""
383
+
384
384
  errors: list[ValidationError] = []
385
385
  for i, asset in enumerate(dsl.get("assets", []) or []):
386
386
  atype = asset.get("type")
@@ -397,10 +397,10 @@ def _check_asset_enums(dsl: dict) -> list[ValidationError]:
397
397
 
398
398
 
399
399
  def _check_scene_asset_refs(dsl: dict) -> list[ValidationError]:
400
- """render-video 历史:scene 引用的 assetRef 必须在 assets[] 声明过。
401
400
 
402
- 包括 customPayload / templateData 中的嵌套 assetRef 与 global 引用。
403
- """
401
+
402
+
403
+
404
404
  errors: list[ValidationError] = []
405
405
  declared = {
406
406
  a.get("assetId") for a in (dsl.get("assets") or []) if a.get("assetId")
@@ -414,7 +414,7 @@ def _check_scene_asset_refs(dsl: dict) -> list[ValidationError]:
414
414
 
415
415
 
416
416
  def _check_assetbindings_deprecation(dsl: dict) -> list[ValidationError]:
417
- """完整版额外:assetBindings 引用未知 id 时给 warning + 提示已弃用。"""
417
+
418
418
  errors: list[ValidationError] = []
419
419
  declared = {
420
420
  a.get("assetId") for a in (dsl.get("assets") or []) if a.get("assetId")
@@ -432,20 +432,20 @@ def _check_assetbindings_deprecation(dsl: dict) -> list[ValidationError]:
432
432
 
433
433
 
434
434
  def _check_narration_items_text_coexist(dsl: dict) -> list[ValidationError]:
435
- """分行旁白与整段 ``narration.text`` 并存 → warning(text 会被整个忽略)。
436
435
 
437
- 渲染侧是 **items 优先、text 完全忽略**:``extract_narration_lines`` 一旦拿到
438
- items,TTS 文本就是 ``"\\n".join(items)``,字幕也改走 ``split_subtitle_from_lines``,
439
- ``narration.text`` 既不喂 TTS 也不切字幕。
440
436
 
441
- 为什么值得报:gen_script 的骨架**总是**写一条 ``narration.text``,agent 之后为
442
- 逐行高亮补 items 时不会删它,于是两者并存几乎是这类模板的默认结局。留着的那
443
- 一份是死数据 —— 它曾经在旁白编辑面板里被当成"可以单独改、单独重新生成"的一
444
- 行摆出来(实测一个场景因此显示三行,其中一行是另外两行拼起来的),改它不影响
445
- 成片,生成它照样扣积分。
446
437
 
447
- 只报 warning 不拒绝:两者并存不会让画面出错,它只是让人误以为 text 还有用。
448
- """
438
+
439
+
440
+
441
+
442
+
443
+
444
+
445
+
446
+
447
+
448
+
449
449
  errors: list[ValidationError] = []
450
450
  for idx, scene in enumerate(dsl.get("scenes", []) or []):
451
451
  sid = scene.get("id", f"scenes[{idx}]")
@@ -477,12 +477,12 @@ def _check_narration_items_text_coexist(dsl: dict) -> list[ValidationError]:
477
477
 
478
478
 
479
479
  def _check_narration_items_count(dsl: dict) -> list[ValidationError]:
480
- """render-video / 完整版:结构化 narration items 数与 templateData 的对应数组等长。
481
480
 
482
- 哪些 slideId 走该校验,由模板的 ``lineSyncSlides`` 字段声明(P2.2 起);
483
- registry 不可用或模板没声明则跳过。两个 preset 共用本检查器,统一来源
484
- 避免 render-video 与完整版的算法继续漂移。
485
- """
481
+
482
+
483
+
484
+
485
+
486
486
  errors: list[ValidationError] = []
487
487
  template_id = _pick_template_id(dsl)
488
488
  line_sync_slides = _line_sync_slides_for_template(template_id)
@@ -502,7 +502,7 @@ def _check_narration_items_count(dsl: dict) -> list[ValidationError]:
502
502
  card_list = tdata.get(items_key)
503
503
  if not isinstance(card_list, list):
504
504
  continue
505
- # render-video 历史只在 actual!=expected 时报;完整版也是同样语义。
505
+
506
506
  actual = len([x for x in items if (isinstance(x, str) and x.strip()) or isinstance(x, dict)])
507
507
  expected = len(card_list)
508
508
  if actual != expected:
@@ -515,29 +515,29 @@ def _check_narration_items_count(dsl: dict) -> list[ValidationError]:
515
515
  return errors
516
516
 
517
517
 
518
- # gen_script 写进骨架的「作者还没选版式」哨兵。
519
- #
520
- # 两处必须是同一个字面量,而 gen-script 与 template-registry 是两个独立 skill 目录、
521
- # 没有共享模块可以 import,所以这里复制一份。改动需同步 gen_script.py 的
522
- # ``SLIDE_ID_SENTINEL``(那边的注释也指回这里)。
518
+
519
+
520
+
521
+
522
+
523
523
  SLIDE_ID_SENTINEL = "__CHOOSE_SLIDE__"
524
524
 
525
525
 
526
526
  def _check_slide_id_chosen(dsl: dict) -> list[ValidationError]:
527
- """多版式模板的每个场景都必须显式选一个 ``customPayload.slideId``。
528
527
 
529
- 为什么需要这道门禁:slideId 缺失(或还是哨兵)时,渲染端会静默回落到
530
- ``DefaultSlide`` —— 渲染成功、零日志、退出码 0,画面只剩一行居中标题。这是整条
531
- 管线里最安静的失效方式,看起来"像是模板本来就长这样",只有付完渲染的钱才会发现。
532
528
 
533
- **判定「是不是多版式模板」不写死模板 id**,而是看模板自己声明的
534
- ``customPayloadSchema.slideId.enum`` 有没有超过一个取值 —— 有得挑才要求挑。
535
- 单版式模板(枚举只有一项或压根没声明)不拦:那里没有可选的东西,报错只会变成
536
- 修不掉的常驻噪音。
537
529
 
538
- 报错文案直接把该模板全部可选 slideId 列出来,因为收到这条错误的调用方多半
539
- 正是那个"不知道有哪些版式"的 agent。
540
- """
530
+
531
+
532
+
533
+
534
+
535
+
536
+
537
+
538
+
539
+
540
+
541
541
  tpl = _get_template_cfg(_pick_template_id(dsl))
542
542
  if not tpl:
543
543
  return []
@@ -574,19 +574,19 @@ def _check_slide_id_chosen(dsl: dict) -> list[ValidationError]:
574
574
 
575
575
 
576
576
  def _check_slide_cross_field(dsl: dict) -> list[ValidationError]:
577
- """字段之间的一致性 —— 单看任何一个字段都合法,凑在一起才不成立。
578
577
 
579
- schema 管不了这一类:它逐字段校验类型与枚举,表达不了"这个数必须落在另一个字段的
580
- 长度内"。三条规则都**静态可判定**,而且失败形态一致 —— 画面少东西、零报错:
581
578
 
582
- · ``annotations[].line`` 指向不存在的代码行 → 那句旁白期间没有任何一行点亮
583
- · ``highlightMap`` 的值越过卡片数组 → 那一行全屏变暗、无一张点亮
584
- · 黑板版 ``highlight`` 不是 ``title`` 的子串 → 标题静默退回单色(模板 llmHint
585
- 自己写明了这个行为,正因为写明了才更该校验)
586
579
 
587
- 与 `_check_slide_payload_fields` 分开是因为那个函数按 schema 逐层递归,这里要的是
588
- 跨字段视角;混在一起会让两边都难读。
589
- """
580
+
581
+
582
+
583
+
584
+
585
+
586
+
587
+
588
+
589
+
590
590
  tpl = _get_template_cfg(_pick_template_id(dsl))
591
591
  if not tpl:
592
592
  return []
@@ -612,7 +612,7 @@ def _check_slide_cross_field(dsl: dict) -> list[ValidationError]:
612
612
  continue
613
613
  base = f"scenes[{idx}].customPayload.templateData"
614
614
 
615
- # ① 注解行号 ≤ 代码行数。`line` 是 1 起算的(契约里写明了)。
615
+
616
616
  code = tdata.get("code")
617
617
  annotations = tdata.get("annotations")
618
618
  if isinstance(code, str) and isinstance(annotations, list):
@@ -631,7 +631,7 @@ def _check_slide_cross_field(dsl: dict) -> list[ValidationError]:
631
631
  "up at all. Line numbers are 1-based.",
632
632
  ))
633
633
 
634
- # ② highlightMap 的值必须落在卡片数组里。
634
+
635
635
  hmap = tdata.get("highlightMap")
636
636
  cards_key = line_sync.get(slide_id)
637
637
  cards = tdata.get(cards_key) if cards_key else None
@@ -647,8 +647,8 @@ def _check_slide_cross_field(dsl: dict) -> list[ValidationError]:
647
647
  "line every card goes dim and none lights up.",
648
648
  ))
649
649
 
650
- # ③ 黑板版的 highlight 必须是 title 的子串。字段是否存在由 schema 决定,
651
- # 这里不写死模板名 —— 没有声明 highlight 的模板天然跳过。
650
+
651
+
652
652
  if "highlight" in ((schemas.get(slide_id) or {}).get("properties") or {}):
653
653
  highlight = tdata.get("highlight")
654
654
  title = tdata.get("title")
@@ -669,23 +669,23 @@ def _check_slide_cross_field(dsl: dict) -> list[ValidationError]:
669
669
 
670
670
 
671
671
  def _check_line_sync_narration(dsl: dict) -> list[ValidationError]:
672
- """走逐行高亮的版式,旁白必须是**分行**的,否则整屏一个卡片都不会点亮。
673
-
674
- 为什么必须拦:这是"画面看起来没渲染完"的主路径,而且全链路零日志。链条是——
675
- 旁白只给了整段 ``text``(单卡版式的合法写法)→ 绑定不写 ``narrationLineCount``
676
- → ``render_video`` 的 ``_auto_highlight_map`` 在第一个 if 就 return → 没有
677
- ``highlightMap`` → 模板侧 ``resolveHighlightMap`` 同样拿不到行数 →
678
- ``activeHighlightIndex`` 恒为 -1 → **整场所有卡片停在未点亮态**。
679
- 连 ``_auto_highlight_map`` 那条 warn 日志都打不出来,因为它在更前面就返回了。
680
-
681
- 哪些版式受约束由模板的 ``lineSyncSlides`` 声明,不写死模板名/版式名。
682
-
683
- 两个逃生阀(命中任一即放行,都是模板自己声明的能力,不是本函数的特例):
684
- - ``templateData.highlightMap`` —— 作者显式给了映射表,那他知道自己在做什么;
685
- - schema 里标了 ``x-staticHighlight`` 的字段(如 timeline-axis 的
686
- ``progressIndex``、timeline / column-compare 的 ``highlightIndex``)——
687
- 这些字段的存在本身就是"本屏走静态高亮、不跟旁白走"的声明。
688
- """
672
+
673
+
674
+
675
+
676
+
677
+
678
+
679
+
680
+
681
+
682
+
683
+
684
+
685
+
686
+
687
+
688
+
689
689
  tpl = _get_template_cfg(_pick_template_id(dsl))
690
690
  if not tpl:
691
691
  return []
@@ -710,7 +710,7 @@ def _check_line_sync_narration(dsl: dict) -> list[ValidationError]:
710
710
  if isinstance(items, list) and any(
711
711
  (isinstance(x, str) and x.strip()) or isinstance(x, dict) for x in items
712
712
  ):
713
- continue # 有分行旁白,数量对不对交给 _check_narration_items_count
713
+ continue
714
714
 
715
715
  tdata = custom.get("templateData")
716
716
  if not isinstance(tdata, dict):
@@ -745,24 +745,24 @@ def _check_line_sync_narration(dsl: dict) -> list[ValidationError]:
745
745
 
746
746
 
747
747
  def _check_step_narration_alignment(dsl: dict) -> list[ValidationError]:
748
- """声明「动作与旁白一一对应」的模板:动作条数应当等于旁白句数。
749
748
 
750
- 由模板的 ``capabilities.stepAlignment`` 声明触发,不写死模板名::
751
749
 
752
- "stepAlignment": {
753
- "payloadField": "steps", # customPayload 里放动作数组的字段
754
- "alignTo": "narration-sentences"
755
- }
756
750
 
757
- 为什么值得单独查:这一类失配**渲得出来**。动作按句下标对齐,条数不等时多出的
758
- 句子没有任何动作(画面停住)或多出的动作挤在最后一句上,而退出码是 0、
759
- renderJob 是 completed、漏斗记 rendered —— 缺陷只存在于成片里,没有任何
760
- 自动信号会告状。2026-09-23 线上那条鸡兔同笼就是这么交付的。
761
751
 
762
- 严重度是 **warning**:步数少于句数有时确实是作者的本意(末尾一句总结不配动作)。
763
- 真正要判断的是"作者以为一一对应、实际不是",只有作者做得了这个判断,所以这里
764
- 给两个数字,不给结论。但它必须被打出来 —— 上一版连数字都没有。
765
- """
752
+
753
+
754
+
755
+
756
+
757
+
758
+
759
+
760
+
761
+
762
+
763
+
764
+
765
+
766
766
  tpl = _get_template_cfg(_pick_template_id(dsl))
767
767
  if not tpl:
768
768
  return []
@@ -782,11 +782,11 @@ def _check_step_narration_alignment(dsl: dict) -> list[ValidationError]:
782
782
  continue
783
783
  steps = custom.get(field)
784
784
  if not isinstance(steps, list) or not steps:
785
- continue # 不给 steps 走模板自己的兜底,不是本检查的事
785
+ continue
786
+
787
+
788
+
786
789
 
787
- # 全部显式写了 atSec 的步骤不参与下标对齐,不该算失配。
788
- # 排除 bool:Python 里 True 是 int 的实例,而渲染端用 `typeof === "number"`
789
- # 判断,不认 true。两边口径必须一致,否则这里放行的东西那边仍按下标对齐。
790
790
  def _has_explicit_at_sec(step: object) -> bool:
791
791
  if not isinstance(step, dict):
792
792
  return False
@@ -823,16 +823,16 @@ def _check_step_narration_alignment(dsl: dict) -> list[ValidationError]:
823
823
 
824
824
 
825
825
  def _check_duration_estimate(dsl: dict) -> list[ValidationError]:
826
- """按旁白字数估出的成片时长明显超过 ``meta.targetDuration`` → warning。
827
826
 
828
- 为什么要有:旁白字数决定时长,但没有任何一环在出素材之前核对过。2026-09-27 线上
829
- 一条 html-slide 会话,确认稿写 75s、每场 8–9s,每场却写了 45–58 字,还有 7 场是
830
- 分行旁白(每行之间有停顿)—— 成片 116.9s,用户确认的是一个从来不可能兑现的数。
831
827
 
832
- 只报 warning 不拦:走到这里用户已经确认过脚本,再拦只会多一轮来回;它存在的意义
833
- 是让这件事**有人看见**(模型据此如实告知用户,复盘据此定位)。估算口径见
834
- ``narration_budget``。骨架占位旁白还没填时不估 —— 那是另一条检查的事。
835
- """
828
+
829
+
830
+
831
+
832
+
833
+
834
+
835
+
836
836
  try:
837
837
  from . import narration_budget as nb
838
838
  except ImportError:
@@ -883,22 +883,22 @@ def _check_duration_estimate(dsl: dict) -> list[ValidationError]:
883
883
 
884
884
 
885
885
  def _check_slide_payload_fields(dsl: dict) -> list[ValidationError]:
886
- """逐版式校验 ``customPayload.templateData`` 的字段名。
887
-
888
- 为什么需要这道门禁:多版式模板里「slideId 决定 templateData 吃哪些字段」这条
889
- 契约,此前只存在于模板的 React 源码里 —— 而写 DSL 的 agent 只能看到 registry
890
- 下发的 ``template.json``。字段名猜错(把 ``name`` 写成 ``title``、把 ``detail``
891
- 写成 ``description``)时组件读到 undefined,要么渲染出内置的占位文案、要么整块
892
- 空白,**渲染成功、零日志、退出码 0**。真实案例:一份 7 场景的 DSL 里 5 个场景
893
- 中招,TTS 和渲染的钱全花完才在成片里看出来。
894
-
895
- 契约现在写在 ``template.json`` 的 ``slideSchemas`` 里(键 = slideId,值 = 该版式
896
- 的 JSON Schema),由 template-library 的 ``check:slide-schemas`` 与组件源码双向
897
- 对账。本函数按它核对每个场景,**未声明的字段名带 "did you mean" 提示** ——
898
- 收到这条错误的多半正是那个猜错了名字的 agent,直接把正确的名字给它。
899
-
900
- 没有声明 ``slideSchemas`` 的模板(全部单版式模板)一律跳过,不产生噪音。
901
- """
886
+
887
+
888
+
889
+
890
+
891
+
892
+
893
+
894
+
895
+
896
+
897
+
898
+
899
+
900
+
901
+
902
902
  tpl = _get_template_cfg(_pick_template_id(dsl))
903
903
  if not tpl:
904
904
  return []
@@ -925,11 +925,11 @@ def _check_slide_payload_fields(dsl: dict) -> list[ValidationError]:
925
925
 
926
926
  slide_id = custom.get("slideId") or data.get("slideId")
927
927
  if not isinstance(slide_id, str) or not slide_id:
928
- continue # 「没选版式」由 _check_slide_id_chosen 负责报
928
+ continue
929
929
 
930
930
  schema = schemas.get(slide_id)
931
931
  if not isinstance(schema, dict):
932
- # 版式名写错 —— 同样静默回落到 DefaultSlide,和没写一样致命。
932
+
933
933
  errors.append(ValidationError(
934
934
  f"scenes[{idx}].customPayload.slideId",
935
935
  f"scene '{sid}' uses slideId '{slide_id}', which template '{tid}' does not "
@@ -944,8 +944,8 @@ def _check_slide_payload_fields(dsl: dict) -> list[ValidationError]:
944
944
  path=f"scenes[{idx}].customPayload.templateData",
945
945
  scene=sid,
946
946
  extra_allowed=shared,
947
- # frame 级字段由 Composition 消费,「整包态」binder 会把它们混进
948
- # templateData —— 出现在这里是正当的,不算未声明字段。
947
+
948
+
949
949
  skip_keys=_frame_level_keys(tpl),
950
950
  )
951
951
 
@@ -953,15 +953,15 @@ def _check_slide_payload_fields(dsl: dict) -> list[ValidationError]:
953
953
 
954
954
 
955
955
  def _check_custom_payload_fields(dsl: dict) -> list[ValidationError]:
956
- """单版式模板:按 ``customPayloadSchema`` 逐字段核对 ``customPayload``。
957
956
 
958
- 与 `_check_slide_payload_fields` 是同一件事的两种形态 —— 多版式模板的契约按 slideId
959
- 分支(``slideSchemas``),单版式模板只有一份(``customPayloadSchema``)。多版式模板在
960
- 这里**跳过**,避免同一份数据被两套规则各报一遍。
961
957
 
962
- 为什么值得做:在此之前,10 个模板里只有 2 个的字段名会被校验,其余 8 个写错字段名
963
- 照样一路渲染到成片。契约本来就在 ``template.json`` 里躺着,只是没人读。
964
- """
958
+
959
+
960
+
961
+
962
+
963
+
964
+
965
965
  tpl = _get_template_cfg(_pick_template_id(dsl))
966
966
  if not tpl:
967
967
  return []
@@ -975,7 +975,7 @@ def _check_custom_payload_fields(dsl: dict) -> list[ValidationError]:
975
975
  errors += [ValidationError(f"scenes[{idx}].customPayload", message)
976
976
  for message in required_payload_errors(tpl, custom)]
977
977
  if isinstance(tpl.get("slideSchemas"), dict):
978
- return errors # 多版式字段走 _check_slide_payload_fields
978
+ return errors
979
979
  cps = tpl.get("customPayloadSchema")
980
980
  if not isinstance(cps, dict) or not cps:
981
981
  return errors
@@ -1004,16 +1004,16 @@ def _walk_payload(
1004
1004
  skip_keys: frozenset = frozenset(),
1005
1005
  fallback_desc: Optional[str] = None,
1006
1006
  ) -> list[ValidationError]:
1007
- """沿 schema 递归下钻,逐层做「未声明字段 + 缺必填 + 空数组」核对。
1008
1007
 
1009
- 只下钻**描述了形状的**层:schema 节点没有 ``properties`` 就停(那是作者显式开的
1010
- 开放包,如 ppt-to-video 由抽取脚本生成的 ``slide.nodes[]``),不去猜它的字段。
1011
1008
 
1012
- ⚠️ 这里刻意**不做**类型 / enum / 区间校验 —— 那些由 template-library 的
1013
- `check:payload` 在仓库侧对 dsl-example 做,已有一份完整实现。运行时这份只管
1014
- "字段名对不对、该给的给没给",两边职责不重叠,也就不需要在 Python 里再养一份
1015
- JSON Schema 引擎(这个仓库已经被"同一算法两份实现"坑过不止一次)。
1016
- """
1009
+
1010
+
1011
+
1012
+
1013
+
1014
+
1015
+
1016
+
1017
1017
  errors = _check_payload_object(
1018
1018
  data,
1019
1019
  schema,
@@ -1059,7 +1059,7 @@ def _check_payload_object(
1059
1059
  skip_keys: frozenset = frozenset(),
1060
1060
  fallback_desc: Optional[str] = None,
1061
1061
  ) -> list[ValidationError]:
1062
- """一层对象的「未声明字段 + 缺必填字段 + 空数组」核对。数组元素与顶层共用这一份。"""
1062
+
1063
1063
  props = schema.get("properties")
1064
1064
  if not isinstance(props, dict):
1065
1065
  return []
@@ -1086,8 +1086,8 @@ def _check_payload_object(
1086
1086
  "the slide renders its built-in placeholder instead of your content.",
1087
1087
  ))
1088
1088
 
1089
- # 空数组 = 空屏。这些数组就是版式的画面主体,给了个 [] 与没给没有区别,
1090
- # 但 required 判定不出来(键确实在)。用 schema 的 minItems 表达,不写死键名。
1089
+
1090
+
1091
1091
  for key, sub in props.items():
1092
1092
  if not isinstance(sub, dict) or sub.get("type") != "array":
1093
1093
  continue
@@ -1105,12 +1105,12 @@ def _check_payload_object(
1105
1105
 
1106
1106
 
1107
1107
  def _is_blank(value: object) -> bool:
1108
- """必填判定:``None`` 与纯空白字符串都算没给。
1109
1108
 
1110
- 为什么空串也算:组件里普遍写成 ``props.title || "占位文案"``,而 ``""`` 是 falsy ——
1111
- 写空串拿到的**同样是占位文案**,不是空白。也就是说空串从来就不是"有意留空"的表达,
1112
- 只是另一种漏填。放行它等于给静默失效留一道后门。
1113
- """
1109
+
1110
+
1111
+
1112
+
1113
+
1114
1114
  if value is None:
1115
1115
  return True
1116
1116
  return isinstance(value, str) and not value.strip()
@@ -1122,23 +1122,23 @@ def _suggest_field(
1122
1122
  schema: dict,
1123
1123
  fallback_desc: Optional[str] = None,
1124
1124
  ) -> str:
1125
- """把写错的字段名换成一句能照着改的提示。三级降级。
1126
-
1127
- ① **在各字段的 description 里搜「不是 <wrong>」**。写契约时易错点已经写进了描述
1128
- (``卡片标题(⚠️ 键名是 name,不是 title)``),直接拿错的名字去搜即可。实测对
1129
- 真实事故里那 6 个错误 6/6 命中,而 difflib 是 0/6 —— 因为 title↔name、
1130
- description↔detail 是**同义词**而非拼写错误,字符串相似度对它们天然无效。
1131
- 这一级还自带维护性:提示与契约同处一地,改字段名时顺手就改了。
1132
- ② difflib —— 留给真正的拼写错误(``titel`` → ``title``)。
1133
- ③ 列出全部合法字段,并附上**该版式自己的 description**。后者回答的是"那我该用哪个
1134
- 版式":demo-concept-overview 的描述里就写着「卡片不支持描述文字,要配说明改用
1135
- feature-grid」,而这正是写错的人真正需要的下一步。
1136
- """
1125
+
1126
+
1127
+
1128
+
1129
+
1130
+
1131
+
1132
+
1133
+
1134
+
1135
+
1136
+
1137
1137
  for name, sub in props.items():
1138
1138
  desc = (sub or {}).get("description") if isinstance(sub, dict) else None
1139
1139
  if not isinstance(desc, str):
1140
1140
  continue
1141
- # 「不是 title」/「不是 title,」/「不是 `title`」都要认。
1141
+
1142
1142
  if re.search(rf"不是\s*[`'\"]?{re.escape(wrong)}[`'\"]?(?![\w-])", desc):
1143
1143
  return f" This layout calls it '{name}' — {desc}"
1144
1144
 
@@ -1147,10 +1147,10 @@ def _suggest_field(
1147
1147
  return f" Did you mean '{hit[0]}'?"
1148
1148
 
1149
1149
  tail = f" Declared fields: {', '.join(sorted(props))}."
1150
- # 走到这一级说明「改个名字」解决不了 —— 多半是这个版式压根没有这种能力。
1151
- # 把版式自己的说明带出来,那里通常写着该换成哪个版式(demo-concept-overview 的
1152
- # 描述就写着「卡片不支持描述文字,每项要配一句说明时改用 feature-grid」)。
1153
- # 嵌套 item 层没有自己的 description,所以由调用方把版式级的那句传进来。
1150
+
1151
+
1152
+
1153
+
1154
1154
  slide_desc = schema.get("description") or fallback_desc
1155
1155
  if isinstance(slide_desc, str) and slide_desc.strip():
1156
1156
  tail += f" About this layout: {slide_desc}"
@@ -1158,26 +1158,26 @@ def _suggest_field(
1158
1158
 
1159
1159
 
1160
1160
  def _check_narration_language_strict(dsl: dict) -> list[ValidationError]:
1161
- """完整版:模板若声明 ``capabilities.narrationLanguageStrict`` 则强约束旁白语言。
1162
-
1163
- Generic capability-driven replacement for what used to be a hardcoded
1164
- ``picture-book-en`` CJK check. Templates declare which language the
1165
- narration MUST be in (currently supported: ``"en"`` rejects any CJK
1166
- character; ``"zh"`` is a placeholder reserved for future symmetric
1167
- enforcement — we don't yet flag latin-only narration on zh templates
1168
- because gen-script regularly mixes brand names in English).
1169
-
1170
- Detection points are unchanged from the legacy check:
1171
- - every ``gen-voice`` asset's ``payload.text``
1172
- - every scene's ``audio.narration.text``
1173
- """
1161
+
1162
+
1163
+
1164
+
1165
+
1166
+
1167
+
1168
+
1169
+
1170
+
1171
+
1172
+
1173
+
1174
1174
  tpl = _get_template_cfg(_pick_template_id(dsl))
1175
1175
  if not tpl:
1176
1176
  return []
1177
1177
  strict = (tpl.get("capabilities") or {}).get("narrationLanguageStrict")
1178
1178
  if strict != "en":
1179
- # Today only the "narration must be English" case is enforced;
1180
- # adding "zh" / other languages goes here without touching callers.
1179
+
1180
+
1181
1181
  return []
1182
1182
  tid = tpl.get("templateId", "<unknown>")
1183
1183
  msg = (
@@ -1205,19 +1205,19 @@ _TEXT_LAYER_SKELETON_PREFIXES = ("[骨架待填充]", "[skeleton placeholder]")
1205
1205
 
1206
1206
 
1207
1207
  def _check_text_layer_rules(dsl: dict) -> list[ValidationError]:
1208
- """模板声明 ``capabilities.textLayerRules`` 时,逐场景检查画面文字层。
1209
1208
 
1210
- 声明形状::
1211
1209
 
1212
- "textLayerRules": {
1213
- "required": ["headline", "subheadline"], # 每个场景都必须有且非空
1214
- "language": {"headline": "en", "subheadline": "zh"} # en = 不含中文;zh = 须含中文
1215
- }
1216
1210
 
1217
- 为什么是 error 而不是 llmHint:2026-09-24 线上《狼来了》绘本整本没有一行中文译文,
1218
- 封面把中英文都塞进了 headline —— llmHint 写得清清楚楚,模型没照做,而当时没有
1219
- 任何一道门拦它,成片照出,书页上留着一大块给译文的空白。
1220
- """
1211
+
1212
+
1213
+
1214
+
1215
+
1216
+
1217
+
1218
+
1219
+
1220
+
1221
1221
  tpl = _get_template_cfg(_pick_template_id(dsl))
1222
1222
  if not tpl:
1223
1223
  return []
@@ -1272,19 +1272,19 @@ def _check_text_layer_rules(dsl: dict) -> list[ValidationError]:
1272
1272
  return errors
1273
1273
 
1274
1274
 
1275
- # ════════════════════════════════════════════════════════════════════════════
1276
- # Aggregator presets
1277
- # ════════════════════════════════════════════════════════════════════════════
1275
+
1276
+
1277
+
1278
1278
 
1279
1279
 
1280
1280
  def validate_structural(dsl: dict) -> list[ValidationError]:
1281
- """gen-script 历史规则集 — 仅检查 DSL 整体结构 / 必填字段。
1282
1281
 
1283
- 与历史 ``gen_script.validate_dsl`` 同集合:
1284
- version + meta(title) + global(presence) + scene 数 1–20 + 每场 id/purpose。
1285
1282
 
1286
- 不检查:asset 一致性、enum 枚举、capabilities.narrationLanguageStrict——保持与历史等价。
1287
- """
1283
+
1284
+
1285
+
1286
+
1287
+
1288
1288
  errors: list[ValidationError] = []
1289
1289
  errors += _check_version(dsl)
1290
1290
  errors += _check_meta_presence(dsl)
@@ -1296,51 +1296,51 @@ def validate_structural(dsl: dict) -> list[ValidationError]:
1296
1296
 
1297
1297
 
1298
1298
  def validate_integrity(dsl: dict) -> list[ValidationError]:
1299
- """render-video 历史规则集 — 关注 asset 引用一致性 + 数量对齐。
1300
1299
 
1301
- 与历史 ``render_video.validate_dsl`` 同集合:
1302
- version + scene 数 + assetId 去重 + assetRef 可解析 + 结构化 narration items 数。
1303
1300
 
1304
- 不检查:meta.title、global 必填、enum 枚举、capabilities.narrationLanguageStrict——保持等价。
1305
- """
1301
+
1302
+
1303
+
1304
+
1305
+
1306
1306
  errors: list[ValidationError] = []
1307
1307
  errors += _check_version(dsl)
1308
1308
  errors += _check_scenes_min(dsl)
1309
- # NOTE: render-video 历史只查下限,不查 ≤20 上限——合并时若同启用上限会
1310
- # 把"超 20 场但其它都对"的 DSL 从「通过」改成「拒绝」,违反"重构不影响功能"。
1309
+
1310
+
1311
1311
  errors += _check_asset_duplicates(dsl)
1312
1312
  errors += _check_scene_asset_refs(dsl)
1313
1313
  errors += _check_narration_items_count(dsl)
1314
1314
  errors += _check_narration_items_text_coexist(dsl)
1315
- # 新增(非历史规则):多版式模板必须逐场景选定 slideId。放进 integrity 集合是因为
1316
- # render-video 与 prepare_video_assets 都走这条 —— 而"没选版式"正是要在烧掉渲染
1317
- # 积分之前拦住的东西。单版式模板不受影响,见 _check_slide_id_chosen。
1315
+
1316
+
1317
+
1318
1318
  errors += _check_slide_id_chosen(dsl)
1319
- # 必须排在下面那批模板契约检查**之前**:模板被状态门控挡住时它们会集体空转,
1320
- # 于是"零错误通过"其实是"一条都没查"。见 _check_template_status_gate。
1319
+
1320
+
1321
1321
  errors += _check_template_status_gate(dsl)
1322
- # 同理:字段名写错同样是「渲染成功但画面是空壳」,必须在烧掉 TTS / 渲染积分
1323
- # 之前拦住。只对声明了 slideSchemas 的模板生效,见 _check_slide_payload_fields。
1322
+
1323
+
1324
1324
  errors += _check_slide_payload_fields(dsl)
1325
1325
  errors += _check_custom_payload_fields(dsl)
1326
1326
  errors += _check_line_sync_narration(dsl)
1327
- # warning,不阻断。放在 integrity 里是因为这是 render-video 的入口:失配的后果
1328
- # 是**渲得出来但错位**,只有在真要渲之前说才来得及。
1327
+
1328
+
1329
1329
  errors += _check_step_narration_alignment(dsl)
1330
1330
  errors += _check_slide_cross_field(dsl)
1331
1331
  errors += _check_text_layer_rules(dsl)
1332
- # warning,不阻断。见 _check_duration_estimate。
1332
+
1333
1333
  errors += _check_duration_estimate(dsl)
1334
1334
  return errors
1335
1335
 
1336
1336
 
1337
1337
  def validate_dsl(dsl: dict) -> list[ValidationError]:
1338
- """完整 DSL 校验:结构 + 一致性 + 枚举 + capabilities.narrationLanguageStrict。
1339
1338
 
1340
- 历史上 dsl_validator 暴露的就是这版,无外部 caller(三个真 caller 都
1341
- 走 ``validate_structural`` / ``validate_integrity``)。本函数面向想要"全
1342
- 部规则"的新代码 —— 例如 CI 校验或人工 ``--validate-strict`` 入口。
1343
- """
1339
+
1340
+
1341
+
1342
+
1343
+
1344
1344
  errors: list[ValidationError] = []
1345
1345
  errors += _check_version(dsl)
1346
1346
  errors += _check_meta_presence(dsl)
@@ -1366,6 +1366,6 @@ def validate_dsl(dsl: dict) -> list[ValidationError]:
1366
1366
  errors += _check_step_narration_alignment(dsl)
1367
1367
  errors += _check_slide_cross_field(dsl)
1368
1368
  errors += _check_text_layer_rules(dsl)
1369
- # warning,不阻断。见 _check_duration_estimate。
1369
+
1370
1370
  errors += _check_duration_estimate(dsl)
1371
1371
  return errors