@remixmate/cli 0.9.72 → 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 (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,27 +1,27 @@
1
1
  #!/usr/bin/env python3
2
- """
3
- 视频渲染工具 - 接收 DSL + TemplateBinding,补齐素材,调用 Remotion 渲染
4
2
 
5
- 默认行为:
6
- 读取 DSL 与 TemplateBinding,解析缺失素材,调用原子 Skills 生成,
7
- 编译时间线,最终调用 Remotion 渲染引擎输出视频。
8
3
 
9
- 用法:
10
- python render_video.py --dsl video.dsl.json --template-id <templateId>
11
- python render_video.py --dsl video.dsl.json --template-id <templateId> --resolve-only
12
- python render_video.py --render-plan video.render-plan.json
13
4
 
14
- 环境变量:
15
- PRIV_TOKEN - PrivToken(素材生成需要)
16
- MM_API_BASE_URL - API 根地址
17
- REMOTION_OUTPUT_DIR - 渲染输出目录(默认: tempfile.mkdtemp,按调用隔离;调试时可显式指定固定路径)
18
- ASSET_CACHE_DIR - 素材缓存目录(默认: ./.asset-cache/)
19
- REMOTION_SKIP_VENDOR_CHROME - 设为 1 则不从 OSS 拉取 Chrome Headless
20
- REMOTION_FORCE_VENDOR_CHROME - 设为 1 则强制重新下载并解压(同 REMOTION_FORCE_VENDOR_ZIP)
21
- REMOTION_CHROME_VENDOR_JSON - vendor-zip-urls.json 路径(默认在 chrome-headless-vendor-template 下)
22
- REMOTION_CHROME_VENDOR_DOWNLOAD_TIMEOUT - 下载超时秒数(默认 3600)
23
- REMOTION_CHROME_SHARE_DIR - 浏览器 zip 共享目录(默认 /tmp/agent-share/chrome-headless,仅存 VERSION + *.zip)
24
- """
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
25
 
26
26
  import argparse
27
27
  import atexit
@@ -38,25 +38,25 @@ import urllib.request
38
38
  from datetime import datetime, timezone
39
39
  from typing import Optional
40
40
 
41
- # ── Cross-skill dependency ────────────────────────────────────────────────
42
- # Reach into `skills/template-registry/` — that directory is the de-facto home
43
- # for shared Python code in ab-skill (timeline-compilation, registry loader,
44
- # render-job HTTP client, etc.). Naming it `_SHARED_LIB_DIR` reflects its
45
- # actual role: it hosts much more than template lookup (see AGENTS.md
46
- # "skills/template-registry/scripts/" note).
47
- #
48
- # If signatures of the functions below change, also update:
49
- # - skills/template-registry/video_dsl/runtime/timeline_compiler.py (the source)
50
- # - any other caller discoverable via `grep -r "split_subtitle\|segment_narration"`
51
- # ───────────────────────────────────────────────────────────────────────────
41
+
42
+
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+
52
52
  _SHARED_LIB_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "template-registry")
53
- # Single sys.path setup for both cross-skill import surfaces of the shared lib:
54
- # - <shared> for `video_dsl.runtime.*`
55
- # - <shared>/scripts for `registry_loader`, `match_template`,
56
- # `render_job_client`, `countdown_spec`
57
- # Each deeper function used to repeat its own sys.path.insert; consolidating
58
- # here keeps the module's import side-effects in one place and matches
59
- # Python's "set up sys.path once at module top" idiom.
53
+
54
+
55
+
56
+
57
+
58
+
59
+
60
60
  sys.path.insert(0, _SHARED_LIB_DIR)
61
61
  sys.path.insert(0, os.path.join(_SHARED_LIB_DIR, "scripts"))
62
62
  from video_dsl.runtime.timeline_compiler import (
@@ -66,52 +66,52 @@ from video_dsl.runtime.timeline_compiler import (
66
66
  assign_sentence_indices,
67
67
  )
68
68
 
69
- # 字幕末尾标点去除(与 timeline_compiler 中的 _strip_trailing_punct 同逻辑)
69
+
70
70
  _TRAILING_PUNCT_RE = re.compile(r"[。!?;,,、::..…]+$")
71
71
 
72
72
  def _strip_subtitle_trailing_punct(text: str) -> str:
73
- """去掉字幕段末尾的标点符号,让画面更干净。"""
73
+
74
74
  return _TRAILING_PUNCT_RE.sub("", text)
75
75
 
76
76
 
77
- # ── 字幕段二次平衡(切长 + 合短) ─────────────────────────────────────
78
77
 
79
- _SUB_MAX_CHARS = 18 # 超过此长度的段尝试二次切分
80
- _SUB_MIN_CHARS = 7 # 短于此长度的段尝试合并到相邻段
81
- _SUB_SPLIT_RE = re.compile(r"(?<=[,,、;;])") # 二次切分点:顿号/逗号/分号后
78
+
79
+ _SUB_MAX_CHARS = 18
80
+ _SUB_MIN_CHARS = 7
81
+ _SUB_SPLIT_RE = re.compile(r"(?<=[,,、;;])")
82
82
 
83
83
 
84
84
  def _rebalance_subtitle_segments(segs: list[dict]) -> list[dict]:
85
- """对 TTS 返回的字幕段做二次平衡:
86
- 1. 过长的段(> _SUB_MAX_CHARS)在逗号/顿号处切开,时间按字符比例分配
87
- 2. 过短的段(< _SUB_MIN_CHARS)合并到前一段(共享时间窗口)
88
- """
85
+
86
+
87
+
88
+
89
89
  if not segs:
90
90
  return segs
91
91
 
92
- # Phase 1: 切长
92
+
93
93
  expanded: list[dict] = []
94
94
  for seg in segs:
95
95
  text = seg["text"]
96
96
  if len(text) <= _SUB_MAX_CHARS:
97
97
  expanded.append(seg)
98
98
  continue
99
- # 尝试在逗号/顿号处切分
99
+
100
100
  parts = _SUB_SPLIT_RE.split(text)
101
101
  parts = [p for p in parts if p.strip()]
102
102
  if len(parts) <= 1:
103
- # 没有合适的切分点,保持原样
103
+
104
104
  expanded.append(seg)
105
105
  continue
106
- # 按字符比例分配时间
106
+
107
107
  total_chars = max(sum(len(p) for p in parts), 1)
108
108
  total_frames = seg["endFrame"] - seg["startFrame"]
109
109
  cur_frame = seg["startFrame"]
110
110
  for p in parts:
111
111
  ratio = len(p) / total_chars
112
112
  frames = max(int(total_frames * ratio), 1)
113
- # `**seg` 而不是只写三个键:切出来的各部分仍属于**同一句**,
114
- # 所以 sentenceIndex 等字段要原样继承(见 build_render_plan 处的说明)。
113
+
114
+
115
115
  expanded.append({
116
116
  **seg,
117
117
  "text": _strip_subtitle_trailing_punct(p.strip()),
@@ -119,22 +119,22 @@ def _rebalance_subtitle_segments(segs: list[dict]) -> list[dict]:
119
119
  "endFrame": cur_frame + frames,
120
120
  })
121
121
  cur_frame += frames
122
- # 修正最后一段的 endFrame 对齐
122
+
123
123
  if expanded:
124
124
  expanded[-1]["endFrame"] = seg["endFrame"]
125
125
 
126
- # Phase 2: 合短(把过短的段合并到前一段)
126
+
127
127
  if len(expanded) <= 1:
128
128
  return expanded
129
129
  merged: list[dict] = [expanded[0]]
130
130
  for seg in expanded[1:]:
131
131
  if len(seg["text"]) < _SUB_MIN_CHARS and merged:
132
- # 合并到前一段:文本拼接,endFrame 取后者
132
+
133
133
  merged[-1]["text"] = merged[-1]["text"] + seg["text"]
134
134
  merged[-1]["endFrame"] = seg["endFrame"]
135
135
  else:
136
136
  merged.append(seg)
137
- # 最后一段如果太短也合并
137
+
138
138
  if len(merged) > 1 and len(merged[-1]["text"]) < _SUB_MIN_CHARS:
139
139
  merged[-2]["text"] = merged[-2]["text"] + merged[-1]["text"]
140
140
  merged[-2]["endFrame"] = merged[-1]["endFrame"]
@@ -142,15 +142,15 @@ def _rebalance_subtitle_segments(segs: list[dict]) -> list[dict]:
142
142
 
143
143
  return merged
144
144
 
145
- # remotion-renderer 位于 monorepo 的 apps/ab-render/ 目录。脚本按相对位置推断
146
- # (skills/render-video/scripts/ → ../../../../apps/ab-render)。
147
- # 若 skill 被单独 clone 或路径不同,通过 REMOTION_RENDERER_DIR 环境变量覆盖。
145
+
146
+
147
+
148
148
  _DEFAULT_RENDERER_DIR = os.path.abspath(
149
149
  os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "apps", "ab-render")
150
150
  )
151
151
  REMOTION_RENDERER_DIR = os.environ.get("REMOTION_RENDERER_DIR", _DEFAULT_RENDERER_DIR)
152
- # 默认按调用创建独立 tempdir,避免多用户并发时落盘文件互相覆盖。
153
- # 调试场景可显式设置 REMOTION_OUTPUT_DIR 指向固定路径。
152
+
153
+
154
154
  OUTPUT_DIR = os.environ.get("REMOTION_OUTPUT_DIR") or tempfile.mkdtemp(prefix="ab-render-")
155
155
  ASSET_CACHE_DIR = os.environ.get("ASSET_CACHE_DIR", "./.asset-cache")
156
156
 
@@ -164,13 +164,13 @@ RESOLUTION_MAP = {
164
164
 
165
165
 
166
166
  def _narration_speed(narration: dict) -> Optional[float]:
167
- """Read a narration block's speech-rate multiplier, or None when unset.
168
167
 
169
- Tolerant on purpose: the DSL is hand-editable, and a malformed speed must
170
- not take down a render that would otherwise be fine — it falls back to the
171
- voice's own pace. 0 and negatives are treated as unset for the same reason
172
- ab-api does (`speed: 0` there means "follow the global setting").
173
- """
168
+
169
+
170
+
171
+
172
+
173
+
174
174
  if not isinstance(narration, dict):
175
175
  return None
176
176
  raw = narration.get("speed")
@@ -180,14 +180,14 @@ def _narration_speed(narration: dict) -> Optional[float]:
180
180
 
181
181
 
182
182
  def _narration_language_boost(narration: dict) -> Optional[str]:
183
- """Read a narration block's language hint, or None when unset.
184
-
185
- Same tolerance as _narration_speed: a malformed value must not take down an
186
- otherwise fine render. The service applies its own default when we send
187
- nothing, so "unset" is always a safe outcome — this flag exists to pin a
188
- language when auto-detection is not good enough (e.g. an English-only
189
- template whose narration still carries a Chinese brand name).
190
- """
183
+
184
+
185
+
186
+
187
+
188
+
189
+
190
+
191
191
  if not isinstance(narration, dict):
192
192
  return None
193
193
  raw = narration.get("languageBoost")
@@ -197,14 +197,14 @@ def _narration_language_boost(narration: dict) -> Optional[str]:
197
197
 
198
198
 
199
199
  def _narration_voice(narration: dict) -> Optional[str]:
200
- """Read a narration block's voice id, or None when unset.
201
-
202
- Same tolerance as _narration_speed. Exists because the voice used to be read
203
- only from the audio asset's payload.voiceId — which gen_script bakes once
204
- when it drafts the skeleton — so changing global.narration.voiceId (or a
205
- scene's voice) after that never reached TTS: the video kept the original
206
- voice while every other layer reported the new one (2026-09-28).
207
- """
200
+
201
+
202
+
203
+
204
+
205
+
206
+
207
+
208
208
  if not isinstance(narration, dict):
209
209
  return None
210
210
  raw = narration.get("voiceId")
@@ -214,12 +214,12 @@ def _narration_voice(narration: dict) -> Optional[str]:
214
214
 
215
215
 
216
216
  def extract_narration_lines(narration: dict) -> Optional[tuple[list[str], int, list[Optional[float]]]]:
217
- """If narration uses the structured {intro, items, outro} form, return
218
- (lines, intro_line_count, at_sec_list). `at_sec_list` is parallel to
219
- `lines` and contains the per-line `atSec` hint (video-timeline offset
220
- in seconds) when the DSL author specified one, or None otherwise.
221
- Returns None if the narration only uses the flat `text` form.
222
- """
217
+
218
+
219
+
220
+
221
+
222
+
223
223
  if not narration:
224
224
  return None
225
225
  items = narration.get("items")
@@ -263,16 +263,16 @@ _SKILLS_BASE_DIR = os.environ.get(
263
263
 
264
264
 
265
265
  def _resolve_ab_skill_cli_path() -> str | None:
266
- """Locate the compiled ab-skill CLI (dist/cli.js).
267
266
 
268
- Search order:
269
- 1. AB_SKILL_CLI_PATH env (ab-agent always sets this)
270
- 2. <_SKILLS_BASE_DIR>/../dist/cli.js (ab-skill source layout)
271
- 3. <_SKILLS_BASE_DIR>/../skills-cli/cli.js (ab-agent bundled layout)
272
267
 
273
- Returns None when nothing is found. The caller's error message then
274
- says exactly what's missing.
275
- """
268
+
269
+
270
+
271
+
272
+
273
+
274
+
275
+
276
276
  explicit = os.environ.get("AB_SKILL_CLI_PATH", "").strip()
277
277
  if explicit and os.path.exists(explicit):
278
278
  return explicit
@@ -287,21 +287,21 @@ def _resolve_ab_skill_cli_path() -> str | None:
287
287
 
288
288
 
289
289
  def build_skill_command(skill_name: str) -> list[str]:
290
- """Return the argv prefix to invoke sibling skill `skill_name`.
291
290
 
292
- Reads <_SKILLS_BASE_DIR>/<skill_name>/skill.json once and dispatches by
293
- entry.type:
294
- - python → [sys.executable, "<skillDir>/<scriptPath>"]
295
- - http / builtin → [node, "<ab-skill-cli>", skill_name]
296
291
 
297
- Centralizing the dispatch here means the 4 resolve_asset_* functions
298
- only need to append their per-call --flag value pairs to the prefix —
299
- they don't have to know whether the sibling is Python or TS.
300
292
 
301
- Raises RuntimeError with a precise message when skill.json / script
302
- file / CLI binary is missing, so subprocess.run gets a useful failure
303
- rather than a generic "file not found".
304
- """
293
+
294
+
295
+
296
+
297
+
298
+
299
+
300
+
301
+
302
+
303
+
304
+
305
305
  skill_dir = os.path.join(_SKILLS_BASE_DIR, skill_name)
306
306
  skill_json_path = os.path.join(skill_dir, "skill.json")
307
307
  if not os.path.exists(skill_json_path):
@@ -338,31 +338,31 @@ def build_skill_command(skill_name: str) -> list[str]:
338
338
 
339
339
 
340
340
  def resolve_remotion_entry(template_id: str, aspect_ratio: str) -> str:
341
- """Look up the top-level Remotion composition for (templateId, aspectRatio).
342
-
343
- Reads ``remotionEntry[aspect_ratio]`` from the template's registry entry.
344
- The registry is the canonical source (matches what ab-render's manifest
345
- consumes); the previous implementation read a local ``template.json`` via
346
- a ``TEMPLATES_DIR`` env override that pointed at a directory which no
347
- longer exists in the repo — so the function always fell back to the
348
- hard-coded defaults regardless of what the template declared. Switching
349
- to the registry preserves the fallback for templates that don't declare
350
- ``remotionEntry`` and lets ones that DO declare it take effect (e.g.
351
- screen-walkthrough's 9:16 entry).
352
-
353
- Falls back to ``MainVideo16x9`` / ``MainVideo`` when the template isn't
354
- found, the registry can't be loaded, or the aspect ratio isn't keyed.
355
-
356
- ``include_all_statuses=True`` —— 状态门控是**选型策略**,不是渲染策略。
357
- 它要回答的是"列表里该不该出现这个模板、agent 该不该挑它",一旦模板已经被选定并
358
- 通过校验,渲染就必须读到它真实的定义。把门控带到这里的后果是静默换成另一个
359
- composition / 丢掉封面,成片与模板声明不符,而日志里只字未提。
360
- 「能不能被选中」由 template-registry 的 ``--include-beta`` 与
361
- ``dsl_validator._check_template_status_gate`` 负责把关。
362
- """
341
+
342
+
343
+
344
+
345
+
346
+
347
+
348
+
349
+
350
+
351
+
352
+
353
+
354
+
355
+
356
+
357
+
358
+
359
+
360
+
361
+
362
+
363
363
  if template_id:
364
364
  try:
365
- from registry_loader import get_template # type: ignore
365
+ from registry_loader import get_template
366
366
  tpl = get_template(template_id, include_all_statuses=True)
367
367
  if tpl:
368
368
  entry_map = tpl.get("remotionEntry") or {}
@@ -375,24 +375,24 @@ def resolve_remotion_entry(template_id: str, aspect_ratio: str) -> str:
375
375
 
376
376
 
377
377
  def resolve_cover_composition_id(template_id: str) -> Optional[str]:
378
- """Look up the cover compositionId for a template via the registry.
379
378
 
380
- Each template's `compositions[]` array may contain at most one entry with
381
- `slot == "cover"`. We return its compositionId, or None if the template
382
- doesn't ship a cover.
383
379
 
384
- Reading from the registry rather than the template.json file directly so
385
- behaviour matches what ab-render's manifest exposes (the registry is the
386
- aggregated truth used by both ab-render and ab-skill).
387
380
 
388
- ``include_all_statuses=True`` 的理由同 ``resolve_remotion_entry``:门控管选型,
389
- 不管渲染。带上门控的话 beta 模板的封面会无声无息地不渲。
390
- """
381
+
382
+
383
+
384
+
385
+
386
+
387
+
388
+
389
+
390
+
391
391
  if not template_id:
392
392
  return None
393
393
  try:
394
- # sys.path setup happens once at module top — see header.
395
- from registry_loader import get_template # type: ignore
394
+
395
+ from registry_loader import get_template
396
396
 
397
397
  tpl = get_template(template_id, include_all_statuses=True)
398
398
  if not tpl:
@@ -412,7 +412,7 @@ def now_iso():
412
412
 
413
413
 
414
414
  def LogPrint(*args, sep=" ", end="\n", file=None, flush=False):
415
- """带本地时间戳的 stderr/stdout 日志,格式 yyyyMMdd HHmmss:SSS(毫秒)。"""
415
+
416
416
  now = datetime.now()
417
417
  stamp = now.strftime("%Y%m%d %H%M%S") + f":{now.microsecond // 1000:03d}"
418
418
  if file is None:
@@ -421,17 +421,17 @@ def LogPrint(*args, sep=" ", end="\n", file=None, flush=False):
421
421
  builtins.print(f"[{stamp}] {message}", end=end, file=file, flush=flush)
422
422
 
423
423
 
424
- # ─── 扣费汇总 ────────────────────────────────────────────────────────────────
425
- # 一条视频管线的花费分散在多处:每个 gen-image / gen-voice / gen-video /
426
- # gen-digital-human 子进程各扣一次,渲染再通过 saveManifest 扣一次。子进程是
427
- # capture_output 起的,它们自己打的 💳 页脚会被吞掉,所以这里把子进程 stdout 里的
428
- # billing 事件抓出来累加,最后统一报一次总账——否则用户跑完一整条管线,只知道视频
429
- # 好了,不知道花了多少积分。
424
+
425
+
426
+
427
+
428
+
429
+
430
430
  _BILLING: dict = {"credits": 0, "balance": None}
431
431
 
432
432
 
433
433
  def absorb_child_billing(stdout: str) -> None:
434
- """从子 skill 的 stdout 里收集 `__progress__` billing 事件(见 CLI src/billing.ts)。"""
434
+
435
435
  for line in (stdout or "").splitlines():
436
436
  line = line.strip()
437
437
  if not line.startswith("{") or "__progress__" not in line:
@@ -452,24 +452,24 @@ def absorb_child_billing(stdout: str) -> None:
452
452
 
453
453
 
454
454
  def print_billing_footer() -> None:
455
- """收尾时报一次总账。通过 atexit 注册,所以 sys.exit / 中途失败也会打印
456
- ——已经发生的扣费不该因为后面某一步失败就不告知用户。
457
455
 
458
- 走 stdout 而不是 stderr:ab-agent 交给 LLM 的是 `result.stdout || result.stderr`
459
- (mcp-tools.ts),只写 stderr 在托管 agent 里等于不可见。
460
- """
456
+
457
+
458
+
459
+
460
+
461
461
  credits = _BILLING["credits"]
462
462
  balance = _BILLING["balance"]
463
463
  try:
464
- import render_job_client # type: ignore
464
+ import render_job_client
465
465
 
466
466
  job_billing = render_job_client.billing_summary()
467
467
  if job_billing.get("credits"):
468
468
  credits += int(job_billing["credits"])
469
- # saveManifest(渲染扣费)在管线里排最后,它带回的余额最新。
469
+
470
470
  if job_billing.get("balance") is not None:
471
471
  balance = int(job_billing["balance"])
472
- except Exception: # noqa: BLE001 — 报账失败绝不能影响已完成的渲染
472
+ except Exception:
473
473
  pass
474
474
  if credits <= 0:
475
475
  return
@@ -478,12 +478,12 @@ def print_billing_footer() -> None:
478
478
 
479
479
 
480
480
  def sync_chrome_headless_vendor(renderer_dir: str, render_plan: dict) -> None:
481
- """Chrome Headless vendor 同步——实现已抽到独立模块 ``_chrome_vendor``。
482
481
 
483
- 保留这个 thin wrapper 是为了:
484
- 1. ``render_with_local_cli`` 现有调用点无需改名;
485
- 2. 让 render_video.py 自身只关心"渲染调度";vendor 部署细节留给独立模块。
486
- """
482
+
483
+
484
+
485
+
486
+
487
487
  from _chrome_vendor import sync_chrome_headless_vendor as _sync
488
488
  _sync(renderer_dir, render_plan)
489
489
 
@@ -500,22 +500,22 @@ def save_json(data: dict, path: str):
500
500
 
501
501
 
502
502
  def validate_dsl(dsl: dict) -> list:
503
- """Integrity DSL validation — delegates to the unified validator.
504
-
505
- Historical rule set (preserved verbatim by ``validate_integrity``):
506
- version + scene count + assetId duplicates + ``scene.audio.narration
507
- .assetRef`` / ``scene.visuals.background.assetRef`` reference integrity
508
- + structured narration items count vs templateData. ``meta.title`` and
509
- ``global`` presence are intentionally NOT enforced here — by the time
510
- a DSL reaches render_video those have already been gated upstream, and
511
- enforcing them again would change historical behavior.
512
-
513
- ``severity="warning"`` 的条目**不阻断**:它们描述的是"能渲染出来但有一处会静默
514
- 退化"(如黑板版 highlight 不是 title 的子串 → 标题退回单色)。把它们和硬错误混在
515
- 同一个 fatal 列表里,作者就只能二选一:要么被无关的软问题挡住,要么把整条校验关掉。
516
- 警告改为打印到 stderr,退出码不受影响。
517
- """
518
- from video_dsl.runtime.dsl_validator import ( # noqa: E402
503
+
504
+
505
+
506
+
507
+
508
+
509
+
510
+
511
+
512
+
513
+
514
+
515
+
516
+
517
+
518
+ from video_dsl.runtime.dsl_validator import (
519
519
  validate_integrity,
520
520
  errors_as_strings,
521
521
  )
@@ -531,26 +531,26 @@ def validate_dsl(dsl: dict) -> list:
531
531
  def _hydrate_dsl_payload(
532
532
  dsl: dict, template_id: str | None, source_document_url: str | None
533
533
  ) -> None:
534
- """按模板声明把源文件的每一页填进 ``customPayload``(就地改 dsl)。
535
534
 
536
- 只对声明了 ``capabilities.payloadHydration`` 的模板生效;
537
- 其余模板这里是零成本 no-op —— 声明驱动,不写 ``if template_id == X``(AGENTS.md)。
538
535
 
539
- 失败一律 exit 2 并把「下一步做什么」打全:这条路上所有失败形态的共同点是
540
- *数据源没通*,而历史报错说的是"字段没填",两者要采取的行动完全不同。
541
536
 
542
- 共享模块导入失败(裁剪安装 / 单测环境)时静默跳过:那时 payloadRequirements
543
- 的校验会照常报必填缺失,行为退回改动前,不会更糟。
544
- """
537
+
538
+
539
+
540
+
541
+
542
+
543
+
544
+
545
545
  try:
546
- from payload_hydrator import HydrationError, hydrate_dsl # noqa: E402
546
+ from payload_hydrator import HydrationError, hydrate_dsl
547
547
  except ImportError:
548
548
  return
549
549
 
550
550
  try:
551
- from registry_loader import get_template # noqa: E402
551
+ from registry_loader import get_template
552
552
  template = get_template(template_id) if template_id else None
553
- except Exception: # noqa: BLE001 - registry 不可达时退回"不注入",由校验兜底报错
553
+ except Exception:
554
554
  template = None
555
555
  if not template:
556
556
  return
@@ -571,18 +571,18 @@ def _hydrate_dsl_payload(
571
571
 
572
572
 
573
573
  def _compose_image_prompts(dsl: dict, template_id: str | None) -> None:
574
- """按模板 imageStyleGuide 把画风套到待生成图片的 prompt 上(就地改 dsl)。
575
574
 
576
- 只对 ``imageStyleGuide.mode == "compose"`` 的模板生效:agent 在 payload.prompt 里
577
- 只写画面主体,模板的画风 / 配色 / 负向词 / 比例在这里确定地、只一次地拼上去。
578
- 放在这里(而不是 gen_script)是因为此刻旁白已是定稿,不是骨架占位。
579
- 其余模板是零成本 no-op;registry 不可达时同样跳过,与 _hydrate_dsl_payload 一致。
580
- """
575
+
576
+
577
+
578
+
579
+
580
+
581
581
  try:
582
- from registry_loader import get_template # noqa: E402
583
- from video_dsl.runtime.prompt_enhancer import compose_dsl_image_prompts # noqa: E402
582
+ from registry_loader import get_template
583
+ from video_dsl.runtime.prompt_enhancer import compose_dsl_image_prompts
584
584
  template = get_template(template_id) if template_id else None
585
- except Exception: # noqa: BLE001 - 取不到模板就不 compose,prompt 按 agent 写的原样用
585
+ except Exception:
586
586
  return
587
587
  if not template:
588
588
  return
@@ -591,20 +591,20 @@ def _compose_image_prompts(dsl: dict, template_id: str | None) -> None:
591
591
 
592
592
 
593
593
  def _warn_step_sentence_mismatch(entry: dict, sentence_indices: list[int], step_field: Optional[str]) -> None:
594
- """动作条数与旁白句数不等时出声。
595
594
 
596
- 动作数组放在哪个字段由模板的 ``capabilities.stepAlignment.payloadField`` 声明
597
- (``step_field``);没声明的模板不检查。
598
595
 
599
- 为什么值得单独打一条:这一类缺陷**渲得出来**。它不报错、不退非零、renderJob
600
- completed、漏斗记 rendered —— 2026-09-23 线上那条鸡兔同笼成片就是这样交付的,
601
- 10 步板面配 10 句旁白,听起来该一一对应,实际板面在音频中点就走完了。
602
- 既然成片本身不会告状,那就必须有人在这里说一句。
603
596
 
604
- 只警告不阻断:步数少于句数有时是作者的本意(末尾一句总结不需要新动作)。
605
- 真正需要拦的是"作者以为一一对应、实际不是",而那个判断只有作者做得了 ——
606
- 所以这里给的是两个数字,不是一个结论。
607
- """
597
+
598
+
599
+
600
+
601
+
602
+
603
+
604
+
605
+
606
+
607
+
608
608
  if not step_field:
609
609
  return
610
610
  steps = (entry.get("props") or {}).get(step_field)
@@ -636,21 +636,21 @@ def resolve_dimensions(dsl: dict) -> tuple:
636
636
 
637
637
 
638
638
  def validate_and_fix_render_plan(render_plan: dict) -> list:
639
- """Validate RenderPlan integrity and auto-fix recoverable issues.
640
639
 
641
- Checks performed:
642
- 1. subtitleSegments frame numbers within scene [startFrame, endFrame]
643
- 2. Video assets have non-null duration (required for screen-walkthrough)
644
- 3. Timeline frame continuity (prev.endFrame == next.startFrame)
645
- 4. All generated assets have a URL
646
640
 
647
- Returns list of warning messages (empty = all good).
648
- Mutates render_plan in-place to fix issues.
649
- """
641
+
642
+
643
+
644
+
645
+
646
+
647
+
648
+
649
+
650
650
  warnings: list[str] = []
651
651
  fps = render_plan.get("renderConfig", {}).get("fps", 30)
652
652
 
653
- # ── Check 1: subtitle frame bounds ────────────────────────────────────
653
+
654
654
  for entry in render_plan.get("timeline", []):
655
655
  scene_id = entry.get("sceneId", "?")
656
656
  scene_start = entry.get("startFrame", 0)
@@ -671,21 +671,21 @@ def validate_and_fix_render_plan(render_plan: dict) -> list:
671
671
  seg["endFrame"] = scene_end
672
672
  fixed = True
673
673
  if seg["startFrame"] >= seg["endFrame"]:
674
- # Degenerate segment after clamping — give it at least 1 frame
674
+
675
675
  seg["endFrame"] = min(seg["startFrame"] + max(1, int(fps * 0.5)), scene_end)
676
676
 
677
- # ── Check 2: video asset duration ─────────────────────────────────────
678
- # Whether the template needs every video asset to declare a non-null
679
- # duration is a TEMPLATE CAPABILITY, not a property of ab-skill. The
680
- # capability lives in template.json under ``capabilities.needsVideoDuration``;
681
- # ab-skill simply reads + applies it. New templates that need this guarantee
682
- # only have to declare the field — they don't have to touch this code.
677
+
678
+
679
+
680
+
681
+
682
+
683
683
  template_id = render_plan.get("templateId", "")
684
684
  needs_video_duration = False
685
685
  if template_id:
686
686
  try:
687
- from registry_loader import get_template # type: ignore
688
- # include_all_statuses:门控管选型不管渲染(理由见 resolve_remotion_entry)。
687
+ from registry_loader import get_template
688
+
689
689
  _tpl = get_template(template_id, include_all_statuses=True)
690
690
  needs_video_duration = bool(
691
691
  ((_tpl or {}).get("capabilities") or {}).get("needsVideoDuration")
@@ -697,8 +697,8 @@ def validate_and_fix_render_plan(render_plan: dict) -> list:
697
697
  if asset.get("type") != "video":
698
698
  continue
699
699
  if asset.get("duration") is None and needs_video_duration:
700
- # Fallback: use the scene duration of whichever scene references this asset
701
- # via props.videoAssetId / backgroundAssetId / imageAssetId.
700
+
701
+
702
702
  fallback_dur = None
703
703
  for entry in render_plan.get("timeline", []):
704
704
  props = entry.get("props") or {}
@@ -716,7 +716,7 @@ def validate_and_fix_render_plan(render_plan: dict) -> list:
716
716
  f"[fix] asset {asset.get('assetId')}: duration was null, set to scene duration {fallback_dur:.1f}s"
717
717
  )
718
718
 
719
- # ── Check 3: timeline frame continuity ────────────────────────────────
719
+
720
720
  timeline = render_plan.get("timeline", [])
721
721
  for i in range(1, len(timeline)):
722
722
  prev_end = timeline[i - 1].get("endFrame", 0)
@@ -726,7 +726,7 @@ def validate_and_fix_render_plan(render_plan: dict) -> list:
726
726
  f"[fix] timeline gap: {timeline[i-1].get('sceneId')}.endFrame={prev_end} != "
727
727
  f"{timeline[i].get('sceneId')}.startFrame={curr_start}, correcting"
728
728
  )
729
- # Shift current and all subsequent scenes
729
+
730
730
  offset = prev_end - curr_start
731
731
  for j in range(i, len(timeline)):
732
732
  timeline[j]["startFrame"] += offset
@@ -736,20 +736,20 @@ def validate_and_fix_render_plan(render_plan: dict) -> list:
736
736
  for seg in timeline[j].get("subtitleSegments", []):
737
737
  seg["startFrame"] += offset
738
738
  seg["endFrame"] += offset
739
- # Update total
739
+
740
740
  last = timeline[-1]
741
741
  render_plan["renderConfig"]["totalFrames"] = last["endFrame"]
742
742
  render_plan["renderConfig"]["totalDuration"] = round(last["endFrame"] / fps, 2)
743
- break # re-check from start would be needed for multiple gaps, but rare
743
+ break
744
+
744
745
 
745
- # ── Check 4: generated assets have URL ────────────────────────────────
746
746
  for asset in render_plan.get("resolvedAssets", []):
747
747
  if asset.get("status") == "generated" and not asset.get("url"):
748
748
  warnings.append(
749
749
  f"[warn] asset {asset.get('assetId')}: status=generated but url is empty"
750
750
  )
751
751
 
752
- # Log warnings into render_plan
752
+
753
753
  if warnings:
754
754
  for w in warnings:
755
755
  render_plan.setdefault("logs", []).append({
@@ -765,7 +765,7 @@ def validate_and_fix_render_plan(render_plan: dict) -> list:
765
765
 
766
766
 
767
767
  def _build_bgm_props(dsl: dict) -> dict:
768
- """Extract BGM config from DSL global settings and return as Remotion props."""
768
+
769
769
  bgm = dsl.get("global", {}).get("bgm", {})
770
770
  if bgm.get("enabled") and bgm.get("url"):
771
771
  return {"bgm": {"url": bgm["url"], "volume": bgm.get("volume", 0.15)}}
@@ -773,13 +773,13 @@ def _build_bgm_props(dsl: dict) -> dict:
773
773
 
774
774
 
775
775
  def _probe_video_duration(url: str):
776
- """Backwards-compat wrapper — implementation lives in ``_video_probe`` module."""
776
+
777
777
  from _video_probe import probe_video_duration
778
778
  return probe_video_duration(url)
779
779
 
780
780
 
781
781
  def build_render_plan(dsl: dict, binding: dict) -> dict:
782
- """Build a RenderPlan from DSL and TemplateBinding."""
782
+
783
783
  fps = dsl.get("global", {}).get("fps", 30)
784
784
  width, height = resolve_dimensions(dsl)
785
785
 
@@ -787,15 +787,15 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
787
787
  for b in binding.get("bindings", []):
788
788
  binding_map[b["sceneId"]] = b
789
789
 
790
- # Pre-step: copy narration text from scenes[].audio.narration.text down
791
- # into the matching audio asset's payload, so resolve_asset_audio (which
792
- # only sees the asset, not the scene) can call gen-voice with the right
793
- # text. narration.text is the single source of truth — DSL authors do
794
- # NOT need to populate audio asset payload.text themselves.
795
- #
796
- # Structured narration form ({intro, items, outro}) is also expanded here
797
- # so adjust_timeline_to_audio can later auto-derive highlightMap from
798
- # per-line TTS timestamps.
790
+
791
+
792
+
793
+
794
+
795
+
796
+
797
+
798
+
799
799
  dsl_assets_by_id = {a["assetId"]: a for a in dsl.get("assets", [])}
800
800
  global_narration = (dsl.get("global") or {}).get("narration") or {}
801
801
  global_speed = _narration_speed(global_narration)
@@ -808,25 +808,25 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
808
808
  if not asset:
809
809
  continue
810
810
  payload = asset.setdefault("payload", {})
811
- # Voice follows the same precedence as speed: scene narration.voiceId >
812
- # global.narration.voiceId. The asset's own payload.voiceId (written once
813
- # by gen_script) is only the last resort for DSLs that declare neither —
814
- # letting it win meant a voice change made after the script was drafted
815
- # never reached TTS.
811
+
812
+
813
+
814
+
815
+
816
816
  voice = _narration_voice(narration) or global_voice
817
817
  if voice:
818
818
  payload["voiceId"] = voice
819
- # Speech rate follows the same route as the narration text: single source
820
- # of truth on the DSL, copied down here because resolve_asset_audio only
821
- # ever sees the asset. Scene-level overrides global (mirrors the narration
822
- # editor's "行级覆盖 > 全局"); neither set = gen-voice's own 1.0 default.
819
+
820
+
821
+
822
+
823
823
  speed = _narration_speed(narration)
824
824
  if speed is None:
825
825
  speed = global_speed
826
826
  if speed is not None:
827
827
  payload["speed"] = speed
828
- # 语种提示走与语速完全相同的路线(场景覆盖全局,都不写就交给服务端默认):
829
- # resolve_asset_audio 只看得见 asset,看不见 scene。
828
+
829
+
830
830
  language_boost = _narration_language_boost(narration)
831
831
  if language_boost is None:
832
832
  language_boost = global_language_boost
@@ -845,7 +845,7 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
845
845
 
846
846
  assets_by_id = {}
847
847
  for asset in dsl.get("assets", []):
848
- # url 优先读顶层字段,其次 fallback 到 payload.url(兼容 source=url 写法)
848
+
849
849
  asset_url = asset.get("url", "") or (asset.get("payload") or {}).get("url", "")
850
850
  assets_by_id[asset["assetId"]] = {
851
851
  "assetId": asset["assetId"],
@@ -863,10 +863,10 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
863
863
  "maxRetries": 3,
864
864
  }
865
865
 
866
- # ── Probe duration for existing video assets without duration ──────
867
- # screen-walkthrough's adaptStrategy needs videoDurationSec to avoid
868
- # falling back to static-fallback. Probe via partial download + ffprobe
869
- # or fall back to a heuristic based on Content-Length.
866
+
867
+
868
+
869
+
870
870
  for aid, asset in assets_by_id.items():
871
871
  if asset["type"] == "video" and asset["source"] == "existing" and not asset.get("duration") and asset.get("url"):
872
872
  probed = _probe_video_duration(asset["url"])
@@ -883,9 +883,9 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
883
883
 
884
884
  scene_binding = binding_map.get(scene_id, {})
885
885
 
886
- # P2.3: 不再产出 entry.layers。背景视觉资产、旁白音频、文本图层这些
887
- # 信息全都通过 propExtractors → binding.props 显式传给模板(例如
888
- # backgroundAssetId / narrationAssetId / titleText),模板用 props 即可。
886
+
887
+
888
+
889
889
 
890
890
  narration = (scene.get("audio") or {}).get("narration") or {}
891
891
 
@@ -893,18 +893,18 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
893
893
  extracted = extract_narration_lines(narration)
894
894
  subtitle_at_sec: list[Optional[float]] = []
895
895
  if extracted:
896
- # Honour the authored line boundaries exactly (no secondary
897
- # comma-split, no short-fragment merge) so the fallback
898
- # subtitle count equals len(lines) — which is what
899
- # _auto_highlight_map expects when TTS timestamps are absent.
896
+
897
+
898
+
899
+
900
900
  lines, _intro_lines, subtitle_at_sec = extracted
901
901
  local_subs = split_subtitle_from_lines(lines, duration_frames, fps)
902
902
  else:
903
903
  local_subs = split_subtitle(narration_text, duration_frames, fps)
904
- # ⚠️ 平移帧号时**必须保留原有字段**(`**s`),不要只挑三个键重建。
905
- # 这里曾经只搬 text/startFrame/endFrame,于是 timeline_compiler 给每段打上的
906
- # `sentenceIndex`(模板用它把动作对齐到**句**而不是段)在这一步被静默丢掉 ——
907
- # 下游模板拿不到句边界,只能退回按段对齐,也就是那个错位 bug 本身。
904
+
905
+
906
+
907
+
908
908
  subtitle_segments = [
909
909
  {
910
910
  **s,
@@ -918,13 +918,13 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
918
918
  trans_type = transition_config.get("default", "fade")
919
919
  trans_dur = int(transition_config.get("duration", 0.5) * fps)
920
920
 
921
- # 首场景不做 fade-in 转场,避免开头黑屏
921
+
922
922
  scene_trans_type = "cut" if idx == 0 else trans_type
923
923
  scene_trans_dur = 0 if idx == 0 else trans_dur
924
924
 
925
- # 透传 scene.customPayload.minDurationSec 到 entry.minDurationFrames,
926
- # 供 adjust_timeline_to_audio 在 audio-driven 计算时取下限(不让 scene
927
- # 被旁白时长拍短)。
925
+
926
+
927
+
928
928
  custom_payload = scene.get("customPayload") or {}
929
929
  min_dur_sec = custom_payload.get("minDurationSec")
930
930
  min_dur_frames = (
@@ -932,9 +932,9 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
932
932
  if isinstance(min_dur_sec, (int, float)) and min_dur_sec > 0
933
933
  else 0
934
934
  )
935
- # 透传 customPayload.tailPadSec 到 entry.tailPadFrames,覆盖
936
- # adjust_timeline_to_audio 默认的 1.5s 旁白尾巴。设 0 = 没有尾巴,
937
- # scene 贴音频结束立刻切走。未指定时(None)走全局默认 1.5s。
935
+
936
+
937
+
938
938
  tail_pad_sec = custom_payload.get("tailPadSec")
939
939
  tail_pad_frames = (
940
940
  int(round(float(tail_pad_sec) * fps))
@@ -967,8 +967,8 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
967
967
  "createdAt": now_iso(),
968
968
  "status": "planning",
969
969
  "templateId": binding.get("templateId", ""),
970
- # 渲染时常用的 DSL 摘要字段,避免下游再去 dsl 全文里捞。
971
- # 不复用 DSL 的 meta(dsl 是单一事实来源),只挑必要的几个供 UI/上传使用。
970
+
971
+
972
972
  "title": dsl.get("meta", {}).get("title", ""),
973
973
  "targetDuration": dsl.get("meta", {}).get("targetDuration"),
974
974
  "resolvedAssets": list(assets_by_id.values()),
@@ -1005,7 +1005,7 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
1005
1005
 
1006
1006
 
1007
1007
  def resolve_asset_image(asset: dict, private_token: str, timeout: int) -> dict:
1008
- """Resolve a single image asset by calling gen-image skill."""
1008
+
1009
1009
  payload = asset.get("payload", {}) if "payload" not in asset else asset["payload"]
1010
1010
 
1011
1011
  try:
@@ -1018,7 +1018,7 @@ def resolve_asset_image(asset: dict, private_token: str, timeout: int) -> dict:
1018
1018
  cmd.extend(["--size", payload["ratio"]])
1019
1019
  if payload.get("model"):
1020
1020
  cmd.extend(["--model", payload["model"]])
1021
- # 模板声明的负向词 / 引导强度 / 种子以前在这里被丢掉,gen-image 从没收到过。
1021
+
1022
1022
  if payload.get("negativePrompt"):
1023
1023
  cmd.extend(["--negative-prompt", str(payload["negativePrompt"])])
1024
1024
  if payload.get("guidanceScale") is not None:
@@ -1044,16 +1044,16 @@ def resolve_asset_image(asset: dict, private_token: str, timeout: int) -> dict:
1044
1044
 
1045
1045
 
1046
1046
  def resolve_asset_audio(asset: dict, private_token: str, timeout: int) -> dict:
1047
- """Resolve a single audio asset by calling gen-voice skill.
1048
1047
 
1049
- Pre-processes text with segment_narration() and newlines so Minimax
1050
- returns per-segment timestamps. Uses --json-output to capture metadata.
1051
1048
 
1052
- If the payload carries `narrationItems` (structured {intro,items,outro}
1053
- form), those lines are fed verbatim to Minimax so the returned subtitle
1054
- count matches the authored line count — enabling an automatic 1:1
1055
- subtitle → card highlightMap in adjust_timeline_to_audio().
1056
- """
1049
+
1050
+
1051
+
1052
+
1053
+
1054
+
1055
+
1056
+
1057
1057
  payload = asset.get("payload", {})
1058
1058
 
1059
1059
  try:
@@ -1077,12 +1077,12 @@ def resolve_asset_audio(asset: dict, private_token: str, timeout: int) -> dict:
1077
1077
  cmd.extend(["--text", tts_text, "--json-output"])
1078
1078
  if payload.get("voiceId"):
1079
1079
  cmd.extend(["--voice-id", payload["voiceId"]])
1080
- # Injected by build_render_plan from the DSL (scene narration > global).
1081
- # Absent = let gen-voice apply its own default rather than pinning 1.0 here.
1080
+
1081
+
1082
1082
  speed = _narration_speed(payload)
1083
1083
  if speed is not None:
1084
1084
  cmd.extend(["--speed", str(speed)])
1085
- # 同 speed:不带就**不传**这个 flag,默认值归服务端一处持有。
1085
+
1086
1086
  language_boost = _narration_language_boost(payload)
1087
1087
  if language_boost is not None:
1088
1088
  cmd.extend(["--language-boost", language_boost])
@@ -1117,7 +1117,7 @@ def resolve_asset_audio(asset: dict, private_token: str, timeout: int) -> dict:
1117
1117
 
1118
1118
 
1119
1119
  def resolve_asset_video(asset: dict, private_token: str, timeout: int) -> dict:
1120
- """Resolve a single video asset by calling gen-video skill."""
1120
+
1121
1121
  payload = asset.get("payload", {})
1122
1122
 
1123
1123
  try:
@@ -1151,7 +1151,7 @@ def resolve_asset_video(asset: dict, private_token: str, timeout: int) -> dict:
1151
1151
 
1152
1152
 
1153
1153
  def resolve_asset_digital_human(asset: dict, private_token: str, timeout: int) -> dict:
1154
- """Resolve a single digital-human avatar asset by calling gen-digital-human skill."""
1154
+
1155
1155
  payload = asset.get("payload", {})
1156
1156
 
1157
1157
  try:
@@ -1183,8 +1183,8 @@ def resolve_asset_digital_human(asset: dict, private_token: str, timeout: int) -
1183
1183
  line = line.strip()
1184
1184
  if line.startswith("http"):
1185
1185
  out = {"status": "generated", "url": line}
1186
- # 宽高 / 时长写回资产:模板按它定盒子比例(缺省 3:4 会裁人),
1187
- # 按时长决定 onEnd=hide/loop 何时生效。探测失败不影响生成结果。
1186
+
1187
+
1188
1188
  from _video_probe import probe_video_meta
1189
1189
  meta = probe_video_meta(line)
1190
1190
  if meta.get("width") and meta.get("height"):
@@ -1215,11 +1215,11 @@ def _resolve_single_asset(
1215
1215
  timeout: int,
1216
1216
  payload_override: Optional[dict] = None,
1217
1217
  ) -> bool:
1218
- """Resolve one asset. Returns True if generated, False if failed/skipped.
1219
1218
 
1220
- ``payload_override`` 整份替换资产 payload,且**不回写 DSL**:数字人连续段的驱动
1221
- 音频是按本次排轴现拼的,写回 DSL 的话下次改了旁白还会拿旧音频去驱动。
1222
- """
1219
+
1220
+
1221
+
1222
+
1223
1223
  source = asset.get("source", "")
1224
1224
  resolver = ASSET_RESOLVERS.get(source)
1225
1225
  if not resolver:
@@ -1243,12 +1243,12 @@ def _resolve_single_asset(
1243
1243
  elif dsl_asset and "payload" in dsl_asset:
1244
1244
  asset_with_payload["payload"] = dsl_asset["payload"]
1245
1245
 
1246
- # Legacy:没走连续段调度(见 resolve_digital_human_segments)的数字人资产,
1247
- # 沿用「取第一个已生成的旁白」—— 只对全片一个数字人成立。
1246
+
1247
+
1248
1248
  if source == "gen-digital-human" and payload_override is None:
1249
1249
  payload = asset_with_payload.get("payload", {})
1250
1250
  if not payload.get("audioUrl"):
1251
- # Find the first generated TTS audio asset and use its URL
1251
+
1252
1252
  for ra in render_plan["resolvedAssets"]:
1253
1253
  if ra.get("type") == "audio" and ra.get("source") == "gen-voice" and ra.get("status") == "generated" and ra.get("url"):
1254
1254
  payload["audioUrl"] = ra["url"]
@@ -1272,15 +1272,15 @@ def _resolve_single_asset(
1272
1272
  asset["generatedBy"]["completedAt"] = now_iso()
1273
1273
  if result.get("audio_length_ms") is not None:
1274
1274
  asset["duration"] = result["audio_length_ms"]
1275
- # 视频类资产(数字人):ms 时长 + 像素尺寸,模板的 ResolvedAsset 契约同名字段。
1275
+
1276
1276
  if result.get("duration_ms") is not None:
1277
1277
  asset["duration"] = result["duration_ms"]
1278
1278
  if result.get("width") and result.get("height"):
1279
1279
  asset["width"], asset["height"] = result["width"], result["height"]
1280
1280
  if result.get("subtitles"):
1281
1281
  asset["ttsSubtitles"] = result["subtitles"]
1282
- # Propagate structured-narration metadata onto the resolved
1283
- # asset so adjust_timeline_to_audio can auto-derive highlightMap.
1282
+
1283
+
1284
1284
  src_payload = asset_with_payload.get("payload", {}) or {}
1285
1285
  if isinstance(src_payload.get("narrationItems"), list):
1286
1286
  asset["narrationLineCount"] = len(src_payload["narrationItems"])
@@ -1306,12 +1306,12 @@ def _resolve_single_asset(
1306
1306
 
1307
1307
 
1308
1308
  def apply_stub_urls(render_plan: dict, stub_image_url: str = "", stub_video_url: str = "") -> int:
1309
- """Short-circuit pending image/video assets with a stub URL (test mode, no API cost).
1310
1309
 
1311
- For every pending asset whose source is gen-image / gen-video and whose type is image
1312
- / video, replace with the stub URL in-place (status → generated). Returns the number
1313
- of assets that were stubbed.
1314
- """
1310
+
1311
+
1312
+
1313
+
1314
+
1315
1315
  if not stub_image_url and not stub_video_url:
1316
1316
  return 0
1317
1317
 
@@ -1354,15 +1354,15 @@ def resolve_assets(
1354
1354
  timeout: int,
1355
1355
  defer_digital_human: bool = False,
1356
1356
  ) -> dict:
1357
- """Resolve all pending assets in the render plan.
1358
1357
 
1359
- Two-phase resolution:
1360
- Phase 1: resolve non-avatar assets (TTS, images, video) — **in parallel**
1361
- Phase 2: resolve avatar/digital-human assets which may depend on generated audio URLs — serial
1362
1358
 
1363
- ``defer_digital_human=True`` 时跳过 Phase 2:场景引用的数字人要等排轴之后按连续段
1364
- 生成(驱动音频要按场景最终时长补静音),由 resolve_digital_human_segments 接手。
1365
- """
1359
+
1360
+
1361
+
1362
+
1363
+
1364
+
1365
+
1366
1366
  from concurrent.futures import ThreadPoolExecutor, as_completed
1367
1367
 
1368
1368
  render_plan["status"] = "resolving-assets"
@@ -1373,9 +1373,9 @@ def resolve_assets(
1373
1373
  })
1374
1374
 
1375
1375
  pending = [a for a in render_plan["resolvedAssets"] if a["status"] == "pending"]
1376
- # Phase 1: resolve non-digital-human assets first (TTS audio needed by avatar)
1376
+
1377
1377
  phase1 = [a for a in pending if a.get("source") != "gen-digital-human"]
1378
- # Phase 2: resolve digital-human assets (can now use generated audio URLs)
1378
+
1379
1379
  phase2 = [a for a in pending if a.get("source") == "gen-digital-human"]
1380
1380
  if defer_digital_human:
1381
1381
  from _digital_human_segments import collect_segments
@@ -1385,7 +1385,7 @@ def resolve_assets(
1385
1385
  generated = 0
1386
1386
  failed = 0
1387
1387
 
1388
- # Phase 1: parallel resolution (concurrency configurable via env)
1388
+
1389
1389
  if phase1:
1390
1390
  default_parallelism = 8
1391
1391
  max_workers = min(
@@ -1413,7 +1413,7 @@ def resolve_assets(
1413
1413
  failed += 1
1414
1414
  LogPrint(f" ❌ {asset['assetId']} exception: {exc}", file=sys.stderr)
1415
1415
 
1416
- # Phase 2: serial resolution (digital-human depends on TTS audio)
1416
+
1417
1417
  for asset in phase2:
1418
1418
  ok = _resolve_single_asset(asset, render_plan, private_token, max_retries, timeout)
1419
1419
  if ok:
@@ -1438,17 +1438,17 @@ def resolve_digital_human_segments(
1438
1438
  timeout: int,
1439
1439
  work_dir: Optional[str] = None,
1440
1440
  ) -> dict:
1441
- """排轴之后按连续段生成数字人,并给段内场景注入起播帧 / 段角色。
1442
1441
 
1443
- 必须在 adjust_timeline_to_audio 之后调:驱动音频 = 段内各场景旁白、每段补静音
1444
- 到该场景**最终**时长,视频第 Σ前序场景帧 帧才正好是本场景第 0 帧。
1445
1442
 
1446
- - 单场景段且旁白 ≥ 5 秒:直接用旁白驱动,不拼接。
1447
- - 其余情况用 ffmpeg 拼一条(ab-agent 镜像自带 ffmpeg),上传后驱动。
1448
- - 没有 ffmpeg:多场景段拆成每场景一段(人物在场景边界重新起播,但口型不错位),
1449
- 并在 logs 里说明。
1450
- - 已生成的资产(续跑 / 复用)不重做,只重新注入偏移——偏移只取决于排轴。
1451
- """
1443
+
1444
+
1445
+
1446
+
1447
+
1448
+
1449
+
1450
+
1451
+
1452
1452
  import _digital_human_segments as dhs
1453
1453
 
1454
1454
  segments = dhs.collect_segments(render_plan)
@@ -1474,15 +1474,15 @@ def resolve_digital_human_segments(
1474
1474
  })
1475
1475
  LogPrint(f" 🧑‍💼 {message}", file=sys.stderr)
1476
1476
 
1477
- # 先决定每段怎么驱动;没有 ffmpeg 时把多场景段拆开,拆完再统一生成。
1478
- jobs: list[tuple[str, dict]] = [] # (assetId, payload)
1477
+
1478
+ jobs: list[tuple[str, dict]] = []
1479
1479
  for asset_id, indices in list(segments.items()):
1480
1480
  asset = assets.get(asset_id)
1481
1481
  if not asset or asset.get("status") != "pending":
1482
1482
  continue
1483
1483
  base_payload = dsl_payloads.get(asset_id, {})
1484
1484
  if base_payload.get("audioUrl"):
1485
- # 作者显式给了驱动音频:尊重它,不再拼接。
1485
+
1486
1486
  jobs.append((asset_id, base_payload))
1487
1487
  continue
1488
1488
  if len(indices) > 1 and not ffmpeg:
@@ -1495,7 +1495,7 @@ def resolve_digital_human_segments(
1495
1495
  timeline = render_plan.get("timeline", [])
1496
1496
  for idx, new_id in zip(indices, new_ids):
1497
1497
  if not dhs.plan_drive_pieces(render_plan, [idx])[0].get("url"):
1498
- # 没有旁白的场景单独成段就没有东西可驱动:这一屏不出人,而不是整段失败。
1498
+
1499
1499
  timeline[idx].get("props", {}).pop("avatarAssetId", None)
1500
1500
  render_plan["resolvedAssets"] = [
1501
1501
  a for a in render_plan["resolvedAssets"] if a.get("assetId") != new_id
@@ -1524,7 +1524,7 @@ def resolve_digital_human_segments(
1524
1524
  if not any(p.get("url") for p in pieces):
1525
1525
  raise RuntimeError("no generated narration audio in this segment to drive the presenter")
1526
1526
  if not ffmpeg:
1527
- # 单场景但旁白不足 5 秒,又没法补静音:照样提交,由服务端判定。
1527
+
1528
1528
  url = next(p["url"] for p in pieces if p.get("url"))
1529
1529
  log(f"{asset_id}: narration shorter than {dhs.MIN_DRIVE_SEC:.0f}s and ffmpeg not found; submitting unpadded", level="warn")
1530
1530
  else:
@@ -1539,7 +1539,7 @@ def resolve_digital_human_segments(
1539
1539
  total = sum(p["durationSec"] for p in pieces)
1540
1540
  log(f"{asset_id}: drive audio for {len(pieces)} scene(s), {total:.1f}s")
1541
1541
  payload["audioUrl"] = url
1542
- except Exception as exc: # noqa: BLE001 — 任一段失败不拖垮其它段
1542
+ except Exception as exc:
1543
1543
  asset["status"] = "failed"
1544
1544
  failed += 1
1545
1545
  render_plan.setdefault("errors", []).append({
@@ -1550,44 +1550,44 @@ def resolve_digital_human_segments(
1550
1550
  })
1551
1551
  log(f"{asset_id}: drive audio failed: {exc}", level="error")
1552
1552
  continue
1553
- # 口型音轨就是驱动音频,文本 / 音色字段留着会让服务端改走 TTS 驱动。
1553
+
1554
1554
  payload.pop("text", None)
1555
1555
  payload.pop("voiceId", None)
1556
1556
  if not _resolve_single_asset(asset, render_plan, private_token, max_retries, timeout, payload_override=payload):
1557
1557
  failed += 1
1558
1558
 
1559
- for warning in dhs.inject_segment_props(render_plan, dhs.collect_segments(render_plan)):
1560
- log(warning, level="warn")
1561
- if failed:
1562
- render_plan["status"] = "failed"
1563
- return render_plan
1559
+ for warning in dhs.inject_segment_props(render_plan, dhs.collect_segments(render_plan)):
1560
+ log(warning, level="warn")
1561
+ if failed:
1562
+ render_plan["status"] = "failed"
1563
+ return render_plan
1564
+
1565
+
1566
+ def _auto_highlight_map(
1567
+ entry: dict,
1568
+ narration_asset: Optional[dict],
1569
+ render_plan: dict,
1570
+ line_sync: dict[str, str],
1571
+ ) -> None:
1572
+
1573
+
1574
+
1575
+
1576
+
1577
+
1578
+
1579
+
1580
+
1581
+
1582
+
1583
+
1584
+
1585
+
1586
+
1587
+
1588
+
1564
1589
 
1565
1590
 
1566
- def _auto_highlight_map(
1567
- entry: dict,
1568
- narration_asset: Optional[dict],
1569
- render_plan: dict,
1570
- line_sync: dict[str, str],
1571
- ) -> None:
1572
- """Auto-fill ``templateData.highlightMap`` for any template that uses
1573
- structured ``{intro, items, outro}`` narration and ships per-line cards.
1574
-
1575
- Originally written for html-slide's multi-item slides — that's still the
1576
- primary consumer — but the trigger is capability-driven, not templateId-
1577
- keyed: any template whose binding pushes a ``narrationLineCount`` onto
1578
- the narration asset and whose subtitle segments line up 1-per-line gets
1579
- this auto-fill. Adding a new template that follows the same pattern needs
1580
- NO change to this function.
1581
-
1582
- ``line_sync`` is the template's ``lineSyncSlides`` declaration (slideId →
1583
- card-array key). It caps the map at the card count so trailing outro lines
1584
- don't point past the last card; slides it doesn't list get no cap.
1585
-
1586
- Skipped silently when:
1587
- - narration asset has no narrationLineCount (structured form wasn't used)
1588
- - author already supplied a highlightMap (explicit wins)
1589
- - subtitleSegments count ≠ narrationLineCount (emits a warn log)
1590
- """
1591
1591
  if not narration_asset:
1592
1592
  return
1593
1593
  line_count = narration_asset.get("narrationLineCount")
@@ -1599,7 +1599,7 @@ def _auto_highlight_map(
1599
1599
  if not isinstance(tdata, dict):
1600
1600
  return
1601
1601
  if tdata.get("highlightMap"):
1602
- return # respect author-provided map
1602
+ return
1603
1603
 
1604
1604
  segs = entry.get("subtitleSegments") or []
1605
1605
  if len(segs) != line_count:
@@ -1616,10 +1616,10 @@ def _auto_highlight_map(
1616
1616
 
1617
1617
  intro_lines = int(narration_asset.get("narrationIntroLines") or 0)
1618
1618
  item_count = line_count - intro_lines
1619
- # outro counts toward line_count too; cap item_count at the authored
1620
- # card-array length so trailing outro lines don't spill into card
1621
- # indices. 数组键读模板的 lineSyncSlides 声明(按 slideId),CLI 不保留键表副本
1622
- # —— 以前这里有一份模板 CARD_ARRAY_KEYS 的手抄,已经漂移过两次。
1619
+
1620
+
1621
+
1622
+
1623
1623
  slide_id = props.get("slideId") or tdata.get("slideId")
1624
1624
  items_key = line_sync.get(slide_id) if isinstance(slide_id, str) else None
1625
1625
  if items_key and not isinstance(tdata.get(items_key), list):
@@ -1643,23 +1643,23 @@ def _auto_highlight_map(
1643
1643
 
1644
1644
 
1645
1645
  def _timing_template(template_id: str) -> dict:
1646
- """排轴阶段要读的模板声明;取不到就返回空 dict,走全局默认,不因为这个挡渲染。"""
1646
+
1647
1647
  if not template_id:
1648
1648
  return {}
1649
1649
  try:
1650
- from registry_loader import get_template # type: ignore
1650
+ from registry_loader import get_template
1651
1651
  return get_template(template_id, include_all_statuses=True) or {}
1652
- except Exception: # noqa: BLE001
1652
+ except Exception:
1653
1653
  return {}
1654
1654
 
1655
1655
 
1656
1656
  def _template_default_tail_pad_frames(tpl: dict, fps: int) -> int | None:
1657
- """模板声明的旁白尾巴默认值(``capabilities.defaultTailPadSec``),换算成帧。
1658
1657
 
1659
- 场景自己的 ``customPayload.tailPadSec`` 仍然优先;这里只替换全局 1.5s 兜底。
1660
- 为什么要模板级:逐场景设置靠模型照着 llmHint 抄,实际经常漏 —— 2026-09-24
1661
- 线上《狼来了》绘本每页结尾 1.9s 空白,86s 里 15s 没有声音。
1662
- """
1658
+
1659
+
1660
+
1661
+
1662
+
1663
1663
  value = (tpl.get("capabilities") or {}).get("defaultTailPadSec")
1664
1664
  if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0:
1665
1665
  return None
@@ -1667,8 +1667,8 @@ def _template_default_tail_pad_frames(tpl: dict, fps: int) -> int | None:
1667
1667
 
1668
1668
 
1669
1669
  def adjust_timeline_to_audio(render_plan: dict) -> dict:
1670
- """Post-process: use real TTS audio durations and timestamps to fix
1671
- scene durations and subtitle segments."""
1670
+
1671
+
1672
1672
  import math
1673
1673
 
1674
1674
  fps = render_plan["renderConfig"]["fps"]
@@ -1684,8 +1684,8 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1684
1684
  default_tail_pad = int(fps * 1.5)
1685
1685
 
1686
1686
  for entry in render_plan["timeline"]:
1687
- # narration assetId 现在直接由 propExtractors 注入到 props.narrationAssetId,
1688
- # 不再依赖 entry.layers[type=audio] 这条间接路径。
1687
+
1688
+
1689
1689
  narration_asset = None
1690
1690
  nar_id = (entry.get("props") or {}).get("narrationAssetId")
1691
1691
  if nar_id:
@@ -1696,7 +1696,7 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1696
1696
  if narration_asset and narration_asset.get("duration"):
1697
1697
  audio_ms = narration_asset["duration"]
1698
1698
  if audio_ms <= 0:
1699
- # Sanity check: TTS returned 0 or negative duration — keep original estimate
1699
+
1700
1700
  render_plan.setdefault("logs", []).append({
1701
1701
  "phase": "audio-adjust",
1702
1702
  "level": "warn",
@@ -1705,18 +1705,18 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1705
1705
  })
1706
1706
  else:
1707
1707
  audio_frames = math.ceil(audio_ms / 1000.0 * fps)
1708
- # DSL 里 scene.customPayload.tailPadSec 可覆盖默认尾巴
1709
- # (比如开场卡片要紧接下一场,设 0 直接贴音频结束);默认值是模板的
1710
- # capabilities.defaultTailPadSec,没声明时 1.5s。
1708
+
1709
+
1710
+
1711
1711
  tail_pad_override = entry.get("tailPadFrames")
1712
1712
  tail_pad = (
1713
1713
  int(tail_pad_override)
1714
1714
  if isinstance(tail_pad_override, int) and tail_pad_override >= 0
1715
1715
  else default_tail_pad
1716
1716
  )
1717
- min_dur = int(fps * 2) # minimum 2 seconds per scene
1718
- # 用户在 DSL 里指定的 scene 下限(minDurationSec → minDurationFrames),
1719
- # 用于"视频比旁白长"的场景:要把视频完整播完,scene 时长不能被旁白拍短。
1717
+ min_dur = int(fps * 2)
1718
+
1719
+
1720
1720
  scene_min_dur = int(entry.get("minDurationFrames") or 0)
1721
1721
  new_dur = max(min_dur, scene_min_dur, audio_frames + tail_pad)
1722
1722
 
@@ -1726,11 +1726,11 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1726
1726
 
1727
1727
  tts_subs = narration_asset.get("ttsSubtitles", [])
1728
1728
  if tts_subs:
1729
- # 句归属必须在 _strip_subtitle_trailing_punct **之前**算:
1730
- # 判据就是句末标点,去掉之后所有段都会被认成同一句。
1731
- # 这一层是「第 i 个动作对齐第 i 句」类模板(math-principles-expl
1732
- # 的 steps、ppt-to-video 的 beats)唯一能拿到句边界的地方 ——
1733
- # TTS 行是按停顿切的,段数远多于句数。
1729
+
1730
+
1731
+
1732
+
1733
+
1734
1734
  sentence_indices = assign_sentence_indices(
1735
1735
  [s.get("text") or "" for s in tts_subs]
1736
1736
  )
@@ -1745,20 +1745,20 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1745
1745
  ]
1746
1746
  _warn_step_sentence_mismatch(entry, sentence_indices, step_field)
1747
1747
  else:
1748
- # No TTS timestamps — re-distribute existing subtitle
1749
- # lines across the REAL audio duration, by character
1750
- # length ratio. Avoid going through
1751
- # split_subtitle_from_lines() because its internal
1752
- # estimated_audio_frames heuristic can clip
1753
- # `usable_frames` below the actual audio length.
1754
- # 字幕跟随真实音频时长分布,撑大 scene(因 minDuration)
1755
- # 时尾巴静音段无字幕。
1748
+
1749
+
1750
+
1751
+
1752
+
1753
+
1754
+
1755
+
1756
1756
  old_subs = entry.get("subtitleSegments", [])
1757
1757
  at_sec_list = entry.get("subtitleAtSec") or []
1758
- # 仅当某条字幕显式给了 atSec(视频时间轴的提示点)
1759
- # 时,把字幕锚定到对应的视频帧;这样字幕节奏跟随
1760
- # 画面而不是旁白长度。最后一条字幕会一直显示到
1761
- # scene 结尾(让"看下效果"等收尾句保持在屏)。
1758
+
1759
+
1760
+
1761
+
1762
1762
  has_at_sec = any(
1763
1763
  isinstance(a, (int, float)) and a is not None
1764
1764
  for a in at_sec_list
@@ -1766,8 +1766,8 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1766
1766
 
1767
1767
  if has_at_sec and old_subs:
1768
1768
  scene_dur = entry["durationFrames"]
1769
- # 算每条字幕的起始帧(相对 scene)。
1770
- # atSec 缺失时以前一条结束位置为准。
1769
+
1770
+
1771
1771
  starts: list[int] = []
1772
1772
  for i, s in enumerate(old_subs):
1773
1773
  at = at_sec_list[i] if i < len(at_sec_list) else None
@@ -1775,7 +1775,7 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1775
1775
  f = max(0, int(round(at * fps)))
1776
1776
  else:
1777
1777
  f = starts[i - 1] + (fps // 2) if i > 0 else 0
1778
- # 单调递增,避免后给的 atSec 比前一条小
1778
+
1779
1779
  if i > 0 and f <= starts[i - 1]:
1780
1780
  f = starts[i - 1] + (fps // 2)
1781
1781
  starts.append(min(f, scene_dur - 1))
@@ -1783,8 +1783,8 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1783
1783
  for i, s in enumerate(old_subs):
1784
1784
  text = s.get("text", "")
1785
1785
  start_f = starts[i]
1786
- # 最后一条延续到 scene 结尾;其余以下一条
1787
- # 起点为终点。
1786
+
1787
+
1788
1788
  end_f = scene_dur if i == len(old_subs) - 1 else starts[i + 1]
1789
1789
  new_subs.append({
1790
1790
  **s,
@@ -1794,9 +1794,9 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1794
1794
  })
1795
1795
  entry["subtitleSegments"] = new_subs
1796
1796
  elif old_subs:
1797
- sub_span = audio_frames # use exact audio length
1798
- # Use plain char count (markdown ** stripped) for
1799
- # ratio. Fall back to 1 to avoid div-by-zero.
1797
+ sub_span = audio_frames
1798
+
1799
+
1800
1800
  def _plain(t: str) -> int:
1801
1801
  return len(re.sub(r"\*+", "", t or ""))
1802
1802
  total_chars = max(sum(_plain(s.get("text", "")) for s in old_subs), 1)
@@ -1817,11 +1817,11 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1817
1817
 
1818
1818
  _auto_highlight_map(entry, narration_asset, render_plan, line_sync)
1819
1819
 
1820
- # Re-anchor subtitles for scenes that weren't handled in the narration block above.
1821
- # This covers scenes without narration or with invalid duration.
1820
+
1821
+
1822
1822
  old_scene_start = entry.get("startFrame", 0)
1823
1823
  if not (narration_asset and narration_asset.get("duration") and narration_asset["duration"] > 0):
1824
- # Subtitles weren't touched above — re-anchor if position shifted
1824
+
1825
1825
  if old_scene_start != current_frame:
1826
1826
  old_subs = entry.get("subtitleSegments", [])
1827
1827
  if old_subs:
@@ -1841,14 +1841,14 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1841
1841
  entry["startTime"] = round(current_frame / fps, 2)
1842
1842
  entry["endTime"] = round(entry["endFrame"] / fps, 2)
1843
1843
 
1844
- # P2.3: 不再维护 entry.layers —— 旧的 layers 数组只是给 ab-render 模板"间接"
1845
- # 找 background asset 用,现已收敛到 propExtractors 写到 props.backgroundAssetId
1846
- # / props.narrationAssetId 等显式字段。新版 build_render_plan 不再写入
1847
- # entry.layers;如果上游真的塞了 layers 进来(旧 RenderPlan 文件被
1848
- # --render-plan 喂进来),干脆扔掉,避免帧号坐标系混乱。
1844
+
1845
+
1846
+
1847
+
1848
+
1849
1849
  entry.pop("layers", None)
1850
1850
 
1851
- # 为 props.slides 中的每张幻灯片分配 durationFrames(Remotion Sequence 必需)
1851
+
1852
1852
  slides = entry.get("props", {}).get("slides")
1853
1853
  if slides and isinstance(slides, list) and len(slides) > 0:
1854
1854
  scene_dur = entry["durationFrames"]
@@ -1878,7 +1878,7 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1878
1878
 
1879
1879
 
1880
1880
  def render_with_local_cli(render_plan: dict, output_path: str) -> bool:
1881
- """Call Remotion CLI to render the video locally."""
1881
+
1882
1882
  render_plan["status"] = "rendering"
1883
1883
  render_plan["logs"].append({
1884
1884
  "phase": "render",
@@ -1886,12 +1886,12 @@ def render_with_local_cli(render_plan: dict, output_path: str) -> bool:
1886
1886
  "timestamp": now_iso(),
1887
1887
  })
1888
1888
 
1889
- # Absolutise paths so they survive the cwd switch to REMOTION_RENDERER_DIR
1889
+
1890
1890
  output_path = os.path.abspath(output_path)
1891
1891
  os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
1892
1892
 
1893
- # 直接使用 resolvedAssets 中的 HTTPS URL,让 Chrome headless 自行加载外网图片。
1894
- # 若 render-plan.json 曾被本地化处理过(url 以 "/" 开头),从 originalUrl 恢复。
1893
+
1894
+
1895
1895
  https_assets = []
1896
1896
  for asset in render_plan.get("resolvedAssets", []):
1897
1897
  asset = asset.copy()
@@ -1958,7 +1958,7 @@ def render_with_local_cli(render_plan: dict, output_path: str) -> bool:
1958
1958
  composition_id,
1959
1959
  output_path,
1960
1960
  "--props", props_path,
1961
- # 不使用 --public-dir,图片直接通过 HTTPS URL 由 Chrome headless 加载。
1961
+
1962
1962
  "--width", str(config.get("width", 1920)),
1963
1963
  "--height", str(config.get("height", 1080)),
1964
1964
  "--fps", str(config.get("fps", 30)),
@@ -1977,7 +1977,7 @@ def render_with_local_cli(render_plan: dict, output_path: str) -> bool:
1977
1977
  sys.stderr.flush()
1978
1978
 
1979
1979
  try:
1980
- # Stream output to stderr so progress is visible in real-time
1980
+
1981
1981
  proc = subprocess.Popen(
1982
1982
  cmd,
1983
1983
  stdout=subprocess.PIPE,
@@ -1998,19 +1998,19 @@ def render_with_local_cli(render_plan: dict, output_path: str) -> bool:
1998
1998
  if len(stderr_tail) > 50:
1999
1999
  stderr_tail.pop(0)
2000
2000
 
2001
- # Parse and print compact progress: "Rendered 100/2617"
2001
+
2002
2002
  stripped = line.strip()
2003
2003
  if stripped.startswith("Rendered "):
2004
2004
  try:
2005
2005
  parts = stripped.split()
2006
- frac = parts[1].rstrip(",") # "100/2617"
2006
+ frac = parts[1].rstrip(",")
2007
2007
  current = int(frac.split("/")[0])
2008
- # Print progress every 10% or every 100 frames
2008
+
2009
2009
  if total_frames and (current - last_rendered >= max(total_frames // 10, 1)):
2010
2010
  pct = int(current / total_frames * 100)
2011
2011
  LogPrint(f" 🎞️ Render progress: {current}/{total_frames} ({pct}%)", file=sys.stderr)
2012
2012
  sys.stderr.flush()
2013
- # 结构化进度,供上层解析并转发到前端
2013
+
2014
2014
  print(
2015
2015
  json.dumps({
2016
2016
  "__progress__": True,
@@ -2067,25 +2067,25 @@ def render_with_local_cli(render_plan: dict, output_path: str) -> bool:
2067
2067
 
2068
2068
 
2069
2069
  def _persist_plan_checkpoint(render_plan: dict, job_id: Optional[int], private_token: str) -> None:
2070
- """把当前 RenderPlan 存个档(尽力而为,失败只记日志)。
2071
2070
 
2072
- 存在的唯一理由:远端 taskId 必须在**渲染结束之前**就落库。脚本可能在任何时刻
2073
- 被外层超时 SIGTERM,那一刻没有任何机会写盘;只在流程末尾 save_plan 的话,
2074
- taskId 永远存不下来,"续跑"就无从谈起。
2075
- """
2071
+
2072
+
2073
+
2074
+
2075
+
2076
2076
  if not job_id:
2077
2077
  return
2078
2078
  try:
2079
- import render_job_client # type: ignore
2079
+ import render_job_client
2080
2080
  render_job_client.save_plan(job_id, json.dumps(render_plan, ensure_ascii=False), private_token)
2081
2081
  LogPrint(f" 💾 RenderPlan checkpoint saved (jobId={job_id})", file=sys.stderr)
2082
- except Exception as exc: # noqa: BLE001 — 存档失败不该让渲染本身失败
2082
+ except Exception as exc:
2083
2083
  LogPrint(f" ⚠️ RenderPlan checkpoint failed (jobId={job_id}): {exc}", file=sys.stderr)
2084
2084
 
2085
2085
 
2086
2086
 
2087
2087
  def _builtin_dynamic_render_enabled() -> bool:
2088
- """内置模板是否也走 /renderDraft(Phase 3b 路由翻转开关,默认关)。"""
2088
+
2089
2089
  return os.environ.get("REMIXMATE_BUILTIN_DYNAMIC_RENDER", "").strip().lower() in {"1", "true", "yes", "on"}
2090
2090
 
2091
2091
  def render_with_remote_api(
@@ -2099,13 +2099,13 @@ def render_with_remote_api(
2099
2099
  conversation_id: Optional[str] = None,
2100
2100
  job_id: Optional[int] = None,
2101
2101
  ) -> bool:
2102
- """Submit render to ab-api /tool/renderVideo and poll until completion.
2103
2102
 
2104
- On success, writes upload.fileUrl into render_plan and skips local MP4 download.
2105
2103
 
2106
- job_id 用于中途存档(见 _persist_plan_checkpoint):有它才能在被强杀后续跑,
2107
- 没有则退化成旧行为(一次性,杀了就得重渲)。
2108
- """
2104
+
2105
+
2106
+
2107
+
2108
+
2109
2109
  import remote_renderer_client
2110
2110
 
2111
2111
  render_plan["status"] = "rendering"
@@ -2133,11 +2133,11 @@ def render_with_remote_api(
2133
2133
  }
2134
2134
  effective_title = upload_title or os.path.splitext(os.path.basename(output_path))[0]
2135
2135
 
2136
- # ─── Cover composition resolution ────────────────────────────────────
2137
- # 优先级:
2138
- # 1) render_plan.remotionProps.coverCompositionId(上游显式指定)
2139
- # 2) registry.compositions[].slot=="cover" 反查(自动匹配)
2140
- # 不传 → ab-render 不渲封面,行为与改造前一致。
2136
+
2137
+
2138
+
2139
+
2140
+
2141
2141
  cover_composition_id = (
2142
2142
  render_plan.get("remotionProps", {}).get("coverCompositionId")
2143
2143
  )
@@ -2145,60 +2145,60 @@ def render_with_remote_api(
2145
2145
  template_id = render_plan.get("templateId", "")
2146
2146
  cover_composition_id = resolve_cover_composition_id(template_id)
2147
2147
 
2148
- # ─── 私有模板路由 ────────────────────────────────────────────────────
2149
- # 仅【用户私有模板】(isBuiltin=false 且 hasSource) 源码在 OSS、不在
2150
- # ab-render 启动 bundle,需走动态渲染:/renderDraft {templateKey},ab-render 服务端取包。
2151
- # 内置模板即使带 sourceOssKey(admin 调用时 registry 才会输出),渲染仍走 ab-render
2152
- # 预构建的 /render,绝不走 /renderDraft,否则会用 OSS 上的旧快照 + DraftMainVideo
2153
- # 复刻渲染,与生产 MainVideo 漂移。
2148
+
2149
+
2150
+
2151
+
2152
+
2153
+
2154
2154
  pending_resume = bool(str(render_plan.get("remoteTaskId") or "").strip())
2155
2155
  render_path = "/render"
2156
2156
  status_path = "/renderStatus"
2157
2157
 
2158
2158
  source_oss_key = None
2159
- # 内置模板是否也走动态渲染(Phase 3b 路由翻转,ab-platform/docs/
2160
- # template-authoring-page-review.md §7.2)。默认关:翻转依赖 ab-render 以服务身份取
2161
- # 内置源码(DRAFT_BUILTIN_SOURCE_TOKEN ↔ ab-api ab_render.service_token),服务端没配好
2162
- # 就打开,普通用户渲染内置模板会全部失败。服务端就绪后设 REMIXMATE_BUILTIN_DYNAMIC_RENDER=1;
2163
- # 回退 = 取消该变量,内置模板回到 /render。
2159
+
2160
+
2161
+
2162
+
2163
+
2164
2164
  builtin_dynamic = _builtin_dynamic_render_enabled()
2165
2165
  is_builtin_dynamic = False
2166
2166
  template_id = render_plan.get("templateId", "")
2167
2167
  if template_id:
2168
2168
  try:
2169
- from registry_loader import get_template # type: ignore
2170
- # include_all_statuses:门控管选型不管渲染(理由见 resolve_remotion_entry)。
2171
- # 漏掉它的话私有 beta 模板会丢 sourceOssKey,转而按内置模板渲——渲出来的
2172
- # 是另一个模板的画面。
2169
+ from registry_loader import get_template
2170
+
2171
+
2172
+
2173
2173
  _meta = get_template(template_id, include_all_statuses=True)
2174
2174
  if _meta and not _meta.get("isBuiltin"):
2175
2175
  source_oss_key = _meta.get("hasSource")
2176
2176
  elif _meta and builtin_dynamic:
2177
- # 内置模板:客户端拿不到也不需要 sourceOssKey(源码保护),只要知道
2178
- # 它在 registry 里;ab-render 按 templateKey 以服务身份取包。
2177
+
2178
+
2179
2179
  source_oss_key = True
2180
2180
  is_builtin_dynamic = True
2181
2181
  except Exception:
2182
2182
  source_oss_key = None
2183
2183
 
2184
2184
  if source_oss_key:
2185
- # 只传 templateKey:由 ab-render 带本次的 X-Priv-Token 向 ab-api 换源码包 URL
2186
- # 并自行下载(所有权校验在 ab-api)。源码包 URL 不经过客户端 ——
2187
- # 见 ab-platform/docs/templates/template-source-protection-design.md §4.3。
2185
+
2186
+
2187
+
2188
2188
  payload = {
2189
2189
  "templateKey": template_id,
2190
2190
  "inputProps": input_props,
2191
2191
  "upload": True,
2192
2192
  "uploadTitle": effective_title,
2193
2193
  }
2194
- # 私有模板封面:传 coverCompositionId 让 /renderDraft 渲模板自带 cover 组件
2195
- # (ab-render 注册了 DraftCover),与内置 /render 的 MainCover 对等;
2196
- # 不传则 ab-render 退回截首帧 / 不渲。
2194
+
2195
+
2196
+
2197
2197
  if cover_composition_id:
2198
2198
  payload["coverCompositionId"] = cover_composition_id
2199
2199
  if is_builtin_dynamic:
2200
- # 与预构建 /render 的 MainVideo 语义对齐:不把 entry.props 摊平到顶层
2201
- # (摊平会让同名键覆盖平台注入的 typography / variantId 等)。
2200
+
2201
+
2202
2202
  payload["spreadEntryProps"] = False
2203
2203
  render_path = "/renderDraft"
2204
2204
  status_path = "/renderDraftStatus"
@@ -2216,7 +2216,7 @@ def render_with_remote_api(
2216
2216
 
2217
2217
  total_frames = config.get("totalFrames", 0)
2218
2218
  if pending_resume:
2219
- # 续跑:下面那堆"正在提交"的日志会误导人,跳过
2219
+
2220
2220
  LogPrint(f"🎬 Remote render task already exists, will resume polling", file=sys.stderr)
2221
2221
  elif is_builtin_dynamic:
2222
2222
  LogPrint(f" Builtin template (dynamic bundle): {template_id}", file=sys.stderr)
@@ -2230,17 +2230,17 @@ def render_with_remote_api(
2230
2230
  LogPrint(f" total frames: {total_frames}", file=sys.stderr)
2231
2231
  sys.stderr.flush()
2232
2232
 
2233
- # ─── 续跑既有任务 ────────────────────────────────────────────────────
2234
- # 上一次调用可能是被外层超时 SIGTERM 掉的(skill 执行有上限),而远端任务还在
2235
- # ab-render 上跑着。这时重新提交 = 白烧一遍渲染、并且大概率再次撞上同一个上限。
2236
- # 所以先看 plan 里有没有上次留下的 taskId:有就直接续着轮询。
2237
- #
2238
- # 只在"任务不可用"(已失败 / 查不到)时才退回重新提交 —— 轮询超时不算不可用,
2239
- # 那条路走 RemoteRenderTimeout,保留 taskId 交给下一次调用。
2233
+
2234
+
2235
+
2236
+
2237
+
2238
+
2239
+
2240
2240
  task_id = str(render_plan.get("remoteTaskId") or "").strip()
2241
2241
  if task_id:
2242
- # 提交时用的状态端点存在 plan 里(内置模板 /renderStatus,私有模板
2243
- # /renderDraftStatus)。用存的那个,别依赖这次重新推导的结果。
2242
+
2243
+
2244
2244
  status_path = render_plan.get("remoteStatusPath") or status_path
2245
2245
  LogPrint(f"🔄 resuming existing remote task: taskId={task_id}", file=sys.stderr)
2246
2246
  render_plan["logs"].append({
@@ -2270,19 +2270,19 @@ def render_with_remote_api(
2270
2270
  "message": f"Remote task submitted: {task_id}",
2271
2271
  "timestamp": now_iso(),
2272
2272
  })
2273
- # **立刻落库**,不等渲染结束。被 SIGTERM 的那一刻脚本没有任何机会写盘,
2274
- # 所以 taskId 必须在这里就进 DB,否则下一次调用无从续起(这正是旧代码
2275
- # 只写内存、`remoteTaskId` 全程没人读的后果)。
2273
+
2274
+
2275
+
2276
2276
  _persist_plan_checkpoint(render_plan, job_id, private_token)
2277
2277
 
2278
2278
  last_progress = -1.0
2279
- # 续跑时用提交时刻算 elapsed,否则 ETA 会按"刚开始"估,一上来就偏小。
2279
+
2280
2280
  render_start_time = float(render_plan.get("remoteStartedAt") or time.time())
2281
2281
 
2282
2282
  def _on_progress(status_data: dict):
2283
2283
  nonlocal last_progress
2284
2284
  progress = float(status_data.get("progress") or 0.0)
2285
- # 门槛设小(2%):短渲染服务端进度增量小,太大的门槛会把中间档全吞掉,只剩 0%→100%
2285
+
2286
2286
  if progress - last_progress >= 0.02 or progress >= 1.0:
2287
2287
  pct = int(progress * 100)
2288
2288
  elapsed = time.time() - render_start_time
@@ -2296,7 +2296,7 @@ def render_with_remote_api(
2296
2296
  else:
2297
2297
  LogPrint(f" 🎞️ Remote render progress: {pct}%", file=sys.stderr)
2298
2298
  sys.stderr.flush()
2299
- # 结构化进度,供上层解析并转发到前端
2299
+
2300
2300
  print(
2301
2301
  json.dumps({
2302
2302
  "__progress__": True,
@@ -2321,8 +2321,8 @@ def render_with_remote_api(
2321
2321
  status_path=status_path,
2322
2322
  )
2323
2323
  except remote_renderer_client.RemoteRenderTimeout as exc:
2324
- # 等不动了,但任务**没失败**。保留 taskId,让下一次调用接着轮询;
2325
- # 状态不写 failed(写了模型会当成"渲染失败"而重新走一遍完整流程)。
2324
+
2325
+
2326
2326
  LogPrint(f"⏳ {exc}", file=sys.stderr)
2327
2327
  render_plan["status"] = "rendering"
2328
2328
  _persist_plan_checkpoint(render_plan, job_id, private_token)
@@ -2340,8 +2340,8 @@ def render_with_remote_api(
2340
2340
  except Exception as exc:
2341
2341
  LogPrint(f"❌ remote render polling failed: {exc}", file=sys.stderr)
2342
2342
  render_plan["status"] = "failed"
2343
- # 任务确实不可用了(失败 / 查不到)。清掉 taskId,否则下一次调用会一直
2344
- # 去续一个永远好不了的任务,再也提交不了新的。
2343
+
2344
+
2345
2345
  render_plan.pop("remoteTaskId", None)
2346
2346
  render_plan.pop("remoteStatusPath", None)
2347
2347
  render_plan.pop("remoteStartedAt", None)
@@ -2364,13 +2364,13 @@ def render_with_remote_api(
2364
2364
  })
2365
2365
  return False
2366
2366
 
2367
- # 提取 VOD 元数据
2367
+
2368
2368
  video_id = (result or {}).get("videoId", "")
2369
2369
  file_id = (result or {}).get("fileId")
2370
2370
 
2371
- # 提取封面(可选;ab-render 在视频成功后串行渲一帧静态封面)
2372
- # 失败不影响视频本身的成败——cover_url 为空则上游可以选择回退到占位图
2373
- # 或重试。
2371
+
2372
+
2373
+
2374
2374
  cover_url = (result or {}).get("coverUrl")
2375
2375
  cover_error = (result or {}).get("coverError")
2376
2376
 
@@ -2386,7 +2386,7 @@ def render_with_remote_api(
2386
2386
  if cover_url:
2387
2387
  render_plan["upload"]["coverUrl"] = cover_url
2388
2388
  if cover_error and not cover_url:
2389
- # 视频成功但封面失败:降级为非致命 log,便于上游看到原因
2389
+
2390
2390
  render_plan["logs"].append({
2391
2391
  "phase": "render",
2392
2392
  "message": f"Cover render failed (non-fatal): {cover_error}",
@@ -2407,7 +2407,7 @@ def render_with_remote_api(
2407
2407
  file=sys.stderr,
2408
2408
  )
2409
2409
 
2410
- # 如果是 vod:// 地址,轮询后端获取实际播放 URL
2410
+
2411
2411
  if file_url.startswith("vod://") and file_id and private_token:
2412
2412
  playback_url = _poll_vod_playback_url(file_id, private_token)
2413
2413
  if playback_url:
@@ -2426,16 +2426,16 @@ def _poll_vod_playback_url(
2426
2426
  max_wait: float = 60.0,
2427
2427
  interval: float = 3.0,
2428
2428
  ):
2429
- """Backwards-compat wrapper — implementation lives in ``_vod_polling`` module."""
2429
+
2430
2430
  from _vod_polling import poll_vod_playback_url
2431
2431
  return poll_vod_playback_url(file_id, private_token, max_wait=max_wait, interval=interval)
2432
2432
 
2433
2433
 
2434
2434
  def auto_bind_template(dsl: dict, template_id: str) -> dict:
2435
- """Auto-generate TemplateBinding from DSL + template-id by importing
2436
- match_template.py's binding logic. Eliminates the need for a separate
2437
- match_template.py invocation step."""
2438
- # template-registry/scripts is on sys.path via the module-top setup.
2435
+
2436
+
2437
+
2438
+
2439
2439
  try:
2440
2440
  import match_template
2441
2441
  except ImportError as e:
@@ -2458,14 +2458,14 @@ def auto_bind_template(dsl: dict, template_id: str) -> dict:
2458
2458
  LogPrint(f"❌ template not found: {template_id} (available: {available})", file=sys.stderr)
2459
2459
  sys.exit(1)
2460
2460
 
2461
- # 选型门控在这里落地,而不是在上面的查表上:查表刻意用未门控的列表,因为绑定
2462
- # 必须读到模板真实的定义(slotMapping / compositions)。门控管的是"允许不允许
2463
- # 选中它",而 auto_bind 正是"一个 id 变成本次成片所用模板"的那一刻。
2464
- #
2465
- # dsl_validator 已经在更早的位置报同一件事,但它只看 meta.templateId;手写的
2466
- # DSL 可能没有那个字段,只靠 --template-id 传进来 —— 这里补上那条路。
2461
+
2462
+
2463
+
2464
+
2465
+
2466
+
2467
2467
  try:
2468
- from registry_loader import gated_status # type: ignore
2468
+ from registry_loader import gated_status
2469
2469
  _gated = gated_status(template_id)
2470
2470
  except Exception:
2471
2471
  _gated = None
@@ -2491,15 +2491,15 @@ def auto_bind_template(dsl: dict, template_id: str) -> dict:
2491
2491
 
2492
2492
 
2493
2493
  def _log_render_plan_summary(render_plan: dict) -> None:
2494
- """Print a one-line identity summary of a loaded RenderPlan.
2495
-
2496
- The two ways to feed an existing plan (``--render-plan <file>`` and
2497
- ``--job-id <int>``) used to print only the *source* (path / id), never the
2498
- *content*. When a stale ``output/render-plan.json`` from a previous, unrelated
2499
- run got picked up, the mismatch was rendered silently. Surfacing
2500
- templateId / title / duration / scene count here makes a wrong plan obvious
2501
- at a glance before any render time is spent.
2502
- """
2494
+
2495
+
2496
+
2497
+
2498
+
2499
+
2500
+
2501
+
2502
+
2503
2503
  try:
2504
2504
  template_id = render_plan.get("templateId", "?")
2505
2505
  title = render_plan.get("title", "")
@@ -2515,12 +2515,12 @@ def _log_render_plan_summary(render_plan: dict) -> None:
2515
2515
  file=sys.stderr,
2516
2516
  )
2517
2517
  except Exception:
2518
- # A summary is a convenience, never a hard dependency of rendering.
2518
+
2519
2519
  pass
2520
2520
 
2521
2521
 
2522
2522
  def main():
2523
- # 无论正常结束、sys.exit 还是中途失败,都在最后报一次积分账(无扣费时静默)。
2523
+
2524
2524
  atexit.register(print_billing_footer)
2525
2525
  parser = argparse.ArgumentParser(
2526
2526
  description="Video render tool — DSL + TemplateBinding → Remotion video.",
@@ -2564,8 +2564,8 @@ Examples:
2564
2564
  parser.add_argument(
2565
2565
  "--renderer",
2566
2566
  choices=["local", "remote"],
2567
- # 只有显式等于 local 才本地渲染;空串 / 拼写错误 / 未设一律 remote,
2568
- # 避免 Nacos 里该键为空值时静默掉进 local 模式(本地渲染需容器内自带 renderer)。
2567
+
2568
+
2569
2569
  default=(os.environ.get("REMOTION_RENDER_MODE") or "remote").strip().lower(),
2570
2570
  help="Render mode: remote=call the remote renderer service (default); local=local Remotion CLI (needs Node.js 18+)",
2571
2571
  )
@@ -2573,9 +2573,9 @@ Examples:
2573
2573
  "--remote-poll-timeout",
2574
2574
  type=float,
2575
2575
  default=float(os.environ.get("REMOTION_REMOTE_POLL_TIMEOUT", "2700")),
2576
- # 实测最长的一支约 30 分钟,加上 ab-render 排队要留余量,所以是 45 分钟。
2577
- # 这个值必须小于 ab-agent 侧 render_video 的 skill 超时(默认 50 分钟),
2578
- # 否则外层先 SIGTERM,这里这套"超时但保留 taskId"的续跑逻辑根本轮不到执行。
2576
+
2577
+
2578
+
2579
2579
  help="Remote-render polling timeout (seconds, default 2700)",
2580
2580
  )
2581
2581
  parser.add_argument(
@@ -2598,25 +2598,25 @@ Examples:
2598
2598
  args = parser.parse_args()
2599
2599
 
2600
2600
  private_token = args.priv_token or os.environ.get("PRIV_TOKEN", "")
2601
- # The registry loader shares the environment-based auth entry point. Keep
2602
- # explicit CLI credentials consistent with render_job_client's token.
2601
+
2602
+
2603
2603
  if args.priv_token:
2604
2604
  os.environ["PRIV_TOKEN"] = private_token
2605
2605
 
2606
- # --save-job 默认开启,但若没有 PRIV_TOKEN 则自动降级为关闭,避免阻塞无 token 的本地调试。
2606
+
2607
2607
  if args.save_job and not private_token:
2608
2608
  LogPrint("ℹ️ PRIV_TOKEN not configured; --save-job auto-disabled (will not write to database)", file=sys.stderr)
2609
2609
  args.save_job = False
2610
2610
 
2611
2611
  if args.job_id is not None and args.job_id > 0:
2612
- # ── Load RenderPlan from the database ─────────────────────────────
2612
+
2613
2613
  LogPrint(f"📋 loading RenderPlan from database (jobId={args.job_id})...", file=sys.stderr)
2614
2614
  if not private_token:
2615
2615
  LogPrint("❌ --job-id mode requires PRIV_TOKEN or --priv-token", file=sys.stderr)
2616
2616
  sys.exit(1)
2617
- # render_job_client lives in skills/template-registry/scripts/ (the shared
2618
- # location for cross-skill helpers); it is already on sys.path via the
2619
- # module-top setup.
2617
+
2618
+
2619
+
2620
2620
  from render_job_client import get_plan as rjc_get_plan
2621
2621
  try:
2622
2622
  render_plan_str = rjc_get_plan(args.job_id, private_token)
@@ -2633,10 +2633,10 @@ Examples:
2633
2633
  render_plan = load_json(args.render_plan)
2634
2634
  LogPrint(f"📋 loaded existing RenderPlan: {args.render_plan}", file=sys.stderr)
2635
2635
  _log_render_plan_summary(render_plan)
2636
- # 陈旧文件防护:output/render-plan.json 等共享文件名常被上一次别的项目
2637
- # 的运行残留覆盖(output/ 是 gitignored 调试目录)。当用户同时给了 --dsl
2638
- # 用以表明"我想渲这个 DSL",但磁盘上的 plan 比 DSL 还旧时,几乎可以肯定
2639
- # 加载到的是过期 plan —— 明确警告而不是静默渲染错的东西。
2636
+
2637
+
2638
+
2639
+
2640
2640
  if args.dsl and os.path.exists(args.dsl):
2641
2641
  try:
2642
2642
  if os.path.getmtime(args.render_plan) < os.path.getmtime(args.dsl):
@@ -2669,25 +2669,25 @@ Examples:
2669
2669
  )
2670
2670
  sys.exit(1)
2671
2671
 
2672
- # 解析 DSL:优先 --dsl-json(inline),其次 --dsl(文件路径)
2672
+
2673
2673
  if args.dsl_json:
2674
2674
  try:
2675
- # 规范化 LLM 可能输出的全角/中文标点 → 标准 ASCII,避免 JSON 解析失败
2675
+
2676
2676
  _dsl_str = args.dsl_json
2677
- # 中文引号 → ASCII 引号
2677
+
2678
2678
  _dsl_str = _dsl_str.replace('\u201c', '"').replace('\u201d', '"')
2679
2679
  _dsl_str = _dsl_str.replace('\u2018', "'").replace('\u2019', "'")
2680
- # 全角冒号/逗号/括号 → ASCII(最常见的 JSON 结构破坏者)
2681
- _dsl_str = _dsl_str.replace('\uff1a', ':') # : → :
2682
- _dsl_str = _dsl_str.replace('\uff0c', ',') # , → ,
2683
- _dsl_str = _dsl_str.replace('\uff08', '(') # ( → (
2684
- _dsl_str = _dsl_str.replace('\uff09', ')') # ) → )
2685
- _dsl_str = _dsl_str.replace('\u3010', '[') # 【 → [
2686
- _dsl_str = _dsl_str.replace('\u3011', ']') # 】 → ]
2687
- _dsl_str = _dsl_str.replace('\uff3b', '[') # [ → [
2688
- _dsl_str = _dsl_str.replace('\uff3d', ']') # ] → ]
2689
- _dsl_str = _dsl_str.replace('\uff5b', '{') # { → {
2690
- _dsl_str = _dsl_str.replace('\uff5d', '}') # } → }
2680
+
2681
+ _dsl_str = _dsl_str.replace('\uff1a', ':')
2682
+ _dsl_str = _dsl_str.replace('\uff0c', ',')
2683
+ _dsl_str = _dsl_str.replace('\uff08', '(')
2684
+ _dsl_str = _dsl_str.replace('\uff09', ')')
2685
+ _dsl_str = _dsl_str.replace('\u3010', '[')
2686
+ _dsl_str = _dsl_str.replace('\u3011', ']')
2687
+ _dsl_str = _dsl_str.replace('\uff3b', '[')
2688
+ _dsl_str = _dsl_str.replace('\uff3d', ']')
2689
+ _dsl_str = _dsl_str.replace('\uff5b', '{')
2690
+ _dsl_str = _dsl_str.replace('\uff5d', '}')
2691
2691
  dsl = json.loads(_dsl_str)
2692
2692
  except json.JSONDecodeError as e:
2693
2693
  LogPrint(f"❌ --dsl-json parse failed: {e}", file=sys.stderr)
@@ -2698,14 +2698,14 @@ Examples:
2698
2698
  sys.exit(1)
2699
2699
  dsl = load_json(args.dsl)
2700
2700
 
2701
- # 自动补全 version 字段(LLM 重构 DSL 时可能遗漏)
2701
+
2702
2702
  if "version" not in dsl:
2703
2703
  dsl["version"] = "v1alpha1"
2704
2704
  LogPrint("⚠️ DSL missing version field; auto-filled with v1alpha1", file=sys.stderr)
2705
2705
 
2706
- # 骨架检查:防止 gen_script 原始骨架被直接提交(音频旁白不一致 bug)。
2707
- # agent.ts 已经在 MCP 层做了拦截,这里是第二道保险,兼容绕过 agent 直接调用的场景。
2708
- # narration.text 是唯一权威来源,所以只检查 scenes[].audio.narration。
2706
+
2707
+
2708
+
2709
2709
  _skeleton_offenders: list[str] = []
2710
2710
  _skel_markers = ("[骨架待填充]", "[skeleton placeholder]", "这是需要替换的占位文案")
2711
2711
  for _scene in dsl.get("scenes", []) or []:
@@ -2730,20 +2730,20 @@ Examples:
2730
2730
  )
2731
2731
  sys.exit(2)
2732
2732
 
2733
- # ── 按引用注入模板声明的大 payload(必须在校验之前)─────────────
2734
- #
2735
- # 按引用注入的模板画面数据(payloadHydration.payloadField 指定的字段:一整页
2736
- # 的节点树、坐标、配色、beats)只有一个合法产出方 —— ab-render 的 /extract layout 层,
2737
- # 而它体积大到不能过模型(一页几百 KB,23 页好几 MB)。所以 DSL 里只带一个
2738
- # 引用(meta.sourceDocument),在这里把全量取回来填进每个场景。
2739
- #
2740
- # 为什么必须在 validate_dsl 之前:那道校验读的就是 capabilities.payloadRequirements
2741
- # 的必填项。放在后面 = 每次都被自己还没填的字段拒掉(2026-09-23 线上
2742
- # conv_Sesw2g8oM81 的表现就是 scenes[0..19].customPayload.slide (required))。
2733
+
2734
+
2735
+
2736
+
2737
+
2738
+
2739
+
2740
+
2741
+
2742
+
2743
2743
  _hydrate_dsl_payload(dsl, args.template_id, args.source_document_url)
2744
2744
 
2745
- # compose 模式模板:agent 只写了画面主体,这里拼上模板画风(旁白此刻已定稿)。
2746
- # --job-id / --render-plan 不走这里:计划已落库,不再改 prompt。
2745
+
2746
+
2747
2747
  _compose_image_prompts(dsl, args.template_id)
2748
2748
 
2749
2749
  errors = validate_dsl(dsl)
@@ -2753,19 +2753,19 @@ Examples:
2753
2753
  LogPrint(f" - {err}", file=sys.stderr)
2754
2754
  sys.exit(1)
2755
2755
 
2756
- # binding 由 --template-id 现算,不再接受外部 binding 文件 / JSON。
2757
- # 这是 P1.1 的关键简化:DSL → RenderPlan 之间不再有独立工件,
2758
- # 直接 (DSL + templateId + registry) → RenderPlan。
2759
- # binding 仅在内存中作为中间态使用,不落盘、不入库。需要排查时
2760
- # 直接 print(binding) 即可。
2756
+
2757
+
2758
+
2759
+
2760
+
2761
2761
  binding = auto_bind_template(dsl, args.template_id)
2762
2762
 
2763
2763
  LogPrint(f"📋 Building RenderPlan...", file=sys.stderr)
2764
2764
  render_plan = build_render_plan(dsl, binding)
2765
2765
  render_plan["_dsl_assets"] = dsl.get("assets", [])
2766
2766
 
2767
- # Job/file mode bypasses auto_bind_template. Validate saved plans too, so a
2768
- # previously accepted placeholder-only plan cannot silently render again.
2767
+
2768
+
2769
2769
  if args.job_id or args.render_plan:
2770
2770
  from registry_loader import get_template
2771
2771
  from required_props import validate_required_props
@@ -2787,9 +2787,9 @@ Examples:
2787
2787
  sys.exit(2)
2788
2788
 
2789
2789
  if not args.skip_asset_resolve:
2790
- # Stub mode: short-circuit gen-image / gen-video before counting pending.
2791
- # CLI flag takes precedence; env vars act as fallback with a visible warning
2792
- # so silent cross-session leakage is always observable.
2790
+
2791
+
2792
+
2793
2793
  stub_image_url = args.stub_image_url
2794
2794
  stub_video_url = args.stub_video_url
2795
2795
  if stub_image_url is None:
@@ -2815,16 +2815,16 @@ Examples:
2815
2815
  LogPrint(f"🧪 Stub mode is on but no matching assets were found (no pending gen-image / gen-video resources)", file=sys.stderr)
2816
2816
  pending_count = sum(1 for a in render_plan["resolvedAssets"] if a["status"] == "pending")
2817
2817
  if pending_count > 0:
2818
- # ── Token pre-check ───────────────────────────────────────────
2819
- # Fail fast with a clear message instead of letting child
2820
- # processes silently hang waiting for interactive token input.
2818
+
2819
+
2820
+
2821
2821
  if not private_token:
2822
2822
  LogPrint("❌ asset generation needs an auth token, but PRIV_TOKEN env var is not set and --priv-token was not passed", file=sys.stderr)
2823
2823
  LogPrint(" configure it one of these ways:", file=sys.stderr)
2824
2824
  LogPrint(" 1. export PRIV_TOKEN=<your-token>", file=sys.stderr)
2825
2825
  LogPrint(" 2. python3 render_video.py --priv-token <your-token> ...", file=sys.stderr)
2826
2826
  sys.exit(1)
2827
- # ──────────────────────────────────────────────────────────────
2827
+
2828
2828
  LogPrint(f"🔍 Resolving {pending_count} missing asset(s)...", file=sys.stderr)
2829
2829
  render_plan = resolve_assets(
2830
2830
  render_plan,
@@ -2839,8 +2839,8 @@ Examples:
2839
2839
 
2840
2840
  if not args.skip_asset_resolve:
2841
2841
  render_plan = adjust_timeline_to_audio(render_plan)
2842
- # 数字人按连续段生成要用到排轴后的场景时长,所以排在 adjust 之后;
2843
- # 它还要读 DSL 里的资产 payload,_dsl_assets 因此晚一步再丢。
2842
+
2843
+
2844
2844
  render_plan = resolve_digital_human_segments(
2845
2845
  render_plan,
2846
2846
  private_token=private_token,
@@ -2853,32 +2853,32 @@ Examples:
2853
2853
  output_path = os.path.join(OUTPUT_DIR, "video.mp4")
2854
2854
  rp_output = args.render_plan_output or os.path.join(OUTPUT_DIR, "render-plan.json")
2855
2855
 
2856
- # --save-job 模式下 RenderPlan 只存数据库,不写磁盘(避免多用户并发时互相覆盖同名文件)
2856
+
2857
2857
  if args.save_render_plan or not args.save_job:
2858
2858
  validate_and_fix_render_plan(render_plan)
2859
2859
  save_json(render_plan, rp_output)
2860
2860
  LogPrint(f"💾 RenderPlan saved: {rp_output}", file=sys.stderr)
2861
2861
 
2862
- # ── 保存 RenderPlan 到数据库 ──────────────────────────────────────────
2863
- # 默认 args.save_job=True,所以无论 --resolve-only 还是直接渲染,都会落库;
2864
- # 没有 PRIV_TOKEN 时已在 main 入口降级为 False。
2862
+
2863
+
2864
+
2865
2865
  if args.save_job:
2866
- # 入库前校验 + 自动修复
2866
+
2867
2867
  validate_and_fix_render_plan(render_plan)
2868
2868
 
2869
2869
  from render_job_client import create_job as rjc_create_job, save_plan as rjc_save_plan
2870
2870
  render_plan_json_str = json.dumps(render_plan, ensure_ascii=False)
2871
- # 若已有 job_id 则复用,否则新建
2871
+
2872
2872
  _job_id = args.job_id
2873
2873
  if not _job_id:
2874
- # 构造 DB 列 dsl_meta(轻量摘要供列表查询使用)。
2875
- # 单一来源是 dsl.meta — render_plan 里只放渲染必需的最小集,不再缓存整段 meta。
2874
+
2875
+
2876
2876
  _locals = locals()
2877
2877
  _dsl_obj = _locals.get("dsl")
2878
2878
  _binding_obj = _locals.get("binding")
2879
2879
  _meta_summary: dict = {}
2880
- # 直接渲染(--render-plan / --job-id)这种路径下没有 dsl 对象,
2881
- # 退而用 render_plan 里的字段拼一份摘要,保证列表页有可读信息。
2880
+
2881
+
2882
2882
  if _dsl_obj:
2883
2883
  _src_meta = (_dsl_obj.get("meta") or {})
2884
2884
  for _k in ("title", "topic", "platform", "templateId", "targetDuration"):
@@ -2916,7 +2916,7 @@ Examples:
2916
2916
  try:
2917
2917
  rjc_save_plan(_job_id, render_plan_json_str, private_token)
2918
2918
  LogPrint(f"✅ RenderPlan saved to database: jobId={_job_id}", file=sys.stderr)
2919
- # 把回填的 job_id 暴露给后续 saveManifest 使用
2919
+
2920
2920
  args.job_id = _job_id
2921
2921
  print(f"\n📦 render job jobId: {_job_id}")
2922
2922
  except RuntimeError as e:
@@ -2933,7 +2933,7 @@ Examples:
2933
2933
  target_dur = render_plan.get("targetDuration")
2934
2934
  actual_dur = render_plan.get("renderConfig", {}).get("totalDuration", 0)
2935
2935
 
2936
- # ── 摘要输出到 stdout,供 Agent 读取资产 URL(不要改为 LogPrint/stderr)──
2936
+
2937
2937
  print(f"\n📊 Asset resolution finished: total {total}, generated {generated}, failed {failed}")
2938
2938
  if target_dur:
2939
2939
  diff = actual_dur - target_dur
@@ -2965,9 +2965,9 @@ Examples:
2965
2965
  url = asset.get("url", "")
2966
2966
  print(f" {asset['assetId']:<25} {url}")
2967
2967
 
2968
- # 数字人段也要列出来:模型按这里写成片总结。线上出过 DSL 里没有数字人、
2969
- # 模型却照着自己的意图汇报「数字人出镜」的事(conv_lvFaCClP7HrW5gDOZU1e),
2970
- # 没有这一节时它无从核对。没有数字人时明说,而不是什么都不打。
2968
+
2969
+
2970
+
2971
2971
  avatar_assets = [a for a in render_plan.get("resolvedAssets", []) if a.get("type") == "avatar"]
2972
2972
  avatar_scenes: dict = {}
2973
2973
  for entry in render_plan.get("timeline", []):
@@ -2981,7 +2981,7 @@ Examples:
2981
2981
  print(f" {asset['assetId']:<25} {asset.get('status', '?'):<10} scenes: {scenes_on} {asset.get('url', '')}")
2982
2982
  else:
2983
2983
  print(f"\n🧑 Digital-human segments: none (this render has no presenter)")
2984
- # ─────────────────────────────────────────────────────────────────────
2984
+
2985
2985
 
2986
2986
  if failed > 0:
2987
2987
  sys.exit(1)
@@ -2999,9 +2999,9 @@ Examples:
2999
2999
  or render_plan.get("title")
3000
3000
  or os.path.splitext(os.path.basename(output_path))[0]
3001
3001
  )
3002
- # 阿里云 VOD 与 ab-api DTO 都按"字符数"限制 title 长度(max=128)。
3003
- # DSL 的 meta.title 经常是一段长描述(gen-script 把 topic 直接当 title),
3004
- # 这里截断到 128,避免远程渲染跑完之后栽在 CreateUploadVideoToken 校验上。
3002
+
3003
+
3004
+
3005
3005
  if upload_title_hint and len(upload_title_hint) > 128:
3006
3006
  upload_title_hint = upload_title_hint[:128]
3007
3007
  conversation_id = os.environ.get("CONVERSATION_ID") or None
@@ -3025,7 +3025,7 @@ Examples:
3025
3025
  render_plan["renderMode"] = "local"
3026
3026
  success = render_with_local_cli(render_plan, output_path)
3027
3027
 
3028
- # save-job 模式下 RenderPlan 只入库;非 save-job 才写共享磁盘
3028
+
3029
3029
  if not args.save_job:
3030
3030
  save_json(render_plan, rp_output)
3031
3031
 
@@ -3041,7 +3041,7 @@ Examples:
3041
3041
  "sceneCount": len(render_plan["timeline"]),
3042
3042
  "errorCount": len(render_plan["errors"]),
3043
3043
  }
3044
- # --save-job 模式下 Manifest 只存数据库,不写磁盘(避免多用户并发时互相覆盖同名文件)
3044
+
3045
3045
  manifest_path = None if args.save_job else os.path.join(OUTPUT_DIR, "render-manifest.json")
3046
3046
  if manifest_path:
3047
3047
  save_json(manifest, manifest_path)
@@ -3055,26 +3055,26 @@ Examples:
3055
3055
  LogPrint(f" Remote video: {remote_url}", file=sys.stderr)
3056
3056
  if playback_url:
3057
3057
  LogPrint(f" Playback URL: {playback_url}", file=sys.stderr)
3058
- # Print the playable video URL to stdout; prefer the transcoded playbackUrl.
3058
+
3059
3059
  effective_playback = playback_url or remote_url
3060
3060
  if effective_playback and not effective_playback.startswith("vod://"):
3061
3061
  print(f"\n🎬 Video playback URL: {effective_playback}")
3062
- # 封面也走 stdout:Codex / Claude Code 等没有内联播放器的宿主只能从
3063
- # 这里拿到封面地址(之前只打到 stderr,模型回复里因此只有视频没有封面)。
3062
+
3063
+
3064
3064
  if upload_info.get("coverUrl"):
3065
3065
  print(f"🖼️ Cover image URL: {upload_info['coverUrl']}")
3066
3066
 
3067
- # 结构化资产标记 —— 宿主(ab-agent)据此把权威 URL 作为 attachment
3068
- # 下发前端渲染播放器,绕开模型正文。
3069
- #
3070
- # 为什么必须绕开:模型复述 32 位不透明 hex 会出错。2026-08-30 实测一次
3071
- # 成片链接被吞掉一个字符(…c1c20102 → …c1c2012),用户拿到 404,
3072
- # 而文件本身好好地在 CDN 上。约定与 web-screenshot/scripts/record.py
3073
- # 的 __web_record_asset__ 一致。
3074
- #
3075
- # 只在拿到**非 vod:// 的真实播放地址**时才打标记(上面的 if 已保证):
3076
- # playbackUrl 来自 VOD 轮询,可能超时未就绪。宁可前端没有内联播放器
3077
- # (用户仍可从成片面板看),也不要再给出一条不可用的地址。
3067
+
3068
+
3069
+
3070
+
3071
+
3072
+
3073
+
3074
+
3075
+
3076
+
3077
+
3078
3078
  asset = {"url": effective_playback}
3079
3079
  cover = upload_info.get("coverUrl")
3080
3080
  if cover:
@@ -3093,7 +3093,7 @@ Examples:
3093
3093
  LogPrint(f" Manifest: {manifest_path}", file=sys.stderr)
3094
3094
 
3095
3095
  if render_mode == "remote":
3096
- # In remote mode the fileUrl came from the server; reuse it into the manifest.
3096
+
3097
3097
  manifest["upload"] = render_plan.get("upload", {})
3098
3098
  if manifest_path:
3099
3099
  save_json(manifest, manifest_path)
@@ -3114,10 +3114,10 @@ Examples:
3114
3114
  "fileUrl": upload_result["fileUrl"],
3115
3115
  "title": upload_title,
3116
3116
  "uploadedAt": now_iso(),
3117
- # ab-api 保存 manifest 时按 fileId 给成片建语义卡 / 片段 / 血缘
3117
+
3118
3118
  "fileId": upload_result.get("fileId"),
3119
3119
  }
3120
- # 本地渲染 + 上传成功时同样把地址放到 stdout,供无播放器的宿主回显给用户
3120
+
3121
3121
  if upload_result.get("fileUrl"):
3122
3122
  print(f"\n🎬 Video playback URL: {upload_result['fileUrl']}")
3123
3123
  if manifest_path:
@@ -3126,7 +3126,7 @@ Examples:
3126
3126
  except Exception as e:
3127
3127
  LogPrint(f"\n⚠️ upload failed (the video stays local): {e}", file=sys.stderr)
3128
3128
 
3129
- # ── Save Manifest to the database ────────────────────────────────
3129
+
3130
3130
  if args.save_job and args.job_id:
3131
3131
  from render_job_client import save_manifest as rjc_save_manifest
3132
3132
  try: