@remixmate/cli 0.9.71 → 0.9.73

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 (181) 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 -188
  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 +53 -13
  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-post/SKILL.md +23 -13
  61. package/skills/gen-post/skill.json +9 -10
  62. package/skills/gen-script/SKILL.md +23 -1
  63. package/skills/gen-script/scripts/gen_script.py +644 -551
  64. package/skills/gen-script/skill.json +12 -0
  65. package/skills/gen-script/version.json +1 -1
  66. package/skills/prepare-video-assets/scripts/prepare_video_assets.py +21 -21
  67. package/skills/render-video/scripts/_chrome_vendor.py +17 -17
  68. package/skills/render-video/scripts/_digital_human_segments.py +34 -34
  69. package/skills/render-video/scripts/_video_probe.py +33 -33
  70. package/skills/render-video/scripts/_vod_polling.py +13 -13
  71. package/skills/render-video/scripts/remote_renderer_client.py +42 -42
  72. package/skills/render-video/scripts/render_video.py +639 -639
  73. package/skills/render-video/scripts/required_props.py +37 -37
  74. package/skills/render-video/scripts/upload_video.py +35 -35
  75. package/skills/render-video/version.json +1 -1
  76. package/skills/template-registry/SKILL.md +16 -0
  77. package/skills/template-registry/scripts/countdown_spec.py +92 -92
  78. package/skills/template-registry/scripts/doc_extract.py +40 -40
  79. package/skills/template-registry/scripts/list_templates.py +303 -191
  80. package/skills/template-registry/scripts/log_diagnostics.py +1 -1
  81. package/skills/template-registry/scripts/match_template.py +87 -87
  82. package/skills/template-registry/scripts/payload_hydrator.py +89 -89
  83. package/skills/template-registry/scripts/registry_loader.py +159 -159
  84. package/skills/template-registry/scripts/render_job_client.py +75 -75
  85. package/skills/template-registry/scripts/showcase_loader.py +44 -44
  86. package/skills/template-registry/skill.json +4 -1
  87. package/skills/template-registry/version.json +1 -1
  88. package/skills/template-registry/video_dsl/runtime/__init__.py +8 -8
  89. package/skills/template-registry/video_dsl/runtime/dsl_validator.py +329 -329
  90. package/skills/template-registry/video_dsl/runtime/narration_budget.py +36 -36
  91. package/skills/template-registry/video_dsl/runtime/payload_contract.py +7 -7
  92. package/skills/template-registry/video_dsl/runtime/prompt_enhancer.py +98 -98
  93. package/skills/template-registry/video_dsl/runtime/timeline_compiler.py +127 -127
  94. package/skills/template-registry/video_dsl/schema/video-dsl-v1alpha1.json +14 -1
  95. package/skills/video-parser/scripts/analyze_video.py +173 -173
  96. package/skills/video-parser/scripts/deconstruct_video.py +91 -91
  97. package/skills/video-parser/scripts/parse_via_render.py +21 -21
  98. package/skills/web-screenshot/scripts/_media_screenshot/__init__.py +4 -4
  99. package/skills/web-screenshot/scripts/_media_screenshot/bootstrap.py +7 -7
  100. package/skills/web-screenshot/scripts/_media_screenshot/browser.py +4 -4
  101. package/skills/web-screenshot/scripts/_media_screenshot/cli_args.py +4 -4
  102. package/skills/web-screenshot/scripts/_media_screenshot/js_loader.py +2 -2
  103. package/skills/web-screenshot/scripts/_media_screenshot/overlay.py +1 -1
  104. package/skills/web-screenshot/scripts/_media_screenshot/reader.py +40 -40
  105. package/skills/web-screenshot/scripts/_media_screenshot/recording.py +11 -11
  106. package/skills/web-screenshot/scripts/_media_screenshot/scenes/__init__.py +8 -8
  107. package/skills/web-screenshot/scripts/_media_screenshot/scenes/arrow.py +2 -2
  108. package/skills/web-screenshot/scripts/_media_screenshot/scenes/caption.py +1 -1
  109. package/skills/web-screenshot/scripts/_media_screenshot/scenes/click.py +1 -1
  110. package/skills/web-screenshot/scripts/_media_screenshot/scenes/code_lines.py +5 -5
  111. package/skills/web-screenshot/scripts/_media_screenshot/scenes/focus.py +1 -1
  112. package/skills/web-screenshot/scripts/_media_screenshot/scenes/highlight.py +3 -3
  113. package/skills/web-screenshot/scripts/_media_screenshot/scenes/hover.py +1 -1
  114. package/skills/web-screenshot/scripts/_media_screenshot/scenes/redact.py +1 -1
  115. package/skills/web-screenshot/scripts/_media_screenshot/scenes/scroll.py +1 -1
  116. package/skills/web-screenshot/scripts/_media_screenshot/scenes/sequence.py +1 -1
  117. package/skills/web-screenshot/scripts/_media_screenshot/scenes/title_card.py +1 -1
  118. package/skills/web-screenshot/scripts/_media_screenshot/scenes/type.py +1 -1
  119. package/skills/web-screenshot/scripts/_media_screenshot/scenes/wait.py +1 -1
  120. package/skills/web-screenshot/scripts/_media_screenshot/scenes/zoom.py +2 -2
  121. package/skills/web-screenshot/scripts/_media_screenshot/screenshot.py +15 -15
  122. package/skills/web-screenshot/scripts/_media_screenshot/storyboard.py +2 -2
  123. package/skills/web-screenshot/scripts/_media_screenshot/template.py +11 -11
  124. package/skills/web-screenshot/scripts/_media_screenshot/trim.py +10 -10
  125. package/skills/web-screenshot/scripts/_media_screenshot/urlguard.py +22 -22
  126. package/skills/web-screenshot/scripts/_media_upload.py +33 -33
  127. package/skills/web-screenshot/scripts/read_page.py +15 -15
  128. package/skills/web-screenshot/scripts/record.py +26 -26
  129. package/skills/web-screenshot/scripts/screenshot.py +5 -5
  130. package/dist/argv.d.ts +0 -15
  131. package/dist/auth/auth-lock.d.ts +0 -26
  132. package/dist/auth/auto-login.d.ts +0 -20
  133. package/dist/auth/commands.d.ts +0 -8
  134. package/dist/auth/credential-store.d.ts +0 -60
  135. package/dist/auth/device-flow-runner.d.ts +0 -58
  136. package/dist/auth/device-flow.d.ts +0 -52
  137. package/dist/auth/ensure.d.ts +0 -55
  138. package/dist/auth/environment.d.ts +0 -38
  139. package/dist/auth/pending-store.d.ts +0 -33
  140. package/dist/auth/resolve.d.ts +0 -30
  141. package/dist/billing.d.ts +0 -44
  142. package/dist/capabilities.d.ts +0 -63
  143. package/dist/cli.d.ts +0 -13
  144. package/dist/diagnostics.d.ts +0 -24
  145. package/dist/doctor.d.ts +0 -22
  146. package/dist/errors.d.ts +0 -29
  147. package/dist/exec.d.ts +0 -18
  148. package/dist/handlers/gen-digital-human.d.ts +0 -17
  149. package/dist/handlers/gen-image.d.ts +0 -20
  150. package/dist/handlers/gen-post.d.ts +0 -21
  151. package/dist/handlers/gen-video.d.ts +0 -19
  152. package/dist/handlers/gen-voice.d.ts +0 -26
  153. package/dist/handlers/index.d.ts +0 -20
  154. package/dist/handlers/shared.d.ts +0 -26
  155. package/dist/handlers/template-init.d.ts +0 -10
  156. package/dist/http.d.ts +0 -72
  157. package/dist/index.d.ts +0 -9
  158. package/dist/install.d.ts +0 -71
  159. package/dist/progress.d.ts +0 -23
  160. package/dist/project/commands.d.ts +0 -19
  161. package/dist/project/host.d.ts +0 -91
  162. package/dist/project/resolve.d.ts +0 -43
  163. package/dist/project/store.d.ts +0 -31
  164. package/dist/project/take.d.ts +0 -50
  165. package/dist/project/trace.d.ts +0 -98
  166. package/dist/registry.d.ts +0 -30
  167. package/dist/runner.d.ts +0 -39
  168. package/dist/skill-schema.d.ts +0 -138
  169. package/dist/template/args.d.ts +0 -25
  170. package/dist/template/check.d.ts +0 -23
  171. package/dist/template/commands.d.ts +0 -25
  172. package/dist/template/constants.d.ts +0 -39
  173. package/dist/template/init.d.ts +0 -28
  174. package/dist/template/list.d.ts +0 -23
  175. package/dist/template/preview.d.ts +0 -60
  176. package/dist/template/proc.d.ts +0 -26
  177. package/dist/template/project.d.ts +0 -47
  178. package/dist/template/publish.d.ts +0 -43
  179. package/dist/template/render-client.d.ts +0 -39
  180. package/dist/template/upgrade.d.ts +0 -44
  181. package/dist/text.d.ts +0 -8
@@ -1,19 +1,19 @@
1
1
  #!/usr/bin/env python3
2
- """
3
- Video script generator — turns a topic into Video DSL v1alpha1 JSON.
4
2
 
5
- Default behavior:
6
- Produces a structured Video DSL JSON to stdout based on the topic, duration,
7
- and other constraints.
8
3
 
9
- Usage:
10
- python gen_script.py --topic "3 AI hacks that double study efficiency" --duration 30 --ratio "9:16"
11
- python gen_script.py --validate --input my-video.dsl.json
12
4
 
13
- Environment variables:
14
- VIDEO_DSL_SCHEMA_PATH - DSL schema file path
15
- (default: skills/template-registry/video_dsl/schema/video-dsl-v1alpha1.json)
16
- """
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
17
 
18
18
  import argparse
19
19
  import json
@@ -30,26 +30,26 @@ SCHEMA_PATH = os.environ.get(
30
30
  "skills/template-registry/video_dsl/schema/video-dsl-v1alpha1.json",
31
31
  )
32
32
 
33
- # prompt_enhancer 路径动态添加
33
+
34
34
  _TEMPLATE_DSL_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "template-registry", "video_dsl")
35
35
  if os.path.isdir(_TEMPLATE_DSL_DIR):
36
36
  sys.path.insert(0, os.path.abspath(os.path.join(_TEMPLATE_DSL_DIR, "..")))
37
37
 
38
- # Shared Python lib lives under skills/template-registry/scripts/ — the de-facto
39
- # cross-skill module location (registry_loader, countdown_spec, etc.). See
40
- # AGENTS.md for the convention.
38
+
39
+
40
+
41
41
  _SHARED_SCRIPTS_DIR = os.path.join(
42
42
  os.path.dirname(__file__), "..", "..", "template-registry", "scripts"
43
43
  )
44
44
  if os.path.isdir(_SHARED_SCRIPTS_DIR):
45
45
  sys.path.insert(0, os.path.abspath(_SHARED_SCRIPTS_DIR))
46
46
 
47
- # countdown 策略的声明解析。与下面几个共享依赖同样守护式导入:本文件全篇假定
48
- # 共享目录**可能**不在(见 _SHARED_SCRIPTS_DIR 的 isdir 判断),硬导入会让共享目录
49
- # 缺失时连非 countdown 模板一起起不来。缺失只影响 countdown 这一条路,并在真的
50
- # 用到时明确报错。
47
+
48
+
49
+
50
+
51
51
  try:
52
- from countdown_spec import ( # noqa: E402 (needs the sys.path setup above)
52
+ from countdown_spec import (
53
53
  opening_sec as countdown_opening_sec,
54
54
  resolve_count as resolve_countdown_count,
55
55
  resolve_countdown_spec,
@@ -73,16 +73,16 @@ except ImportError:
73
73
  _HAS_ENHANCER = False
74
74
  SKELETON_PREFIXES = ("[骨架待填充]", "[skeleton placeholder]")
75
75
 
76
- # 旁白字数预算:写进骨架占位旁白,让模型写脚本时就知道这一场放得下多少字。
77
- # 守护式导入同上;缺失时占位文案退回不带预算的旧格式。
76
+
77
+
78
78
  try:
79
79
  from video_dsl.runtime import narration_budget as _nb
80
80
  _HAS_BUDGET = True
81
81
  except ImportError:
82
82
  _HAS_BUDGET = False
83
83
 
84
- # 场景数上限的唯一实现在 dsl_validator(它同时是门禁方)。守护式导入的理由同上:
85
- # 共享目录可能不在(单测 / 裁剪安装),那时退回历史常量 20,行为与改动前一致。
84
+
85
+
86
86
  try:
87
87
  from video_dsl.runtime.dsl_validator import (
88
88
  DEFAULT_MAX_SCENES,
@@ -91,13 +91,13 @@ try:
91
91
  except ImportError:
92
92
  DEFAULT_MAX_SCENES = 20
93
93
 
94
- def max_scenes_for_template(template_config): # type: ignore[misc]
94
+ def max_scenes_for_template(template_config):
95
95
  return DEFAULT_MAX_SCENES
96
96
 
97
- # payloadHydration 声明的读取。gen_script 只用它来判断"这个模板需不需要源文件引用",
98
- # 真正的取数发生在 render-video 侧(见 payload_hydrator 模块头)。
97
+
98
+
99
99
  try:
100
- from payload_hydrator import hydration_spec # noqa: E402
100
+ from payload_hydrator import hydration_spec
101
101
  _HAS_HYDRATOR = True
102
102
  except ImportError:
103
103
  _HAS_HYDRATOR = False
@@ -112,58 +112,58 @@ try:
112
112
  except ImportError:
113
113
  _HAS_REGISTRY_LOADER = False
114
114
 
115
- class TemplateStatusGatedError(RuntimeError): # type: ignore[no-redef]
116
- """占位:共享模块不可导入时(单测 / 裁剪安装)永远不会被抛出。"""
115
+ class TemplateStatusGatedError(RuntimeError):
116
+ pass
117
117
 
118
118
  VALID_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]
119
119
  VALID_PURPOSES = ["opening", "point", "example", "explanation", "highlight", "cta", "ending"]
120
120
 
121
- # 规划用语速 / 行间停顿 / 场景尾巴统一在 video_dsl.runtime.narration_budget。
122
- # 这里原有的 CHARS_PER_SECOND = 5.0 从未参与计算(只出现在注释里),已删。
123
121
 
124
- # ── Language contract (mirrors apps/ab-skill/src/handlers/template-meta.ts) ──
125
- # template.json may declare:
126
- # - outputLanguage: "en" | "zh" default "zh" when missing/invalid
127
- # - defaultVoiceId: string default None when missing/empty
128
- # Read tolerantly: any failure returns the legacy zh defaults plus a stderr warning.
122
+
123
+
124
+
125
+
126
+
127
+
128
+
129
129
 
130
130
  DEFAULT_LANGUAGE = "zh"
131
131
  LANGUAGE_TO_META = {"en": "en-US", "zh": "zh-CN"}
132
132
 
133
- # Fallback voices keyed by output language. Mirrors voice-resolver.ts FALLBACK_VOICE.
134
- # zh keeps the legacy default that's been used everywhere; en is a placeholder
135
- # until the gen-voice service confirms the right English voice id.
133
+
134
+
135
+
136
136
  FALLBACK_VOICE = {
137
137
  "zh": "Chinese (Mandarin)_Male_Announcer",
138
- # TODO: confirm an English voice id against gen-voice service before shipping.
138
+
139
139
  "en": "english-male-friendly-01",
140
140
  }
141
141
 
142
- # ── Narration speed ───────────────────────────────────────────────────────────
143
- # Written to global.narration.speed; render_video copies it down into the
144
- # gen-voice asset payload (same route as narration text) and passes it to
145
- # `gen-voice --speed`. The envelope mirrors gen-voice's own 0.5–2.0 contract;
146
- # the real per-voice limits (capabilities speedMin/speedMax) are only known at
147
- # synthesis time, which is several confirmation steps and a few paid image
148
- # generations later — so an obviously-out-of-range value fails here instead.
142
+
143
+
144
+
145
+
146
+
147
+
148
+
149
149
  DEFAULT_NARRATION_SPEED = 1.0
150
150
  SPEED_MIN = 0.5
151
151
  SPEED_MAX = 2.0
152
152
 
153
153
 
154
154
  def _resolve_narration_speed(speed):
155
- """CLI speed → the value written to global.narration.speed. Total function."""
155
+
156
156
  if speed is None:
157
157
  return DEFAULT_NARRATION_SPEED
158
158
  return float(speed)
159
159
 
160
160
 
161
161
  def _resolve_template_language_meta(template_config):
162
- """Tolerant reader for outputLanguage / defaultVoiceId on a template dict.
163
162
 
164
- Returns (language, default_voice_id). Never raises. Logs a single stderr
165
- warning when fields are present but malformed.
166
- """
163
+
164
+
165
+
166
+
167
167
  if not isinstance(template_config, dict):
168
168
  return DEFAULT_LANGUAGE, None
169
169
  raw_lang = template_config.get("outputLanguage")
@@ -185,10 +185,10 @@ def _resolve_template_language_meta(template_config):
185
185
 
186
186
 
187
187
  def _resolve_voice_id(cli_voice_id, template_default_voice_id, language):
188
- """Mirror of voice-resolver.ts resolveVoiceId. Total function.
189
188
 
190
- Order: explicit CLI override → template defaultVoiceId → language fallback.
191
- """
189
+
190
+
191
+
192
192
  if isinstance(cli_voice_id, str) and cli_voice_id.strip():
193
193
  return cli_voice_id.strip()
194
194
  if isinstance(template_default_voice_id, str) and template_default_voice_id.strip():
@@ -197,12 +197,12 @@ def _resolve_voice_id(cli_voice_id, template_default_voice_id, language):
197
197
 
198
198
 
199
199
  def _check_narration_language(narrations, expected):
200
- """Non-blocking sanity check: warn when narration looks like the wrong language.
201
200
 
202
- Skips strings that start with NARRATION_SKELETON_MARKER or its English variant —
203
- those are gen_script placeholders the agent will replace with real narration
204
- before sending to prepare_video_assets, so a language mismatch on them is expected.
205
- """
201
+
202
+
203
+
204
+
205
+
206
206
  cjk_re = __import__("re").compile(r"[\u4e00-\u9fff]")
207
207
  latin_re = __import__("re").compile(r"[A-Za-z]")
208
208
  for text in narrations:
@@ -227,13 +227,13 @@ def _check_narration_language(narrations, expected):
227
227
 
228
228
 
229
229
  def _localize_label(language, key, **fmt):
230
- """Tiny localization helper for the on-screen / narration default strings.
231
230
 
232
- Keeps the template-driven outputLanguage as the single source of truth: zh
233
- templates keep their legacy Chinese phrasing, en templates emit English. We
234
- deliberately keep the table small — anything more elaborate belongs in the
235
- LLM prompt, not in this fallback skeleton.
236
- """
231
+
232
+
233
+
234
+
235
+
236
+
237
237
  table = {
238
238
  "label_opening": {"zh": "开场 Hook", "en": "Opening hook"},
239
239
  "label_point": {"zh": "内容要点 {n}", "en": "Point {n}"},
@@ -250,7 +250,7 @@ def _localize_label(language, key, **fmt):
250
250
  "zh": "[骨架待填充] 关于{topic}的 CTA 结尾({duration}s{budget})",
251
251
  "en": "[skeleton placeholder] Closing CTA about {topic} ({duration}s{budget})",
252
252
  },
253
- # {budget} 由 _budget_suffix 填:「,整段≤32 字;分行每多一行约少 4 字」。
253
+
254
254
  "budget_suffix": {
255
255
  "zh": ",整段≤{chars} 字;分行旁白每多一行约少 {per_line} 字",
256
256
  "en": ", ≤{chars} words as one block; ~{per_line} fewer per extra narration line",
@@ -259,10 +259,10 @@ def _localize_label(language, key, **fmt):
259
259
  "zh": "[骨架待填充] 关于{topic}的内容({duration}s{budget})",
260
260
  "en": "[skeleton placeholder] Content about {topic} ({duration}s{budget})",
261
261
  },
262
- # NOTE: 这里曾有 "point_description"("关于{topic}的要点内容")与
263
- # "more_about"("更多关于{topic}的内容")两条,用来给 no-visual 场景拼画面
264
- # 文案。两条都已删除:topic 长度不受控,拼进画面必然溢出,而正确的文案来源
265
- # 是调用方给的 --headline / --subheadline。
262
+
263
+
264
+
265
+
266
266
  "follow_us": {"zh": "关注我们", "en": "Follow us"},
267
267
  "video_description": {
268
268
  "zh": "关于「{topic}」的{duration}秒短视频",
@@ -271,8 +271,8 @@ def _localize_label(language, key, **fmt):
271
271
  }
272
272
  entry = table.get(key, {})
273
273
  template = entry.get(language) or entry.get(DEFAULT_LANGUAGE) or ""
274
- # 字数预算只有按场景规划的路径才算得出来(轮播 / 按字幕定时长的模板没有
275
- # 「每场秒数」这个前提),没给就是不带预算的旧格式。
274
+
275
+
276
276
  fmt.setdefault("budget", "")
277
277
  return template.format(**fmt)
278
278
 
@@ -285,17 +285,17 @@ def load_schema():
285
285
 
286
286
 
287
287
  def validate_dsl(dsl: dict) -> list:
288
- """Structural DSL validation — delegates to the unified validator.
289
-
290
- Historical rule set (preserved verbatim by ``validate_structural``):
291
- version + meta(title) + global presence + scene count 1–20 + each
292
- scene's ``id`` and ``purpose``. Asset integrity, enum membership and
293
- capabilities-driven checks (e.g. narrationLanguageStrict) are
294
- intentionally NOT enforced here — those live in ``validate_dsl`` /
295
- ``validate_integrity`` and only run at their respective historical
296
- call sites.
297
- """
298
- from video_dsl.runtime.dsl_validator import ( # noqa: E402
288
+
289
+
290
+
291
+
292
+
293
+
294
+
295
+
296
+
297
+
298
+ from video_dsl.runtime.dsl_validator import (
299
299
  validate_structural,
300
300
  errors_as_strings,
301
301
  )
@@ -310,14 +310,14 @@ def plan_scenes(
310
310
  language: str = DEFAULT_LANGUAGE,
311
311
  max_scenes: int = DEFAULT_MAX_SCENES,
312
312
  ) -> list:
313
- """Plan scene structure based on topic and constraints.
314
-
315
- ``max_scenes`` 来自模板的 ``capabilities.maxScenes``(见 ``_resolve_contract``)。
316
- 被 clamp 时**必须出声**:调用方要的场景数和拿到的不一样,而这个差值一路传到
317
- 下游才会以别的形态爆出来——2026-09-23 线上一份 23 页 PPT 在这里被静默砍到 20,
318
- 模型接着写了 23 段旁白、让用户确认了 23 个场景,最后 prepare_video_assets 报的是
319
- ``scenes[0..19].customPayload.slide (required)``,跟"场景被砍了"半点关系看不出来。
320
- """
313
+
314
+
315
+
316
+
317
+
318
+
319
+
320
+
321
321
  requested = scene_count
322
322
  if scene_count is None:
323
323
  if duration <= 15:
@@ -352,10 +352,10 @@ def plan_scenes(
352
352
  opening_duration = max(3, round(duration * 0.12))
353
353
  ending_duration = max(3, round(duration * 0.12))
354
354
  content_total = duration - opening_duration - ending_duration
355
- # 余数摊到前几段,保证各段加总正好等于 duration。以前是 round(均值) × 段数:
356
- # 2026-09-23 线上 ppt-to-video 传 --duration 600 --scenes 23,21 段 × round(21.7)=22
357
- # 加总 606s,撞上模板 supportedDurations.max=600 被自己的时长门禁拒掉。
358
- # 每段仍有 3s 下限 —— 段数多到摊不开时加总会超出,那是真超出,交给门禁报。
355
+
356
+
357
+
358
+
359
359
  base_content, extra_content = divmod(max(content_total, 0), content_scenes)
360
360
 
361
361
  scenes.append({
@@ -381,11 +381,11 @@ def plan_scenes(
381
381
 
382
382
 
383
383
  def _budget_suffix(duration: int, language: str, speed: float, tail_pad_sec: float | None) -> str:
384
- """占位旁白里的字数预算。拿不到预算模块时返回空串(退回旧格式)。
385
384
 
386
- 为什么写进占位文案而不是单独字段:模型填旁白时一定会读到这一句,而骨架多一个
387
- 字段就得在校验白名单、TTS payload 两头都处理,漏一头就是新的静默问题。
388
- """
385
+
386
+
387
+
388
+
389
389
  if not _HAS_BUDGET:
390
390
  return ""
391
391
  tail = _nb.DEFAULT_TAIL_PAD_SEC if tail_pad_sec is None else tail_pad_sec
@@ -404,19 +404,19 @@ def estimate_narration_text(
404
404
  speed: float = 1.0,
405
405
  tail_pad_sec: float | None = None,
406
406
  ) -> str:
407
- """Generate a short skeleton narration hint for a scene.
408
-
409
- This returns a *skeleton only* — the agent is expected to replace this
410
- text with real narration before submitting DSL to prepare_video_assets. We
411
- deliberately return a short, clearly-placeholder-looking string rather
412
- than padding to the scene's length, because padding with a
413
- repeating filler sentence historically caused the skeleton to be
414
- submitted verbatim to TTS (audio narration mismatch bug).
415
-
416
- It does carry the scene's narration *budget* (see narration_budget): the
417
- one number the agent needs while writing, and never had — a 2026-09-27
418
- session wrote 45–58 chars into 8s scenes and shipped 117s for a 75s target.
419
- """
407
+
408
+
409
+
410
+
411
+
412
+
413
+
414
+
415
+
416
+
417
+
418
+
419
+
420
420
  fmt = {
421
421
  "label": label, "topic": _clip(topic, _PLACEHOLDER_TOPIC_CHARS), "duration": duration,
422
422
  "budget": _budget_suffix(duration, language, speed, tail_pad_sec),
@@ -430,17 +430,17 @@ def estimate_narration_text(
430
430
  return _localize_label(language, "narration_default", **fmt)
431
431
 
432
432
 
433
- # 骨架标记:出现此前缀说明 narration 仍是 gen_script 原始骨架,未被 agent 填充真实内容
433
+
434
434
  NARRATION_SKELETON_MARKER = SKELETON_PREFIXES[0]
435
435
 
436
436
 
437
437
  def generate_image_prompt(
438
438
  purpose: str, topic: str, style: str, narration_text: str = "", style_guide: dict | None = None
439
439
  ) -> dict:
440
- """Generate image prompt for a scene.
441
440
 
442
- Returns dict with keys: prompt, and optionally negativePrompt, guidanceScale.
443
- """
441
+
442
+
443
+
444
444
  if _HAS_ENHANCER:
445
445
  return build_enhanced_prompt(
446
446
  purpose=purpose,
@@ -450,7 +450,7 @@ def generate_image_prompt(
450
450
  style_guide=style_guide,
451
451
  )
452
452
 
453
- # 降级:无 enhancer 时使用原逻辑
453
+
454
454
  style_suffix = f",{style}风格" if style else ""
455
455
  prompts = {
456
456
  "opening": f"{topic}主题概念图,吸引眼球的视觉效果{style_suffix},高清大图",
@@ -461,18 +461,18 @@ def generate_image_prompt(
461
461
 
462
462
 
463
463
  def _parse_ai_slot(slot: str) -> tuple[bool, str]:
464
- """解析 --scene-images 的一项:是不是 AI 段,以及用户给的提示词。
465
464
 
466
- 返回 ``(is_ai, prompt)``:
467
465
 
468
- - ``"https://…/a.png"`` → ``(False, "")`` 用已有素材
469
- - ``"ai"`` → ``(True, "")`` 这一段交给 gen-image,提示词由模板推
470
- - ``"ai: 深色调机房"`` → ``(True, "深色调机房")``
471
466
 
472
- 大小写不敏感。判定刻意收得很紧(整项等于 ``ai``,或以 ``ai:`` 开头):URL 才是这个
473
- 参数的常态,把 ``ai`` 判宽了会让 ``https://ai.example.com/x.png`` 这种正常地址被
474
- 误当成"生成一段",用户的图就此消失。
475
- """
467
+
468
+
469
+
470
+
471
+
472
+
473
+
474
+
475
+
476
476
  text = (slot or "").strip()
477
477
  if text.lower() == "ai":
478
478
  return True, ""
@@ -482,23 +482,23 @@ def _parse_ai_slot(slot: str) -> tuple[bool, str]:
482
482
 
483
483
 
484
484
  def _load_template_config(template_id: str) -> dict | None:
485
- """Load template definition for the given template ID.
486
-
487
- 解析顺序:
488
- 1. 通过 registry_loader 从 ab-api(单一数据源,URL 取
489
- VIDEO_TEMPLATE_REGISTRY_URL 或按 MM_API_BASE_URL 推导)加载,按 templateId 匹配。
490
- 2. 兜底:旧路径 template-registry/video_dsl/templates/<id>/template.json
491
- (仅在仓库还残留旧目录时使用)。
492
- 返回完整 template dict(含 supportedAspectRatios / assetRequirements / slotMapping 等),
493
- 找不到时返回 None。
494
-
495
- ⚠️ 一个例外**不走**兜底:模板存在、但被状态门控挡在可见集合外(``beta`` 而本进程
496
- 没开 ENABLE_BETA_TEMPLATES)时 ``TemplateStatusGatedError`` 原样上抛。下面那些
497
- ``_template_*`` / ``_resolve_contract`` 读到 None 会一路退回通用默认值 —— arc 三段式、
498
- 默认 layout、空 customPayload,而且 llmHint 根本不会打给模型。对「id 不存在」这是
499
- 合理容错,对「模板在线但本进程看不见」则是把配置问题伪装成一条能跑通的废片。
500
- """
501
- # 优先使用 registry_loader(这是模板元数据的唯一权威来源)
485
+
486
+
487
+
488
+
489
+
490
+
491
+
492
+
493
+
494
+
495
+
496
+
497
+
498
+
499
+
500
+
501
+
502
502
  if _HAS_REGISTRY_LOADER:
503
503
  try:
504
504
  return _get_template(template_id)
@@ -510,7 +510,7 @@ def _load_template_config(template_id: str) -> dict | None:
510
510
  file=sys.stderr,
511
511
  )
512
512
 
513
- # 兜底:旧的本地目录布局
513
+
514
514
  legacy_dir = os.path.join(
515
515
  os.path.dirname(__file__), "..", "..", "template-registry", "video_dsl", "templates"
516
516
  )
@@ -522,11 +522,11 @@ def _load_template_config(template_id: str) -> dict | None:
522
522
 
523
523
 
524
524
  def _template_supported_ratio(template_config: dict | None) -> str | None:
525
- """Return template 的首选宽高比。
526
525
 
527
- 如果模板声明了 supportedAspectRatios,返回数组首项作为推荐值;
528
- 否则返回 None,由调用方使用全局默认(16:9)。
529
- """
526
+
527
+
528
+
529
+
530
530
  if not template_config:
531
531
  return None
532
532
  ratios = template_config.get("supportedAspectRatios") or []
@@ -537,15 +537,15 @@ def _template_supported_ratio(template_config: dict | None) -> str | None:
537
537
 
538
538
 
539
539
  def _template_primary_visual_type(template_config: dict | None) -> str:
540
- """从模板的 assetRequirements 推导每个 scene 的主要视觉素材类型。
541
540
 
542
- 返回值之一:
543
- - "image" : 模板要求图片素材(默认通用 DSL 也是图片)
544
- - "video" : 模板要求视频素材(assetRequirements 含 video 但不含 image)
545
- - "none" : 模板不需要任何视觉素材(assetRequirements 只有 audio)
546
- """
541
+
542
+
543
+
544
+
545
+
546
+
547
547
  if not template_config:
548
- return "image" # 默认:通用 DSL 走图片
548
+ return "image"
549
549
  requirements = template_config.get("assetRequirements") or []
550
550
  if "image" in requirements:
551
551
  return "image"
@@ -555,22 +555,22 @@ def _template_primary_visual_type(template_config: dict | None) -> str:
555
555
 
556
556
 
557
557
  def _template_needs_narration(template_config: dict | None) -> bool:
558
- """模板是否需要旁白(TTS)。
559
-
560
- 默认 True —— 保持历史行为:每个场景挂一个 gen-voice 素材 + 写入
561
- ``audio.narration`` 骨架,由 agent 后续填真实文案。
562
-
563
- 模板可通过 ``capabilities.needsNarration=false`` 声明"纯视觉 / 无旁白"
564
- (如单图 Ken Burns、BGM-only 展示)。此时 gen_script:
565
- - 不生成 gen-voice 素材;
566
- - 场景不写 ``audio.narration``。
567
- 于是骨架的 narrationSceneCount=0,ab-agent 的 prepare_video_assets 预校验
568
- 会直接放行,不再强制用户为每个场景填旁白 —— 所有 needsNarration=false 的模板
569
- 走的是同一条 pass-through 路径。
570
-
571
- 兼容历史脏数据:部分模板的 ``capabilities`` 是空列表 ``[]``(而非 dict),
572
- 统一按"未声明"处理 → 返回 True。仅当显式为 JSON ``false`` 时才关闭旁白。
573
- """
558
+
559
+
560
+
561
+
562
+
563
+
564
+
565
+
566
+
567
+
568
+
569
+
570
+
571
+
572
+
573
+
574
574
  caps = (template_config or {}).get("capabilities")
575
575
  if not isinstance(caps, dict):
576
576
  return True
@@ -578,46 +578,46 @@ def _template_needs_narration(template_config: dict | None) -> bool:
578
578
 
579
579
 
580
580
  def _template_needs_image(template_config: dict | None) -> bool:
581
- """Check if a template requires image assets.
582
581
 
583
- If assetRequirements is defined and does NOT include 'image', images are not needed.
584
- """
582
+
583
+
584
+
585
585
  if template_config is None:
586
- return True # default: generate images for generic DSL
586
+ return True
587
587
  requirements = template_config.get("assetRequirements", [])
588
588
  return "image" in requirements
589
589
 
590
590
 
591
- # ═══════════════════════════════════════════════════════════════════════════════
592
- # Template-as-Contract: capabilities-driven DSL assembly
593
- # ═══════════════════════════════════════════════════════════════════════════════
594
- #
595
- # 模板的所有差异都通过 template.json 的 ``capabilities`` 声明(registry 是单一数据源,
596
- # 内置模板与个人 OSS 模板同源可达)。gen_script 把 capabilities 归一化成一份 contract,
597
- # 再用同一条流水线组装 DSL —— 不再有 input-schema.json,也不再有 if template_id==X 分支。
598
- #
599
- # 归一化 contract 字段(全部可缺省,缺省值保证"未声明能力"的模板走通用图文旁白):
600
- # - needs_narration bool capabilities.needsNarration,默认 True
601
- # - scene_strategy str capabilities.sceneStrategy ∈ {arc, single, fixed, countdown},默认 arc
602
- # - fixed_scenes list[dict] capabilities.fixedScenes(single/fixed 用;缺省单 opening)
603
- # - max_scenes int capabilities.maxScenes,默认 20(arc 的 --scenes 上限;
604
- # "一页一场景"的模板由源文件页数决定场景数,应声明抬高)
605
- # - countdown dict|None countdown 策略的解析结果(见 countdown_spec 模块;
606
- # 非 countdown 模板为 None)
607
- # - payload_style str capabilities.payloadStyle ∈ {visual-overlay, slide, carousel-caption}
608
- # - duration_strategy str capabilities.durationStrategy ∈ {explicit, fit-caption, fit-images}
609
- # - payload_defaults dict capabilities.payloadDefaults(carousel-caption 的轮播/字幕默认值)
610
- # - duration_model dict|None capabilities.durationModel(fit-caption 估时用的节奏数值,
611
- # 由模板声明;缺省则不估时,见 _estimate_fit_caption_duration)
612
- # - primary_visual_type / default_layout 由 assetRequirements / capabilities 推导
591
+
592
+
593
+
594
+
595
+
596
+
597
+
598
+
599
+
600
+
601
+
602
+
603
+
604
+
605
+
606
+
607
+
608
+
609
+
610
+
611
+
612
+
613
613
 
614
614
 
615
615
  def _resolve_countdown(template_config: dict | None) -> dict:
616
- """countdown 声明解析;共享模块缺失时明确报错而不是静默换一种形状。
617
616
 
618
- 退回 ``arc`` 会是最坏的处理:一个倒数模板会拿到「至少 3 段 + 末尾 cta + 升序编号」
619
- 的场景表,既没有数量也没有倒数索引,然后一路通过到渲染。
620
- """
617
+
618
+
619
+
620
+
621
621
  if not _HAS_COUNTDOWN:
622
622
  raise ValueError(
623
623
  "capabilities.sceneStrategy=countdown needs the shared countdown_spec module "
@@ -627,12 +627,12 @@ def _resolve_countdown(template_config: dict | None) -> dict:
627
627
 
628
628
 
629
629
  def _resolve_contract(template_config: dict | None) -> dict:
630
- """把 template.json 的 capabilities 归一化成一份装配 contract。
631
630
 
632
- 宽容读取:capabilities 缺失 / 非 dict(历史脏数据可能是 ``[]``)一律按"未声明"
633
- 处理,回退到通用图文旁白(arc + visual-overlay + 旁白开),保证没有声明能力的
634
- 模板与无模板场景行为一致。
635
- """
631
+
632
+
633
+
634
+
635
+
636
636
  caps = (template_config or {}).get("capabilities")
637
637
  caps = caps if isinstance(caps, dict) else {}
638
638
 
@@ -654,7 +654,7 @@ def _resolve_contract(template_config: dict | None) -> dict:
654
654
 
655
655
  fixed_scenes = caps.get("fixedScenes")
656
656
  if not isinstance(fixed_scenes, list) or not fixed_scenes:
657
- # single / fixed 未显式给 fixedScenes 时退化为单 opening 场景
657
+
658
658
  fixed_scenes = [{"purpose": "opening"}] if scene_strategy in ("single", "fixed") else []
659
659
 
660
660
  payload_defaults = caps.get("payloadDefaults")
@@ -664,10 +664,10 @@ def _resolve_contract(template_config: dict | None) -> dict:
664
664
  "needs_narration": needs_narration,
665
665
  "scene_strategy": scene_strategy,
666
666
  "fixed_scenes": fixed_scenes,
667
- # 上限读取放共享模块:dsl_validator 的门禁读的是同一个函数。
667
+
668
668
  "max_scenes": max_scenes_for_template(template_config),
669
- # 声明解析放共享模块:render-video 的计划校验要读同一份声明,
670
- # 两边各抄一遍正是这个功能上一版栽过的跟头。
669
+
670
+
671
671
  "countdown": _resolve_countdown(template_config) if scene_strategy == "countdown" else None,
672
672
  "payload_style": payload_style,
673
673
  "duration_strategy": duration_strategy,
@@ -676,21 +676,21 @@ def _resolve_contract(template_config: dict | None) -> dict:
676
676
  "primary_visual_type": primary_visual_type,
677
677
  "default_layout": caps.get("defaultLayout"),
678
678
  "authored_scenes": caps.get("authoredScenes") is True,
679
- # 画面文字契约(dsl_validator 按 error 执行);骨架据此为每个必填 role 留占位
679
+
680
680
  "text_layer_rules": caps.get("textLayerRules") if isinstance(caps.get("textLayerRules"), dict) else None,
681
- # arc 叙事弧的收尾场景用途(缺省 cta)。绘本收尾是 ending(寓意页),不是「关注我们」
681
+
682
682
  "arc_closing_purpose": caps.get("arcClosingPurpose") if isinstance(caps.get("arcClosingPurpose"), str) else None,
683
- # 骨架里 transitions.default 的取值;缺省 fade(历史行为)
683
+
684
684
  "default_transition": caps.get("defaultTransition") if isinstance(caps.get("defaultTransition"), str) else None,
685
685
  }
686
686
 
687
687
 
688
688
  def _slot_reads_visual(template_config: dict | None, purpose: str) -> bool:
689
- """这个用途的版式会不会显示背景素材(slotMapping 里有没有读 visuals.background.assetRef)。
690
689
 
691
- 不显示的版式(如绘本的 ending 寓意页)不该规划图片:素材照样会被生成、扣积分,
692
- 却永远不上屏。模板没声明 slotMapping 时按「会显示」处理,保持历史行为。
693
- """
690
+
691
+
692
+
693
+
694
694
  mapping = (template_config or {}).get("slotMapping")
695
695
  if not isinstance(mapping, dict) or not mapping:
696
696
  return True
@@ -709,12 +709,12 @@ def _slot_reads_visual(template_config: dict | None, purpose: str) -> bool:
709
709
 
710
710
 
711
711
  def _placeholder_text_layers(rules: dict) -> list[dict]:
712
- """模板声明了 capabilities.textLayerRules 时,骨架的文字层 = 每个必填 role 一条占位。
713
712
 
714
- 通用骨架给的是「内容要点 N」「关注我们」这类文案,对有文字契约的模板全是错的,
715
- 而且留着一个没声明的 role(badge)只会让模型以为那里要填东西。占位带骨架前缀,
716
- dsl_validator 会拒绝原样提交;带上语言要求,模型不用再去 llmHint 里找。
717
- """
713
+
714
+
715
+
716
+
717
+
718
718
  language = rules.get("language") if isinstance(rules.get("language"), dict) else {}
719
719
  hints = {"en": "English text for this page", "zh": "本页中文译文"}
720
720
  layers = []
@@ -728,9 +728,9 @@ def _placeholder_text_layers(rules: dict) -> list[dict]:
728
728
  return layers
729
729
 
730
730
 
731
- # 视频标题 / 占位旁白里引用 topic 的长度上限。topic 是给模型写脚本用的内容提要,
732
- # 线上常见 200+ 字(2026-09-27 一条会话 250 字),原样拿去当标题或塞进每场占位旁白
733
- # 都不合适。
731
+
732
+
733
+
734
734
  _TITLE_MAX_CHARS = 60
735
735
  _PLACEHOLDER_TOPIC_CHARS = 30
736
736
 
@@ -741,48 +741,48 @@ def _clip(text: str, limit: int) -> str:
741
741
 
742
742
 
743
743
  def _video_title(headline: str | None, topic: str) -> str:
744
- """meta.title:成片的标题。
745
-
746
- 它会成为上传点播的标题(render_video 的 upload_title_hint)和脚本产物列表的
747
- 标题(ab-api artifact_narration.dslTitle)。以前直接等于 topic —— 模型传进来的
748
- 是一段内容提要,于是作品标题成了「讲解 AI Agent 的核心底层逻辑「Agent Loop」:
749
- 传统大模型的单次响应 vs…」(render_video 只能截到 128 字兜底)。用户给了
750
- headline 就用 headline(折掉换行),没给才退回截短的 topic;完整 topic 仍在
751
- meta.topic。
752
- """
753
- # 画面文案里的 **强调** 标记对标题没有意义,去掉。
744
+
745
+
746
+
747
+
748
+
749
+
750
+
751
+
752
+
753
+
754
754
  return _clip((headline or "").replace("**", ""), _TITLE_MAX_CHARS) or _clip(topic, _TITLE_MAX_CHARS)
755
755
 
756
756
 
757
757
  def _normalize_linebreaks(value: str | None) -> str:
758
- """把画面文字里的**字面量** ``\\n`` 折成真换行。
759
758
 
760
- ``textLayers[].content`` 用真换行分行,但调用方是在拼 shell 命令行的模型:
761
- ``--subheadline 'A\\nB'`` 在 POSIX 单引号里传进来的是**反斜杠 + n 两个字符**,
762
- 不是换行。不折的话它会原样渲进画面(副标题上真的印着一个 ``\\n``),而这既不
763
- 报错也不出现在确认摘要里 —— 属于"只有渲完看片才发现"的那类坑。
764
759
 
765
- 反过来说,画面文案里没有任何理由需要一个字面 ``\\n``,所以这个折叠是无损的。
766
- 真换行(``$'a\\nb'`` 或参数里直接带换行)本来就能用,不受影响。
767
- """
760
+
761
+
762
+
763
+
764
+
765
+
766
+
767
+
768
768
  return (value or "").replace("\\n", "\n").strip()
769
769
 
770
770
 
771
771
  def _echo_template_llm_hint(template_config: dict | None, template_id: str | None) -> None:
772
- """把模板自己声明的 ``llmHint`` 原样打到 stderr。
773
-
774
- **本函数刻意不认识任何一个具体模板。** 每个模板的画面文字该怎么写(标题写钩子
775
- 还是写产品名、副标题几行、支持不支持 ``**强调**``)是模板的事实,真源在它自己的
776
- ``template.json`` —— 与 ``imageStyleGuide`` 由 ``prompt_enhancer`` 通用消费、
777
- ``capabilities`` 由 ``_resolve_contract`` 通用归一化是同一条纪律:**CLI 出机制,
778
- 模板出内容**。把某个模板的版式规则抄进 skill 文档或这里的分支,等于在 registry
779
- 之外开第二份会漂移的事实。
780
-
781
- 之所以要在这里再打一遍:``list_templates`` 的表格视图把 llmHint 截到 200 字
782
- (便于浏览),而真正的写作规范往往长得多(现网最长的已有 1400+ 字)。调用方
783
- 多半是照着那份被截断的摘要选完模板就直接来调 gen_script 的,规则的后半截它从没
784
- 见过。这里在装配 DSL 之前把全文补给它,让它能在确认摘要之前自己纠偏。
785
- """
772
+
773
+
774
+
775
+
776
+
777
+
778
+
779
+
780
+
781
+
782
+
783
+
784
+
785
+
786
786
  hint = (template_config or {}).get("llmHint")
787
787
  if isinstance(hint, str) and hint.strip():
788
788
  print(
@@ -793,16 +793,16 @@ def _echo_template_llm_hint(template_config: dict | None, template_id: str | Non
793
793
 
794
794
 
795
795
  def _warn_on_url_text_layer(headline: str, subheadline: str) -> None:
796
- """画面文字整条是一个裸 URL 时出声提醒(不阻断)。
797
796
 
798
- 这是 DSL 层的事实、不是某个模板的版式:``textLayers`` 是**画在画面上**的字,
799
- 而观众没法点视频里的链接、也很少有人会照着念一串 URL 抄下来。链接的正确去处
800
- 因模板而异(打字机行、结尾 CTA、简介),所以这里只说"这不该出现在画面标题里",
801
- 不替模板规定它该去哪 —— 那句话由模板的 llmHint 自己讲。
802
797
 
803
- 只 warn 不 exit:这是文风问题,照样渲得出一条能看的片子,与"caption 为空渲出
804
- 黑屏"那种结构性失败不同级。
805
- """
798
+
799
+
800
+
801
+
802
+
803
+
804
+
805
+
806
806
  for flag, value in (("--headline", headline), ("--subheadline", subheadline)):
807
807
  lines = [ln.strip() for ln in (value or "").split("\n") if ln.strip()]
808
808
  if lines and all(
@@ -822,16 +822,16 @@ def _warn_on_url_text_layer(headline: str, subheadline: str) -> None:
822
822
  def _enforce_supported_duration(
823
823
  dsl: dict, template_config: dict | None, template_id: str | None
824
824
  ) -> None:
825
- """校验成片总时长落在模板自己声明的 ``supportedDurations`` 区间内。
826
825
 
827
- 这条约束此前只在 template-library 的 CI(``check-dsl-examples.mjs``)对仓库里的
828
- 示例 DSL 生效,运行时链路(gen_script → prepare_video_assets → render_video)没有
829
- 任何一环校验它。于是一个声明了 ``supportedDurations.min`` 的模板照样可以静默产出
830
- 低于下限的成片 —— 模板自己说了"我不是为这么短设计的",却没人拦。这里在 DSL 出厂前补上同一道栅栏。
831
826
 
832
- 越界即报错退出,而不是静默出片:时长不足通常意味着内容(打字机文案 / 旁白 / 场景)
833
- 根本没填够,继续往下走只会烧掉渲染积分换一条废片。
834
- """
827
+
828
+
829
+
830
+
831
+
832
+
833
+
834
+
835
835
  sd = (template_config or {}).get("supportedDurations")
836
836
  if not isinstance(sd, dict):
837
837
  return
@@ -870,16 +870,16 @@ def _record_source_document(
870
870
  template_id: str | None,
871
871
  source_document_url: str | None,
872
872
  ) -> None:
873
- """把源文件引用写进 ``meta.sourceDocument``,供渲染管线按引用取数。
874
873
 
875
- 这个键必须由**骨架**携带,不能指望模型后面补:ab-agent 的合并层
876
- (``applyNarrationToSkeleton``)以骨架为基底,只从模型提交的 dsl_json 里搬
877
- narration 与 customPayload,``meta`` / ``global`` 归骨架所有。模型往 dsl_json 里写
878
- ``meta.sourceDocument`` 会被静默丢掉。
879
874
 
880
- 模板声明了 ``payloadHydration`` 却没拿到 URL 时在这里就警告,而不是等到
881
- prepare_video_assets 才失败 —— 那时模型已经写完全部旁白并让用户确认过了。
882
- """
875
+
876
+
877
+
878
+
879
+
880
+
881
+
882
+
883
883
  if source_document_url:
884
884
  dsl["meta"]["sourceDocument"] = {"url": source_document_url}
885
885
  return
@@ -888,7 +888,7 @@ def _record_source_document(
888
888
  return
889
889
  try:
890
890
  spec = hydration_spec(template_config)
891
- except Exception: # noqa: BLE001 - 模板声明写坏了不该让脚本生成失败
891
+ except Exception:
892
892
  return
893
893
  if not spec:
894
894
  return
@@ -907,15 +907,15 @@ def _record_source_document(
907
907
  def _plan_contract_scenes(
908
908
  contract: dict, topic: str, duration: int, scene_count: int | None, language: str
909
909
  ) -> list:
910
- """按 contract.scene_strategy 规划场景列表。
911
910
 
912
- - arc : 沿用 plan_scenes 叙事弧(opening → point* → cta,最少 3 段)。
913
- - single : 单场景(默认 opening),整段时长归一个场景。
914
- - fixed : 按 capabilities.fixedScenes 顺序展开,时长均分。
915
911
 
916
- single / fixed 完全绕开 arc 的"最少 3 段"下限 —— 单图 Ken Burns / 打字机卡片
917
- 这类模板由此能产出真正的单场景,而不再被强行补到 3 段。``--scenes`` 只对 arc 生效。
918
- """
912
+
913
+
914
+
915
+
916
+
917
+
918
+
919
919
  if contract["scene_strategy"] == "arc":
920
920
  plans = plan_scenes(
921
921
  topic, duration, scene_count, False,
@@ -929,8 +929,8 @@ def _plan_contract_scenes(
929
929
 
930
930
  fixed = contract["fixed_scenes"] or [{"purpose": "opening"}]
931
931
  n = len(fixed)
932
- # 非 arc 策略的场景数由模板钉死,--scenes 不生效 —— 但必须出声。2026-09-23 线上
933
- # screen-walkthrough 传 --scenes 6 拿回 2 场,没有任何提示,模型只能自己猜为什么。
932
+
933
+
934
934
  if scene_count is not None and scene_count != n:
935
935
  print(
936
936
  f"⚠️ requested {scene_count} scene(s) but this template's sceneStrategy="
@@ -954,24 +954,24 @@ def _plan_contract_scenes(
954
954
 
955
955
 
956
956
  def _plain_len(text: str) -> int:
957
- """剥离 **强调** 标记后的纯字符数(与渲染端 emphasis.plainLength 等价)。"""
957
+
958
958
  return len((text or "").replace("**", ""))
959
959
 
960
960
 
961
- # ── fit-caption 估时 ───────────────────────────────────────────────────────
962
- #
963
- # 这里以前钉着一组常量(打字收尾 1.5s、标题入场 1.0s、每张图最少 2.0s …),注释写
964
- # 明"与 template-library 里那个模板组件的 timing.ts 保持同一套数值"。也就是说
965
- # **一个具体模板的组件内部节奏被手抄进了 CLI**:模板调一次 tailHold,这边不跟着改
966
- # 就开始漂,而漂移的表现是"估时和组件实际耗时对不上",没有任何门禁看得见。
967
- #
968
- # 现在数值由模板在 ``capabilities.durationModel`` 里自己声明,本文件只实现算法:
969
- # 各分量并行、取 max、加收尾留白。没声明的模板不估时(见 _estimate_fit_caption_duration)。
970
- # 与 ``payloadDefaults`` / ``imageStyleGuide`` 同一条纪律:模板出数值,CLI 出机制。
961
+
962
+
963
+
964
+
965
+
966
+
967
+
968
+
969
+
970
+
971
971
 
972
972
 
973
973
  def _estimate_caption_natural_sec(caption: dict, model: dict) -> float:
974
- """打字机自然节奏耗时(startDelay + 打字 + 行间停顿 + 收尾),秒。"""
974
+
975
975
  max_lines = model.get("maxLines")
976
976
  lines = caption.get("lines") or []
977
977
  if isinstance(max_lines, int) and max_lines > 0:
@@ -995,7 +995,7 @@ def _estimate_caption_natural_sec(caption: dict, model: dict) -> float:
995
995
 
996
996
 
997
997
  def _estimate_carousel_min_sec(carousel: dict, model: dict) -> float:
998
- """轮播至少需要的秒数(每张图/视频的最小停留,减去重叠的过渡)。"""
998
+
999
999
  items = carousel.get("items") or []
1000
1000
  n = len(items)
1001
1001
  if n == 0:
@@ -1018,13 +1018,13 @@ def _estimate_carousel_min_sec(carousel: dict, model: dict) -> float:
1018
1018
  def _estimate_fit_caption_duration(
1019
1019
  custom_payload: dict, duration_model: dict | None, template_id: str | None
1020
1020
  ) -> float:
1021
- """fit-caption 策略:按打字机 + 轮播 + 标题入场推算建议时长(秒)。
1022
1021
 
1023
- 算法与模板导出的 ``capabilities.durationEstimator`` 等价(各分量并行、取 max、
1024
- 加收尾留白),但**每一个数值都来自模板声明的 ``capabilities.durationModel``**。
1025
- 模板没声明就不估 —— 返回 0,调用方沿用传入的 targetDuration 并收到一条 warning。
1026
- 宁可退回调用方的时长,也不拿一组从别的模板抄来的数字去替这个模板做决定。
1027
- """
1022
+
1023
+
1024
+
1025
+
1026
+
1027
+
1028
1028
  if not isinstance(duration_model, dict) or not duration_model:
1029
1029
  print(
1030
1030
  f"⚠️ template '{template_id}' declares durationStrategy=fit-caption but no "
@@ -1048,11 +1048,11 @@ def _estimate_fit_caption_duration(
1048
1048
 
1049
1049
 
1050
1050
  def _estimate_fit_images_duration(sequence: dict, model: dict | None, fps: int = 30) -> float:
1051
- """Apply template-declared image timing, in frames just like the renderer.
1052
1051
 
1053
- Hold tiers and transition margins belong to durationModel.sequence; never
1054
- infer them from a template id or silently use the caller's target duration.
1055
- """
1052
+
1053
+
1054
+
1055
+
1056
1056
  timing = (model or {}).get("sequence", {})
1057
1057
  tiers = timing.get("holdSecByCount", [])
1058
1058
  count = len(sequence["items"])
@@ -1064,7 +1064,7 @@ def _estimate_fit_images_duration(sequence: dict, model: dict | None, fps: int =
1064
1064
  margin = timing.get("minHoldMarginFrames")
1065
1065
  if transition_ms is None or margin is None:
1066
1066
  raise ValueError("fit-images requires sequence transitionMs and durationModel.sequence.minHoldMarginFrames")
1067
- # Python round uses ties-to-even; JS Math.round uses floor(x + 0.5).
1067
+
1068
1068
  min_frames = max(2, math.floor(transition_ms / 1000 * fps + 0.5)) + margin
1069
1069
  return sum(max(min_frames, math.floor(item.get("holdSec", base_hold) * fps + 0.5))
1070
1070
  for item in sequence["items"]) / fps
@@ -1092,12 +1092,12 @@ def _build_carousel_caption_dsl(
1092
1092
  duration_strategy: str | None,
1093
1093
  duration_model: dict | None,
1094
1094
  ) -> dict:
1095
- """carousel-caption 装配(单场景、图/视频轮播 + 打字机文字)。
1096
1095
 
1097
- 所有差异来自归一化 contract(capabilities.payloadDefaults / durationStrategy /
1098
- needsNarration),不再读 input-schema.json,也没有 if template_id==X 分支。
1099
- 轮播素材由调用方在 carousel_items 里直接给 URL(existing 资产),不触发 AI 生图。
1100
- """
1096
+
1097
+
1098
+
1099
+
1100
+
1101
1101
  defaults = deepcopy(payload_defaults or {})
1102
1102
  sequence_mode = isinstance(defaults.get("sequence"), dict)
1103
1103
  if sequence_mode and not carousel_items:
@@ -1105,12 +1105,12 @@ def _build_carousel_caption_dsl(
1105
1105
  if sequence_mode and len(caption_lines) > len(carousel_items):
1106
1106
  raise ValueError("Image sequence has more --caption-lines than images; use at most one caption per image")
1107
1107
 
1108
- # ── Build assets from carousel_items ──────────────────────────────────
1108
+
1109
1109
  assets = []
1110
1110
  carousel_asset_ids = []
1111
1111
  for idx, url in enumerate(carousel_items):
1112
1112
  asset_id = f"carousel-img-{idx + 1:02d}"
1113
- # Determine if URL is a video (basic heuristic on extension)
1113
+
1114
1114
  is_video = urlsplit(url).path.lower().endswith((".mp4", ".mov", ".webm", ".avi"))
1115
1115
  if sequence_mode and is_video:
1116
1116
  raise ValueError("Image sequence requires image URLs in --carousel-items, not video URLs")
@@ -1123,7 +1123,7 @@ def _build_carousel_caption_dsl(
1123
1123
  })
1124
1124
  carousel_asset_ids.append((asset_id, "video" if is_video else "image"))
1125
1125
 
1126
- # ── Narration asset (可选;仅在模板声明 narration.enabled 时产出) ───────
1126
+
1127
1127
  narration_asset_id = "narration-scene-01"
1128
1128
  if narration_enabled:
1129
1129
  assets.append({
@@ -1136,7 +1136,7 @@ def _build_carousel_caption_dsl(
1136
1136
  },
1137
1137
  })
1138
1138
 
1139
- # ── Build carousel config ─────────────────────────────────────────────
1139
+
1140
1140
  carousel_defaults = defaults.get("carousel", {})
1141
1141
  carousel_config = {
1142
1142
  "items": [],
@@ -1153,25 +1153,25 @@ def _build_carousel_caption_dsl(
1153
1153
  item["muted"] = True
1154
1154
  carousel_config["items"].append(item)
1155
1155
 
1156
- # ── Build caption config ──────────────────────────────────────────────
1156
+
1157
1157
  caption_defaults = defaults.get("caption", {})
1158
1158
  caption_config = {
1159
1159
  "lines": caption_lines if caption_lines else [],
1160
1160
  **caption_defaults,
1161
1161
  }
1162
- # Override lines explicitly (defaults shouldn't clobber user-provided lines)
1162
+
1163
1163
  if caption_lines:
1164
1164
  caption_config["lines"] = caption_lines
1165
1165
 
1166
- # ── Build customPayload ───────────────────────────────────────────────
1167
- #
1168
- # background / headlineStyle 只在模板自己声明了 payloadDefaults 时才写进去。
1169
- # 这里原本硬编码着 ``{"preset": "grid-particles"}`` / ``{"pill": True}`` 作为
1170
- # 兜底 —— 那两个值是**某一个**模板的词汇(一个背景 preset 枚举成员 + 一个它
1171
- # 顶部装饰的开关)。同为 carousel-caption 但没声明这两个键的模板既不认识也不读
1172
- # 它们,等于往它的 DSL 里塞两坨死配置(payload 契约门禁只查"声明了却没人消费",
1173
- # 查不到"没声明却被硬塞进来");而下一个模板如果恰好也有 background、枚举却不同,
1174
- # 拿到的就是一个它不认识的值。模板的默认值归模板声明,CLI 只负责透传。
1166
+
1167
+
1168
+
1169
+
1170
+
1171
+
1172
+
1173
+
1174
+
1175
1175
  custom_payload = {
1176
1176
  "carousel": carousel_config,
1177
1177
  "caption": caption_config,
@@ -1180,9 +1180,9 @@ def _build_carousel_caption_dsl(
1180
1180
  if defaults.get(key):
1181
1181
  custom_payload[key] = defaults[key]
1182
1182
 
1183
- # The carousel-caption capability also covers full-frame image sequences.
1184
- # Its declared sequence defaults select that payload shape; do not send
1185
- # carousel entrance/exit or a typewriter caption block to such components.
1183
+
1184
+
1185
+
1186
1186
  if sequence_mode:
1187
1187
  sequence_config = defaults["sequence"]
1188
1188
  sequence_config["items"] = [
@@ -1192,14 +1192,14 @@ def _build_carousel_caption_dsl(
1192
1192
  ]
1193
1193
  custom_payload = {"sequence": sequence_config}
1194
1194
 
1195
- # ── Narration skeleton ────────────────────────────────────────────────
1195
+
1196
1196
  narration_text = _localize_label(output_language, "narration_opening",
1197
1197
  label="", topic=_clip(topic, _PLACEHOLDER_TOPIC_CHARS), duration=duration)
1198
1198
 
1199
- # ── Effective scene duration ──────────────────────────────────────────
1200
- # fit-caption 策略:时长由打字机自然节奏决定,而不是盲取调用方传入的 targetDuration。
1201
- # 这是打字机/画面驱动模板的核心——没有旁白来"撑"时长,
1202
- # 必须由 caption 自己定,否则文字打完后画面会静止到 targetDuration。
1199
+
1200
+
1201
+
1202
+
1203
1203
  effective_duration = duration
1204
1204
  if duration_strategy == "fit-images":
1205
1205
  if not sequence_mode:
@@ -1215,7 +1215,7 @@ def _build_carousel_caption_dsl(
1215
1215
  file=sys.stderr,
1216
1216
  )
1217
1217
 
1218
- # ── Build scene (sceneStrategy=single: one scene carries everything) ──
1218
+
1219
1219
  text_layers = [
1220
1220
  {"role": "headline", "content": headline, "animation": "slide-up"},
1221
1221
  ]
@@ -1239,7 +1239,7 @@ def _build_carousel_caption_dsl(
1239
1239
  },
1240
1240
  }
1241
1241
 
1242
- # ── Assemble full DSL ─────────────────────────────────────────────────
1242
+
1243
1243
  effective_headline = headline or topic
1244
1244
  effective_subheadline = subheadline or ""
1245
1245
 
@@ -1261,8 +1261,8 @@ def _build_carousel_caption_dsl(
1261
1261
  "aspectRatio": ratio,
1262
1262
  "resolution": resolution,
1263
1263
  "fps": 30,
1264
- # 无配音模板:关闭 CC 字幕(字幕段是 narration→TTS 的派生物),
1265
- # 并省略 global.narration,避免下游误判存在旁白。
1264
+
1265
+
1266
1266
  "subtitle": {"enabled": narration_enabled, "style": "bottom"},
1267
1267
  **({"narration": {"voiceId": resolved_voice_id, "speed": narration_speed}} if narration_enabled else {}),
1268
1268
  **({"font": {"fontId": font_id, **({"fontName": font_name} if font_name else {})}} if font_id else {}),
@@ -1276,26 +1276,26 @@ def _build_carousel_caption_dsl(
1276
1276
  return dsl
1277
1277
 
1278
1278
 
1279
- # 「作者还没选版式」的显式标记。
1280
- #
1281
- # 为什么是哨兵而不是留空:slideId 缺失会静默回落到 DefaultSlide —— 渲染成功、零日志、
1282
- # 退出码 0,画面只剩一行居中标题,正是要消灭的失效形态。哨兵让下游能区分「作者没选」
1283
- # 与「作者选了但拼错」,两者的修法不同。dsl_validator 见到它会硬拒绝(同名常量在
1284
- # video_dsl/runtime/dsl_validator.py,两处字面量必须一致)。
1279
+
1280
+
1281
+
1282
+
1283
+
1284
+
1285
1285
  SLIDE_ID_SENTINEL = "__CHOOSE_SLIDE__"
1286
1286
 
1287
1287
 
1288
1288
  def _is_multi_slide_template(template_config: dict | None) -> bool:
1289
- """模板是否注册了多个可选版式 —— 由模板自己声明,不看模板 id。
1290
1289
 
1291
- 判据是 ``customPayloadSchema.slideId.enum`` 有没有超过一个取值:有得挑才谈得上
1292
- 「该挑哪个」。dsl_validator 的 ``_check_slide_id_chosen`` 用的是同一条判据,两侧
1293
- 因此不会对同一个模板给出相反的结论。
1294
1290
 
1295
- **刻意不写死模板 id**:哪个模板有几种版式是模板自己的事实,真源在它的
1296
- ``template.json``;把名单抄进 CLI,模板改名 / 新增多版式模板都要跟着发一次 npm,
1297
- 而漏发的表现是"这个模板又开始只出一种版式了"——正是本次要修的那个形态。
1298
- """
1291
+
1292
+
1293
+
1294
+
1295
+
1296
+
1297
+
1298
+
1299
1299
  schema = (template_config or {}).get("customPayloadSchema")
1300
1300
  if not isinstance(schema, dict):
1301
1301
  return False
@@ -1305,34 +1305,34 @@ def _is_multi_slide_template(template_config: dict | None) -> bool:
1305
1305
 
1306
1306
 
1307
1307
  def _build_custom_payload(template_config: dict | None) -> dict:
1308
- """骨架阶段的 customPayload —— **不猜版式、不拼画面文案**。
1309
1308
 
1310
- 这个函数曾经按 purpose 写死两种 slideId(opening / cta → demo-concept-overview,
1311
- 其余 → demo-single-concept),于是 16 种注册版式里只有 2 种会被用到,且与内容
1312
- 形态完全无关 —— 线上「不论选题版式永远一样」就是这么来的。它还往
1313
- templateData.description 里拼 "关于{topic}的要点内容",topic 长度不受控,画面
1314
- 溢出是必然的。
1315
1309
 
1316
- 现在分两档:
1317
1310
 
1318
- - 多版式模板 → 只放哨兵,等 agent 按内容挑版式并填 templateData;
1319
- - 其余 no-visual 模板 → 空载荷。这些模板只有一种版式,没有可挑的东西,而注入
1320
- 它们 schema 不认识的字段只会把真正要的 slide / statement / item 挤掉。
1321
- """
1311
+
1312
+
1313
+
1314
+
1315
+
1316
+
1317
+
1318
+
1319
+
1320
+
1321
+
1322
1322
  if _is_multi_slide_template(template_config):
1323
1323
  return {"slideId": SLIDE_ID_SENTINEL, "templateData": {}}
1324
1324
  return {}
1325
1325
 
1326
1326
 
1327
1327
  def _plan_countdown_scenes(spec: dict, count: int, duration: float, headline: str) -> list:
1328
- """``sceneStrategy=countdown``:一个开场 + N 个同类节拍,倒数编号,没有 cta。
1329
-
1330
- 为什么不能复用 ``arc``:``plan_scenes`` 写死了「至少 3 个场景」(Top 1 只需要 2 个)、
1331
- 无条件在末尾 append 一个 ``cta``(榜单类模板往往不要片尾),而且 ``content_scenes
1332
- = scene_count - 2``、编号从 #1 升序 —— 倒数要的正好相反。``fixed`` 则要一份静态
1333
- 场景表,表达不了动态的 N。所以 countdown 是 sceneStrategy 轴上的第四种形状,
1334
- 而不是某个模板的特例。
1335
- """
1328
+
1329
+
1330
+
1331
+
1332
+
1333
+
1334
+
1335
+
1336
1336
  head_sec = countdown_opening_sec(spec, count, duration)
1337
1337
  beat_sec = (duration - head_sec) / count
1338
1338
  return [{"purpose": "opening", "duration": head_sec, "label": headline}] + [
@@ -1342,15 +1342,15 @@ def _plan_countdown_scenes(spec: dict, count: int, duration: float, headline: st
1342
1342
 
1343
1343
 
1344
1344
  def _warn_on_count_mismatch(spec: dict, headline: str, subheadline: str, count: int) -> None:
1345
- """画面文字里写的数量与实际节拍数不一致时出声提醒(不阻断、不改写)。
1346
1345
 
1347
- **刻意只 warn 不改写。** 标题该怎么写、数量以什么措辞出现是模板的版式事实,
1348
- 模板在渲染时本来就会同步;手写 DSL、历史计划、ab-web 改过的标题都不经过
1349
- gen_script,所以这里替它改既不必要,也会静默改掉调用方给的文案。
1350
1346
 
1351
- 认哪些措辞由模板的 ``countdown.countPatterns`` 声明(见 countdown_spec),
1352
- CLI 不内置任何词表;没声明的模板不做这项检查。
1353
- """
1347
+
1348
+
1349
+
1350
+
1351
+
1352
+
1353
+
1354
1354
  for flag, value in (("--headline", headline), ("--subheadline", subheadline)):
1355
1355
  stated = countdown_stated_counts(spec, value)
1356
1356
  if stated and count not in stated:
@@ -1367,11 +1367,11 @@ def _warn_on_count_mismatch(spec: dict, headline: str, subheadline: str, count:
1367
1367
 
1368
1368
 
1369
1369
  def _template_background_presets(template_config: dict | None) -> list[str]:
1370
- """模板在 ``customPayloadSchema.background.preset`` 声明的背景取值(没声明 = 空表)。
1371
1370
 
1372
- 背景词汇是**模板自己的**:同样叫「网格」,不同模板的取值名各不相同,默认值也
1373
- 各不相同。所以这里只读声明,不在 CLI 里维护任何取值表。
1374
- """
1371
+
1372
+
1373
+
1374
+
1375
1375
  schema = (template_config or {}).get("customPayloadSchema") or {}
1376
1376
  background = schema.get("background") if isinstance(schema, dict) else None
1377
1377
  props = background.get("properties") if isinstance(background, dict) else None
@@ -1380,6 +1380,68 @@ def _template_background_presets(template_config: dict | None) -> list[str]:
1380
1380
  return [str(v) for v in enum] if isinstance(enum, list) else []
1381
1381
 
1382
1382
 
1383
+
1384
+
1385
+
1386
+
1387
+
1388
+
1389
+
1390
+ TEMPLATE_SOURCES = ("user-explicit", "user-confirmed", "auto")
1391
+ CONFIRMED_TEMPLATE_SOURCES = ("user-explicit", "user-confirmed")
1392
+
1393
+
1394
+ class TemplateSelectionError(ValueError):
1395
+ pass
1396
+
1397
+
1398
+ def _template_selection(
1399
+ template_id: str | None,
1400
+ source: str | None,
1401
+ candidates: list[str] | None,
1402
+ ) -> dict | None:
1403
+
1404
+
1405
+
1406
+
1407
+
1408
+ cands: list[str] = []
1409
+ for c in candidates or []:
1410
+ c = (c or "").strip()
1411
+ if c and c not in cands:
1412
+ cands.append(c)
1413
+
1414
+ if not source:
1415
+ if cands:
1416
+ raise TemplateSelectionError("--template-candidates needs --template-source.")
1417
+ return None
1418
+ if not template_id:
1419
+ raise TemplateSelectionError("--template-source needs --template-id: it records how that template was chosen.")
1420
+ if source == "auto":
1421
+ listed = ", ".join(cands) if cands else template_id
1422
+ raise TemplateSelectionError(
1423
+ f"template {template_id!r} was picked by the agent and the user has not confirmed it yet.\n"
1424
+ f" Stop here and ask the user to choose: list the candidates ({listed}) with one line on why "
1425
+ f"each fits and its detail-page link (template_registry pageUrl), recommended one first. End the turn.\n"
1426
+ f" After the user picks one, call gen_script again with --template-source user-confirmed "
1427
+ f"(and the same --template-candidates)."
1428
+ )
1429
+ if source not in CONFIRMED_TEMPLATE_SOURCES:
1430
+ raise TemplateSelectionError(
1431
+ f"--template-source must be one of {', '.join(TEMPLATE_SOURCES)} (got {source!r})."
1432
+ )
1433
+ if source == "user-confirmed" and cands and template_id not in cands:
1434
+ raise TemplateSelectionError(
1435
+ f"--template-id {template_id!r} is not among --template-candidates ({', '.join(cands)}). "
1436
+ f"If the user named a template outside the shortlist, that is --template-source user-explicit."
1437
+ )
1438
+
1439
+ selection: dict = {"source": source}
1440
+ if cands:
1441
+ selection["candidates"] = cands
1442
+ return selection
1443
+
1444
+
1383
1445
  def _apply_look(
1384
1446
  dsl: dict,
1385
1447
  template_config: dict | None,
@@ -1387,18 +1449,18 @@ def _apply_look(
1387
1449
  variant: str | None,
1388
1450
  background_preset: str | None,
1389
1451
  ) -> None:
1390
- """把用户选的视觉风格 / 背景样式写进 DSL(就地改)。
1391
1452
 
1392
- 这两个值以前没有任何一跳能落进管线:复刻弹窗让用户选了,gen_script 却只写模板
1393
- 默认背景,merge 模式又只替换旁白——成片照出、不报错,用户只觉得「好像没生效」。
1394
1453
 
1395
- - ``--variant`` → ``meta.templateVariant``:match_template 把它当**显式**选择,
1396
- 优先于 meta.style 的模糊匹配,最终成为 inputProps.variantId;
1397
- - ``--background-preset`` → 每个场景 ``customPayload.background.preset``:
1398
- customPayload 原样透传给模板,模板读的正是这个字段。保留模板默认背景里的其它键。
1399
1454
 
1400
- 取值不在模板声明里就直接退出:静默丢掉用户的选择,正是这里要修的那个 bug。
1401
- """
1455
+
1456
+
1457
+
1458
+
1459
+
1460
+
1461
+
1462
+
1463
+
1402
1464
  if not variant and not background_preset:
1403
1465
  return
1404
1466
  if not template_id or template_config is None:
@@ -1457,34 +1519,34 @@ def build_dsl(
1457
1519
  item_count: int | None = None,
1458
1520
  source_document_url: str | None = None,
1459
1521
  ) -> dict:
1460
- """Build a complete Video DSL JSON.
1461
-
1462
- When template_id is provided, loads the template config and adapts the DSL
1463
- structure to match the template's assetRequirements and scene patterns.
1464
-
1465
- 模板差异全部来自归一化 contract(capabilities):payloadStyle=carousel-caption
1466
- 走单场景轮播+打字机装配;其余按 sceneStrategy(arc/single/fixed)+ visual-overlay/
1467
- slide 走统一场景循环。无 input-schema.json、无 if template_id==X 分支。
1468
-
1469
- headline / subheadline 让作者显式提供画面上的"短主标题 + 副标题"。
1470
- DSL 元字段命名约定:
1471
- - meta.headline 画面上的主标题(建议 4-12 字)。默认退回 topic
1472
- - meta.subheadline 画面上的副标题(项目名 / 一句标语 / 来源)。默认 ""
1473
- 模板的 textLayers[role=headline|subheadline] 会取这两个字段,propExtractors
1474
- 通过 role=headline / role=subheadline 抽出对应的 props(如 titleText / projectName)。
1475
- 注意:subtitle(CC 字幕)由 global.subtitle 与 render-plan.subtitleSegments
1476
- 单独承载,与 subheadline 完全是两个东西,命名上刻意分开避免歧义。
1477
-
1478
- scene_images 是用户**自带的配图**(visual-overlay 模板专用),按顺序占位:第 i 张
1479
- 图给第 i 个场景,没被占到的场景照常走 gen-image 补图。这条规则不是随便定的——
1480
- 见 docs/product/creation/asset-annotation-design.md §6:另外两种立场(模型自由挑选 / 严格一一对应)
1481
- 都会让"只传两张图"要么失去可预期性,要么直接跑不通。
1482
-
1483
- speed 是旁白语速倍率,落在 global.narration.speed(不给 = 1.0)。它**不写进
1484
- gen-voice 资产的 payload**——与旁白文本同一条规矩:唯一来源在 DSL 上,
1485
- render_video 在调 TTS 前按 assetRef 回查注入,免得两处存一个值各自漂移。
1486
- """
1487
- # Load template config(registry 单一数据源)→ 归一化 contract
1522
+
1523
+
1524
+
1525
+
1526
+
1527
+
1528
+
1529
+
1530
+
1531
+
1532
+
1533
+
1534
+
1535
+
1536
+
1537
+
1538
+
1539
+
1540
+
1541
+
1542
+
1543
+
1544
+
1545
+
1546
+
1547
+
1548
+
1549
+
1488
1550
  template_config = _load_template_config(template_id) if template_id else None
1489
1551
  contract = _resolve_contract(template_config)
1490
1552
  countdown = contract["countdown"]
@@ -1496,15 +1558,15 @@ def build_dsl(
1496
1558
 
1497
1559
  primary_visual_type = contract["primary_visual_type"]
1498
1560
  payload_style = contract["payload_style"]
1499
- # visual-overlay 模板才生成视觉素材并用 visuals.background;slide / carousel-caption 不走。
1561
+
1500
1562
  has_visual = payload_style == "visual-overlay"
1501
- # 旁白是模板能力(capabilities.needsNarration)。声明为 false 的模板(纯视觉 /
1502
- # BGM-only,如单图 Ken Burns)不分配 gen-voice 素材、场景不写 audio.narration。
1563
+
1564
+
1503
1565
  needs_narration = contract["needs_narration"]
1504
1566
 
1505
- # ── Resolve template-driven output language + voice ────────────────────
1506
- # outputLanguage is owned by the template (template.json). Missing/invalid
1507
- # values fall back to "zh" so old templates keep their current behavior.
1567
+
1568
+
1569
+
1508
1570
  output_language, template_default_voice_id = _resolve_template_language_meta(template_config)
1509
1571
  resolved_voice_id = _resolve_voice_id(voice_id, template_default_voice_id, output_language)
1510
1572
  narration_speed = _resolve_narration_speed(speed)
@@ -1515,7 +1577,7 @@ def build_dsl(
1515
1577
  file=sys.stderr,
1516
1578
  )
1517
1579
 
1518
- # 缺省值:headline 退回 topic,subheadline 默认空字符串
1580
+
1519
1581
  effective_headline = _normalize_linebreaks(headline) or topic
1520
1582
  effective_subheadline = _normalize_linebreaks(subheadline)
1521
1583
  _echo_template_llm_hint(template_config, template_id)
@@ -1523,16 +1585,16 @@ def build_dsl(
1523
1585
  if beat_count is not None:
1524
1586
  _warn_on_count_mismatch(countdown, effective_headline, effective_subheadline, beat_count)
1525
1587
 
1526
- # ── carousel-caption 模板(单场景、图/视频轮播 + 打字机)单独装配并直接返回 ──
1588
+
1527
1589
  if payload_style == "carousel-caption":
1528
1590
  print(
1529
1591
  f"ℹ️ contract: payloadStyle=carousel-caption for template {template_id}",
1530
1592
  file=sys.stderr,
1531
1593
  )
1532
- # 兜底校验:carousel-caption 模板的画面由 carousel_items(图片/视频 URL)驱动,
1533
- # 文字由 caption_lines 驱动。两者皆空时会生成一个空轮播 + 空字幕的退化场景——
1534
- # 渲染出来就是「黑屏 + fit-caption 退化成最短 2s」。这是调用方(agent)忘了
1535
- # 把用户提供的图片塞进 --carousel-items 的典型表现,必须显式报错而不是静默产出。
1594
+
1595
+
1596
+
1597
+
1536
1598
  if not (carousel_items or []) and not (caption_lines or []):
1537
1599
  print(
1538
1600
  "❌ carousel-caption template "
@@ -1548,13 +1610,13 @@ def build_dsl(
1548
1610
  file=sys.stderr,
1549
1611
  )
1550
1612
  sys.exit(1)
1551
- # 兜底校验 ②:durationStrategy=fit-caption 的模板由打字机
1552
- # 文案驱动节奏 —— caption 就是内容本体,不是可选装饰。caption_lines 为空时上面的
1553
- # "两者皆空" 检查放行,产出的却是「顶部标题 + 轮播、底部一个字都没有」的退化片:
1554
- # 估时掉到轮播地板值(2 张图 ≈ 5s),而调用方往往还在确认摘要里描述了一段
1555
- # 根本没进 DSL 的文案,用户在确认环节也看不出来。所以这里必须硬失败。
1556
- # 注意:只卡 fit-caption。carousel-caption 里 durationStrategy=fit-images 的
1557
- # 纯视觉模板本来就允许无文案,不受影响。
1613
+
1614
+
1615
+
1616
+
1617
+
1618
+
1619
+
1558
1620
  if contract["duration_strategy"] == "fit-caption" and not (caption_lines or []):
1559
1621
  print(
1560
1622
  "❌ template "
@@ -1597,11 +1659,11 @@ def build_dsl(
1597
1659
  _enforce_supported_duration(carousel_dsl, template_config, template_id)
1598
1660
  return carousel_dsl
1599
1661
 
1600
- # ── 其余模板:统一场景规划(arc 叙事弧 / single / fixed)+ 统一装配循环 ──────
1601
- #
1602
- # scene_images 的每一项要么是一条 URL(用已有素材),要么是 `ai` / `ai:<提示词>`
1603
- # (这一段留给 gen-image)。两者共用同一个位置序列 —— 用户在界面上排出来的顺序
1604
- # 就是它,AI 段能插在任意位置,而不是只能挂在末尾。
1662
+
1663
+
1664
+
1665
+
1666
+
1605
1667
  provided_slots = [s.strip() for s in (scene_images or []) if s and s.strip()]
1606
1668
 
1607
1669
  if beat_count is not None:
@@ -1609,14 +1671,14 @@ def build_dsl(
1609
1671
  else:
1610
1672
  scene_plans = _plan_contract_scenes(contract, topic, duration, scene_count, output_language)
1611
1673
 
1612
- # 条目比场景多时**抬高**场景数,让每一条都有地方放。
1613
- #
1614
- # 只抬高、不压低:给 2 张图不该把一条 30s 的片子从 5 段压成 3 段。用户给图表达的是
1615
- # "这几张都要用上",不是"整条片子改成这么多段"——按条目数直接改写场景数,会让
1616
- # "多传了两张图"变成"视频结构被换掉了",而他根本没要求这个。
1617
- #
1618
- # 反过来条目多于场景时必须抬:不抬的话第 N+1 条之后会被静默丢掉,而用户在成片里是
1619
- # 看不出"我的图去哪了"的。arc 之外的策略场景数由模板钉死,抬不动(下面警告兜底)。
1674
+
1675
+
1676
+
1677
+
1678
+
1679
+
1680
+
1681
+
1620
1682
  if (
1621
1683
  provided_slots
1622
1684
  and scene_count is None
@@ -1629,8 +1691,8 @@ def build_dsl(
1629
1691
  contract, topic, duration, len(provided_slots), output_language
1630
1692
  )
1631
1693
 
1632
- # 排完之后仍然装不下的(single / fixed 模板,或用户显式指定了更少的场景数)就明说。
1633
- # 静默丢弃用户自带的素材是这条链上最难被发现的一类错。
1694
+
1695
+
1634
1696
  if provided_slots and len(provided_slots) > len(scene_plans):
1635
1697
  print(
1636
1698
  f"\u26a0\ufe0f {len(provided_slots)} scene-image entries provided but the template "
@@ -1643,9 +1705,9 @@ def build_dsl(
1643
1705
  assets = []
1644
1706
  scenes = []
1645
1707
 
1646
- # 模板的生图指南。compose 模式(imageStyleGuide.mode == "compose")下骨架里的
1647
- # payload.prompt 只是「画面主体」占位 —— 画风 / 配色 / 负向词由 render_video 在旁白
1648
- # 定稿后统一拼上,这里若先拼一份完整 prompt,agent 会以为写好了,或在它上面叠写。
1708
+
1709
+
1710
+
1649
1711
  image_style_guide = resolve_style_guide(template_config) if _HAS_ENHANCER else {}
1650
1712
  image_compose = _HAS_ENHANCER and is_compose_mode(image_style_guide)
1651
1713
  image_ratio = image_style_guide.get("ratio") or ratio
@@ -1653,7 +1715,7 @@ def build_dsl(
1653
1715
  tail_pad_sec = _nb.template_tail_pad_sec(template_config) if _HAS_BUDGET else None
1654
1716
  for idx, plan in enumerate(scene_plans):
1655
1717
  scene_id = f"scene-{idx + 1:02d}"
1656
- # 视觉素材的 assetId 命名遵循类型前缀:image → img-,video → video-
1718
+
1657
1719
  if primary_visual_type == "video":
1658
1720
  visual_asset_id = f"video-{scene_id}"
1659
1721
  else:
@@ -1667,16 +1729,16 @@ def build_dsl(
1667
1729
  if needs_narration else ""
1668
1730
  )
1669
1731
 
1670
- # 本场景的版式不显示背景素材(如绘本 ending)→ 不规划视觉素材,免得白生成
1732
+
1671
1733
  scene_has_visual = has_visual and _slot_reads_visual(template_config, plan["purpose"])
1672
1734
 
1673
- # 仅 visual-overlay 模板生成视觉素材(slide 无视觉素材,carousel-caption 已提前返回)
1735
+
1674
1736
  if scene_has_visual and primary_visual_type == "image":
1675
1737
  slot = provided_slots[idx] if idx < len(provided_slots) else ""
1676
1738
  slot_is_ai, slot_prompt = _parse_ai_slot(slot)
1677
1739
  if slot and not slot_is_ai:
1678
- # 用户自带的图排在 stub 之前:stub 是"别烧配额"的测试开关,而用户给的
1679
- # 图同样一分钱不花,没有理由拿占位图把真素材盖掉。
1740
+
1741
+
1680
1742
  assets.append({
1681
1743
  "assetId": visual_asset_id,
1682
1744
  "type": "image",
@@ -1684,9 +1746,9 @@ def build_dsl(
1684
1746
  "status": "generated",
1685
1747
  "url": slot,
1686
1748
  })
1687
- # stub 盖得住 AI 段,盖不住用户自带的图。差别在于花不花钱:--stub-image-url
1688
- # 的全部意义就是"这一趟别调 gen-image",而 AI 段恰恰是要调的那种;反过来
1689
- # 用户自带的 URL 一分钱不花,拿占位图把真素材盖掉纯属损失。
1749
+
1750
+
1751
+
1690
1752
  elif stub_image_url:
1691
1753
  assets.append({
1692
1754
  "assetId": visual_asset_id,
@@ -1704,10 +1766,10 @@ def build_dsl(
1704
1766
  narration_text=narration_text, style_guide=image_style_guide,
1705
1767
  )
1706
1768
 
1707
- # 用户在 AI 段里写了提示词就用他的。**只替换主体,不动 negativePrompt /
1708
- # guidanceScale**:那两项是模板的护栏("不要文字水印、不要变形"),把它们
1709
- # 一起丢掉会让用户随手写的一句话换来一张带乱码文字的图,而他并没有要求
1710
- # 关掉护栏——他只是想说这一段画什么。
1769
+
1770
+
1771
+
1772
+
1711
1773
  if slot_prompt:
1712
1774
  image_result = {**image_result, "prompt": slot_prompt}
1713
1775
 
@@ -1738,16 +1800,16 @@ def build_dsl(
1738
1800
  "url": stub_video_url,
1739
1801
  })
1740
1802
  else:
1741
- # 视频素材:复用 image prompt 生成器作为兜底,再追加 "视频/动态" 关键词
1742
- # 模型 / 时长 / 比例都遵循 gen-video 校验规则(Seedance 2.0:4-15s)
1803
+
1804
+
1743
1805
  image_result = generate_image_prompt(plan["purpose"], topic, style, narration_text=narration_text)
1744
1806
  video_prompt = image_result["prompt"]
1745
- # 视频时长按 gen-video 的 4-15s 区间截断
1807
+
1746
1808
  video_duration = max(4, min(int(plan["duration"]), 15))
1747
1809
  vid_payload = {
1748
1810
  "prompt": video_prompt,
1749
- # 写别名而不是带日期的模型 ID:真源是 ab-api 的能力目录,
1750
- # 版本号换代时这里不该跟着改(seedance → 当前的 Seedance 2.0)
1811
+
1812
+
1751
1813
  "model": os.environ.get("DEFAULT_VIDEO_MODEL", "seedance"),
1752
1814
  "ratio": ratio,
1753
1815
  "duration": video_duration,
@@ -1760,10 +1822,10 @@ def build_dsl(
1760
1822
  "payload": vid_payload,
1761
1823
  })
1762
1824
 
1763
- # narration text 不再在 audio asset 的 payload 里冗余存放——
1764
- # 唯一来源是下游 scenes[].audio.narration.text,render_video 在
1765
- # 调用 TTS skill 前会按 assetRef 回查 scene 文本注入。
1766
- # 模板声明 needsNarration=false 时,完全不生成 gen-voice 素材。
1825
+
1826
+
1827
+
1828
+
1767
1829
  if needs_narration:
1768
1830
  assets.append({
1769
1831
  "assetId": narration_asset_id,
@@ -1788,9 +1850,9 @@ def build_dsl(
1788
1850
  text_layers = [
1789
1851
  {"role": "badge", "content": plan["label"], "animation": "slide-up"},
1790
1852
  ]
1791
- # 给所有 point 场景补一个 headline textLayer:凡是把 titleText 从
1792
- # textLayers[role=headline] 抽出来的 propExtractors 都能直接拿到主标题,
1793
- # 不再依赖 binding 的兜底逻辑。
1853
+
1854
+
1855
+
1794
1856
  text_layers.insert(0, {"role": "headline", "content": effective_headline, "animation": "fade-in"})
1795
1857
  if effective_subheadline:
1796
1858
  text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
@@ -1820,37 +1882,37 @@ def build_dsl(
1820
1882
  "narration": {
1821
1883
  "text": narration_text,
1822
1884
  "assetRef": narration_asset_id,
1823
- # 骨架标记:scenes[].audio.narration.text 同样需要被真实旁白替换
1885
+
1824
1886
  "needsFill": True,
1825
1887
  },
1826
1888
  }
1827
1889
  scenes.append(scene)
1828
1890
  else:
1829
- # The "no-visual" branch labels each scene with a layout hint:
1830
- # 1. template.capabilities.defaultLayout ⇐ the template tells
1831
- # remixmate what layout name it wants on its no-visual scenes
1832
- # 2. template_id ⇐ legacy: pass id as
1833
- # layout name (existing behavior when no capability declared)
1834
- # 3. "text-overlay" ⇐ no-template case
1835
- #
1836
- # ⚠️ 第 3 档以前硬编码着模板库里某个具体模板的 id —— 没选模板时,每个纯
1837
- # 音频场景都被贴上那个名字。那是这份脚本唯一一处"认识某个模板"的可执行代码:
1838
- # 没有模板参与的通用 DSL 不该知道模板库里有谁,模板换名 / 下架都会让这个
1839
- # 字面量变成谎话。改成 schema 自己的枚举值(Scene.layout 的合法取值是
1840
- # full-visual / text-overlay / … 这一组,模板 id 从来就不在里面,写进去反而
1841
- # 会让 dsl_validator 报一条 layout 枚举 warning)。
1842
- #
1843
- # 这个字段是纯描述性的:match_template 只把它抄进 binding 的 layoutVariant,
1844
- # 而 layoutVariant 在 ab-render 与 template-library 里都没有任何读取方
1845
- # (已核对),渲染走的是 slotMapping / compositionId。所以换值不改成片。
1891
+
1892
+
1893
+
1894
+
1895
+
1896
+
1897
+
1898
+
1899
+
1900
+
1901
+
1902
+
1903
+
1904
+
1905
+
1906
+
1907
+
1846
1908
  _layout_cfg = (template_config or {}).get("capabilities") or {}
1847
1909
  layout = _layout_cfg.get("defaultLayout") or template_id or "text-overlay"
1848
- # ⚠️ 画面文字用 effective_headline / effective_subheadline,与上面的
1849
- # has_visual 分支同源。这两条分支长期分叉:visual 分支一直用的是
1850
- # --headline,no-visual 分支却渲染裸 topic —— SKILL.md 写着"用户给了标题
1851
- # 就传 --headline,否则 headline 会回落成长 topic 并撑破顶部文字层",
1852
- # agent 照做了,这条分支没兑现。调用方把整篇提纲当 topic 传进来时,成片
1853
- # 必然是一屏文字墙,而 meta.headline 里存的又是对的,只看 meta 查不出来。
1910
+
1911
+
1912
+
1913
+
1914
+
1915
+
1854
1916
  text_layers = []
1855
1917
  if plan["purpose"] == "opening":
1856
1918
  text_layers = [
@@ -1874,8 +1936,8 @@ def build_dsl(
1874
1936
 
1875
1937
  custom_payload = _build_custom_payload(template_config)
1876
1938
  if beat_count is not None:
1877
- # Field names come from the template's countdown declaration — this
1878
- # function does not know what a "rank" or a "topN" is.
1939
+
1940
+
1879
1941
  custom_payload.update(
1880
1942
  countdown_scene_payload(countdown, beat_count, plan.get("countdownIndex"))
1881
1943
  )
@@ -1887,8 +1949,8 @@ def build_dsl(
1887
1949
  "layout": layout,
1888
1950
  "textLayers": text_layers,
1889
1951
  }
1890
- # 单版式模板的载荷是空 dict —— 写一个空 customPayload 只会让 agent 以为
1891
- # 这里有个需要填的结构,干脆不写这个键。
1952
+
1953
+
1892
1954
  if custom_payload:
1893
1955
  scene["customPayload"] = custom_payload
1894
1956
  if needs_narration:
@@ -1896,7 +1958,7 @@ def build_dsl(
1896
1958
  "narration": {
1897
1959
  "text": narration_text,
1898
1960
  "assetRef": narration_asset_id,
1899
- # 骨架标记:scenes[].audio.narration.text 同样需要被真实旁白替换
1961
+
1900
1962
  "needsFill": True,
1901
1963
  },
1902
1964
  }
@@ -1914,15 +1976,15 @@ def build_dsl(
1914
1976
  "outputLanguage": output_language,
1915
1977
  "style": style or "",
1916
1978
  "description": _localize_label(output_language, "video_description", topic=topic, duration=duration),
1917
- # compose 模式:留一个空位让 agent 写全书共用的角色外观设定,每张图都会逐字注入
1979
+
1918
1980
  **({"characterSheet": ""} if image_compose and has_visual else {}),
1919
1981
  },
1920
1982
  "global": {
1921
1983
  "aspectRatio": ratio,
1922
1984
  "resolution": resolution,
1923
1985
  "fps": 30,
1924
- # 无配音模板:关闭 CC 字幕(字幕段是 narration→TTS 的派生物),并省略
1925
- # global.narration,避免下游误判存在旁白。
1986
+
1987
+
1926
1988
  "subtitle": {"enabled": needs_narration, "style": "bottom"},
1927
1989
  **({"narration": {"voiceId": resolved_voice_id, "speed": narration_speed}} if needs_narration else {}),
1928
1990
  **({"font": {"fontId": font_id, **({"fontName": font_name} if font_name else {})}} if font_id else {}),
@@ -1930,7 +1992,7 @@ def build_dsl(
1930
1992
  },
1931
1993
  "assets": assets,
1932
1994
  "scenes": scenes,
1933
- # 模板声明了 capabilities.defaultTransition 就用它(绘本 = none:淡入会露出黑底)
1995
+
1934
1996
  "transitions": {"default": contract["default_transition"] or "fade", "duration": 0.5},
1935
1997
  }
1936
1998
 
@@ -1940,9 +2002,9 @@ def build_dsl(
1940
2002
  _record_source_document(dsl, template_config, template_id, source_document_url)
1941
2003
 
1942
2004
  if contract["authored_scenes"]:
1943
- # 场景表由调用方按用户素材编写(如按操作时间点切录屏),骨架只提供 meta/global。
1944
- # 这个标记必须由骨架携带:ab-agent 看到它才会整份采用模型提交的 DSL,
1945
- # 否则它以骨架为基底合并,新增场景、existing 素材、visuals 都会被静默丢掉。
2005
+
2006
+
2007
+
1946
2008
  dsl["meta"]["authoredScenes"] = True
1947
2009
  print(
1948
2010
  f"ℹ️ template '{template_id}' declares capabilities.authoredScenes: the scene list below "
@@ -2025,6 +2087,19 @@ Examples:
2025
2087
  parser.add_argument("--allow-digital-human", action="store_true", help="Allow digital-human assets")
2026
2088
  parser.add_argument("--allow-ai-video", action="store_true", help="Allow AI-generated video assets")
2027
2089
  parser.add_argument("--template-id", default=None, help="Template id. The template owns outputLanguage and may declare a defaultVoiceId.")
2090
+ parser.add_argument(
2091
+ "--template-source", default=None, choices=TEMPLATE_SOURCES,
2092
+ help="How --template-id was chosen, recorded at meta.templateSelection.source. "
2093
+ "user-explicit: the user named this template (id, name, detail-page prompt, or an earlier turn). "
2094
+ "user-confirmed: you recommended it and the user confirmed in the chat. "
2095
+ "auto: you picked it and the user has not confirmed — gen_script refuses and tells you to ask first. "
2096
+ "Requires --template-id.",
2097
+ )
2098
+ parser.add_argument(
2099
+ "--template-candidates", action="append", default=None,
2100
+ help="Template ids you offered the user (repeatable), recommended first. Recorded at "
2101
+ "meta.templateSelection.candidates. With user-confirmed, --template-id must be one of them.",
2102
+ )
2028
2103
  parser.add_argument("--validate", action="store_true", help="Validate-only mode: only validate the input DSL")
2029
2104
  parser.add_argument("--input", "-i", help="Input DSL file path (required for validate mode)")
2030
2105
  parser.add_argument(
@@ -2125,10 +2200,25 @@ Examples:
2125
2200
  )
2126
2201
  sys.exit(1)
2127
2202
 
2128
- # ── 模板感知的 ratio 自动推导 ────────────────────────────────────────────
2129
- # 用户没显式传 --ratio 时:
2130
- # 1. 若指定了 --template-id,取模板 supportedAspectRatios[0](避免横竖屏不匹配)
2131
- # 2. 否则回退默认 16:9
2203
+
2204
+ try:
2205
+ template_selection = _template_selection(
2206
+ args.template_id, args.template_source, args.template_candidates,
2207
+ )
2208
+ except TemplateSelectionError as exc:
2209
+ print(f"❌ {exc}", file=sys.stderr)
2210
+ sys.exit(1)
2211
+ if args.template_id and template_selection is None:
2212
+ print(
2213
+ "ℹ️ --template-source not given: how this template was chosen is not recorded. "
2214
+ "If the user did not name it, stop and let them confirm it before writing the script.",
2215
+ file=sys.stderr,
2216
+ )
2217
+
2218
+
2219
+
2220
+
2221
+
2132
2222
  resolved_ratio = args.ratio
2133
2223
  if resolved_ratio is None:
2134
2224
  if args.template_id:
@@ -2158,8 +2248,8 @@ Examples:
2158
2248
  if args.speed is not None:
2159
2249
  print(f" narration speed: {args.speed}x", file=sys.stderr)
2160
2250
 
2161
- # CLI flag takes precedence; env vars act as fallback with a visible warning
2162
- # so silent cross-session leakage is always observable.
2251
+
2252
+
2163
2253
  stub_image_url = args.stub_image_url
2164
2254
  stub_video_url = args.stub_video_url
2165
2255
  if stub_image_url is None:
@@ -2173,11 +2263,11 @@ Examples:
2173
2263
  print(f"⚠️ STUB_VIDEO_URL env var detected ({env_v}); using it as the video stub. Prefer passing --stub-video-url explicitly, or unset the env var.", file=sys.stderr)
2174
2264
  stub_video_url = env_v
2175
2265
 
2176
- # --skip-asset-generation 是为下游"只想要 DSL shape"的 agent 设计的
2177
- # 一键开关:等价于 --stub-image-url + --stub-video-url + 把 gen-voice 资产
2178
- # 也写成 source=existing + status=generated。当用户没显式提供 stub URL 时
2179
- # 用一组 sentinel 占位(https://placeholder.local/...),模板创作 / 调试场景
2180
- # 不会真的去 fetch 这些 URL。
2266
+
2267
+
2268
+
2269
+
2270
+
2181
2271
  if args.skip_asset_generation:
2182
2272
  if not stub_image_url:
2183
2273
  stub_image_url = "https://placeholder.local/stub.png"
@@ -2215,9 +2305,9 @@ Examples:
2215
2305
  source_document_url=args.source_document_url,
2216
2306
  )
2217
2307
  except ValueError as exc:
2218
- # Covers both a bad --item-count and an unusable countdown declaration
2219
- # (CountdownError subclasses ValueError), so an authoring mistake surfaces
2220
- # as a message instead of a traceback.
2308
+
2309
+
2310
+
2221
2311
  parser.error(str(exc))
2222
2312
 
2223
2313
  try:
@@ -2231,19 +2321,22 @@ Examples:
2231
2321
  except ValueError as exc:
2232
2322
  parser.error(str(exc))
2233
2323
 
2234
- # Post-process for --skip-asset-generation: rewrite all gen-voice / gen-digital-human
2235
- # assets to be already-generated stubs. Image / video are already covered by the
2236
- # stub_image_url / stub_video_url params threaded through build_dsl above.
2324
+ if template_selection:
2325
+ dsl.setdefault("meta", {})["templateSelection"] = template_selection
2326
+
2327
+
2328
+
2329
+
2237
2330
  if args.skip_asset_generation:
2238
2331
  _AUDIO_STUB = "https://placeholder.local/stub.mp3"
2239
- _VIDEO_STUB = stub_video_url # same sentinel for digital-human placeholder
2332
+ _VIDEO_STUB = stub_video_url
2240
2333
  for asset in dsl.get("assets", []):
2241
2334
  src = asset.get("source")
2242
2335
  if src in ("gen-voice",):
2243
2336
  asset["source"] = "existing"
2244
2337
  asset["status"] = "generated"
2245
2338
  asset["url"] = _AUDIO_STUB
2246
- # payload 留作 reference(renderer 不会再读它,因为 status=generated)
2339
+
2247
2340
  elif src in ("gen-digital-human",):
2248
2341
  asset["source"] = "existing"
2249
2342
  asset["status"] = "generated"
@@ -2256,7 +2349,7 @@ Examples:
2256
2349
  print(f" - {err}", file=sys.stderr)
2257
2350
  sys.exit(1)
2258
2351
 
2259
- # Non-blocking language sanity check — only narrations from scenes[].
2352
+
2260
2353
  expected_language = dsl.get("meta", {}).get("outputLanguage", DEFAULT_LANGUAGE)
2261
2354
  if expected_language in ("en", "zh"):
2262
2355
  narrations_for_check = [
@@ -2279,8 +2372,8 @@ if __name__ == "__main__":
2279
2372
  try:
2280
2373
  main()
2281
2374
  except TemplateStatusGatedError as exc:
2282
- # 状态门控挡住了一个确实在线的模板。这是配置问题,不是调用方参数错误,
2283
- # 所以给一条可执行的提示而不是 traceback —— 并且**必须**非零退出:静默
2284
- # 退回通用骨架正是这次要消灭的失败形态。
2375
+
2376
+
2377
+
2285
2378
  print(f"❌ {exc}", file=sys.stderr)
2286
2379
  sys.exit(1)