@remixmate/cli 0.9.71 → 0.9.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/LICENSE +45 -21
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/dist/argv.js +1 -51
  5. package/dist/auth/auth-lock.js +1 -100
  6. package/dist/auth/auto-login.js +7 -75
  7. package/dist/auth/commands.js +40 -290
  8. package/dist/auth/credential-store.js +1 -143
  9. package/dist/auth/device-flow-runner.js +13 -110
  10. package/dist/auth/device-flow.js +1 -132
  11. package/dist/auth/ensure.js +1 -62
  12. package/dist/auth/environment.js +1 -69
  13. package/dist/auth/pending-store.js +1 -45
  14. package/dist/auth/resolve.js +1 -44
  15. package/dist/billing.js +3 -76
  16. package/dist/capabilities.js +1 -75
  17. package/dist/cli.js +39 -211
  18. package/dist/diagnostics.js +2 -70
  19. package/dist/doctor.js +9 -188
  20. package/dist/errors.js +1 -33
  21. package/dist/exec.js +4 -47
  22. package/dist/handlers/gen-digital-human.js +33 -264
  23. package/dist/handlers/gen-image.js +9 -113
  24. package/dist/handlers/gen-post.js +15 -188
  25. package/dist/handlers/gen-video.js +7 -185
  26. package/dist/handlers/gen-voice.js +19 -133
  27. package/dist/handlers/index.js +1 -20
  28. package/dist/handlers/shared.js +1 -66
  29. package/dist/handlers/template-init.js +1 -21
  30. package/dist/http.js +3 -155
  31. package/dist/index.js +1 -8
  32. package/dist/install.js +40 -448
  33. package/dist/manifest.json +53 -13
  34. package/dist/progress.js +2 -15
  35. package/dist/project/commands.js +34 -156
  36. package/dist/project/host.js +1 -151
  37. package/dist/project/resolve.js +1 -65
  38. package/dist/project/store.js +2 -97
  39. package/dist/project/take.js +1 -106
  40. package/dist/project/trace.js +5 -278
  41. package/dist/registry.js +3 -70
  42. package/dist/runner.js +7 -225
  43. package/dist/skill-schema.js +1 -194
  44. package/dist/template/args.js +1 -69
  45. package/dist/template/check.js +2 -30
  46. package/dist/template/commands.js +34 -163
  47. package/dist/template/constants.js +1 -46
  48. package/dist/template/init.js +5 -133
  49. package/dist/template/list.js +1 -46
  50. package/dist/template/preview.js +7 -318
  51. package/dist/template/proc.js +1 -43
  52. package/dist/template/project.js +6 -161
  53. package/dist/template/publish.js +5 -185
  54. package/dist/template/render-client.js +2 -79
  55. package/dist/template/upgrade.js +4 -123
  56. package/dist/text.js +1 -15
  57. package/package.json +2 -35
  58. package/skills/doc-parse/scripts/extract_via_render.py +32 -32
  59. package/skills/export-jianying/scripts/gen_jianying_draft.py +188 -188
  60. package/skills/gen-post/SKILL.md +23 -13
  61. package/skills/gen-post/skill.json +9 -10
  62. package/skills/gen-script/SKILL.md +23 -1
  63. package/skills/gen-script/scripts/gen_script.py +644 -551
  64. package/skills/gen-script/skill.json +12 -0
  65. package/skills/gen-script/version.json +1 -1
  66. package/skills/prepare-video-assets/scripts/prepare_video_assets.py +21 -21
  67. package/skills/render-video/scripts/_chrome_vendor.py +17 -17
  68. package/skills/render-video/scripts/_digital_human_segments.py +34 -34
  69. package/skills/render-video/scripts/_video_probe.py +33 -33
  70. package/skills/render-video/scripts/_vod_polling.py +13 -13
  71. package/skills/render-video/scripts/remote_renderer_client.py +42 -42
  72. package/skills/render-video/scripts/render_video.py +639 -639
  73. package/skills/render-video/scripts/required_props.py +37 -37
  74. package/skills/render-video/scripts/upload_video.py +35 -35
  75. package/skills/render-video/version.json +1 -1
  76. package/skills/template-registry/SKILL.md +16 -0
  77. package/skills/template-registry/scripts/countdown_spec.py +92 -92
  78. package/skills/template-registry/scripts/doc_extract.py +40 -40
  79. package/skills/template-registry/scripts/list_templates.py +303 -191
  80. package/skills/template-registry/scripts/log_diagnostics.py +1 -1
  81. package/skills/template-registry/scripts/match_template.py +87 -87
  82. package/skills/template-registry/scripts/payload_hydrator.py +89 -89
  83. package/skills/template-registry/scripts/registry_loader.py +159 -159
  84. package/skills/template-registry/scripts/render_job_client.py +75 -75
  85. package/skills/template-registry/scripts/showcase_loader.py +44 -44
  86. package/skills/template-registry/skill.json +4 -1
  87. package/skills/template-registry/version.json +1 -1
  88. package/skills/template-registry/video_dsl/runtime/__init__.py +8 -8
  89. package/skills/template-registry/video_dsl/runtime/dsl_validator.py +329 -329
  90. package/skills/template-registry/video_dsl/runtime/narration_budget.py +36 -36
  91. package/skills/template-registry/video_dsl/runtime/payload_contract.py +7 -7
  92. package/skills/template-registry/video_dsl/runtime/prompt_enhancer.py +98 -98
  93. package/skills/template-registry/video_dsl/runtime/timeline_compiler.py +127 -127
  94. package/skills/template-registry/video_dsl/schema/video-dsl-v1alpha1.json +14 -1
  95. package/skills/video-parser/scripts/analyze_video.py +173 -173
  96. package/skills/video-parser/scripts/deconstruct_video.py +91 -91
  97. package/skills/video-parser/scripts/parse_via_render.py +21 -21
  98. package/skills/web-screenshot/scripts/_media_screenshot/__init__.py +4 -4
  99. package/skills/web-screenshot/scripts/_media_screenshot/bootstrap.py +7 -7
  100. package/skills/web-screenshot/scripts/_media_screenshot/browser.py +4 -4
  101. package/skills/web-screenshot/scripts/_media_screenshot/cli_args.py +4 -4
  102. package/skills/web-screenshot/scripts/_media_screenshot/js_loader.py +2 -2
  103. package/skills/web-screenshot/scripts/_media_screenshot/overlay.py +1 -1
  104. package/skills/web-screenshot/scripts/_media_screenshot/reader.py +40 -40
  105. package/skills/web-screenshot/scripts/_media_screenshot/recording.py +11 -11
  106. package/skills/web-screenshot/scripts/_media_screenshot/scenes/__init__.py +8 -8
  107. package/skills/web-screenshot/scripts/_media_screenshot/scenes/arrow.py +2 -2
  108. package/skills/web-screenshot/scripts/_media_screenshot/scenes/caption.py +1 -1
  109. package/skills/web-screenshot/scripts/_media_screenshot/scenes/click.py +1 -1
  110. package/skills/web-screenshot/scripts/_media_screenshot/scenes/code_lines.py +5 -5
  111. package/skills/web-screenshot/scripts/_media_screenshot/scenes/focus.py +1 -1
  112. package/skills/web-screenshot/scripts/_media_screenshot/scenes/highlight.py +3 -3
  113. package/skills/web-screenshot/scripts/_media_screenshot/scenes/hover.py +1 -1
  114. package/skills/web-screenshot/scripts/_media_screenshot/scenes/redact.py +1 -1
  115. package/skills/web-screenshot/scripts/_media_screenshot/scenes/scroll.py +1 -1
  116. package/skills/web-screenshot/scripts/_media_screenshot/scenes/sequence.py +1 -1
  117. package/skills/web-screenshot/scripts/_media_screenshot/scenes/title_card.py +1 -1
  118. package/skills/web-screenshot/scripts/_media_screenshot/scenes/type.py +1 -1
  119. package/skills/web-screenshot/scripts/_media_screenshot/scenes/wait.py +1 -1
  120. package/skills/web-screenshot/scripts/_media_screenshot/scenes/zoom.py +2 -2
  121. package/skills/web-screenshot/scripts/_media_screenshot/screenshot.py +15 -15
  122. package/skills/web-screenshot/scripts/_media_screenshot/storyboard.py +2 -2
  123. package/skills/web-screenshot/scripts/_media_screenshot/template.py +11 -11
  124. package/skills/web-screenshot/scripts/_media_screenshot/trim.py +10 -10
  125. package/skills/web-screenshot/scripts/_media_screenshot/urlguard.py +22 -22
  126. package/skills/web-screenshot/scripts/_media_upload.py +33 -33
  127. package/skills/web-screenshot/scripts/read_page.py +15 -15
  128. package/skills/web-screenshot/scripts/record.py +26 -26
  129. package/skills/web-screenshot/scripts/screenshot.py +5 -5
  130. package/dist/argv.d.ts +0 -15
  131. package/dist/auth/auth-lock.d.ts +0 -26
  132. package/dist/auth/auto-login.d.ts +0 -20
  133. package/dist/auth/commands.d.ts +0 -8
  134. package/dist/auth/credential-store.d.ts +0 -60
  135. package/dist/auth/device-flow-runner.d.ts +0 -58
  136. package/dist/auth/device-flow.d.ts +0 -52
  137. package/dist/auth/ensure.d.ts +0 -55
  138. package/dist/auth/environment.d.ts +0 -38
  139. package/dist/auth/pending-store.d.ts +0 -33
  140. package/dist/auth/resolve.d.ts +0 -30
  141. package/dist/billing.d.ts +0 -44
  142. package/dist/capabilities.d.ts +0 -63
  143. package/dist/cli.d.ts +0 -13
  144. package/dist/diagnostics.d.ts +0 -24
  145. package/dist/doctor.d.ts +0 -22
  146. package/dist/errors.d.ts +0 -29
  147. package/dist/exec.d.ts +0 -18
  148. package/dist/handlers/gen-digital-human.d.ts +0 -17
  149. package/dist/handlers/gen-image.d.ts +0 -20
  150. package/dist/handlers/gen-post.d.ts +0 -21
  151. package/dist/handlers/gen-video.d.ts +0 -19
  152. package/dist/handlers/gen-voice.d.ts +0 -26
  153. package/dist/handlers/index.d.ts +0 -20
  154. package/dist/handlers/shared.d.ts +0 -26
  155. package/dist/handlers/template-init.d.ts +0 -10
  156. package/dist/http.d.ts +0 -72
  157. package/dist/index.d.ts +0 -9
  158. package/dist/install.d.ts +0 -71
  159. package/dist/progress.d.ts +0 -23
  160. package/dist/project/commands.d.ts +0 -19
  161. package/dist/project/host.d.ts +0 -91
  162. package/dist/project/resolve.d.ts +0 -43
  163. package/dist/project/store.d.ts +0 -31
  164. package/dist/project/take.d.ts +0 -50
  165. package/dist/project/trace.d.ts +0 -98
  166. package/dist/registry.d.ts +0 -30
  167. package/dist/runner.d.ts +0 -39
  168. package/dist/skill-schema.d.ts +0 -138
  169. package/dist/template/args.d.ts +0 -25
  170. package/dist/template/check.d.ts +0 -23
  171. package/dist/template/commands.d.ts +0 -25
  172. package/dist/template/constants.d.ts +0 -39
  173. package/dist/template/init.d.ts +0 -28
  174. package/dist/template/list.d.ts +0 -23
  175. package/dist/template/preview.d.ts +0 -60
  176. package/dist/template/proc.d.ts +0 -26
  177. package/dist/template/project.d.ts +0 -47
  178. package/dist/template/publish.d.ts +0 -43
  179. package/dist/template/render-client.d.ts +0 -39
  180. package/dist/template/upgrade.d.ts +0 -44
  181. package/dist/text.d.ts +0 -8
@@ -1,21 +1,21 @@
1
1
  #!/usr/bin/env python3
2
- """
3
- 剪映草稿生成脚本 — 调用 ab-api POST /file/generateJianYing
4
- 通过 PrivToken(X-Priv-Token)认证。
5
2
 
6
- 将素材 URL(视频/图片/音频)+ 时长描述打包为剪映可导入的草稿 ZIP。
7
3
 
8
- 默认行为(非 --download):
9
- 生成完成后直接输出下载 URL,供调用方直接展示,无需落盘。
10
4
 
11
- 用法:
12
- python gen_jianying_draft.py --title "我的视频" --scenes '[{"videoUrl":"...","duration":5}]'
13
- python gen_jianying_draft.py --title "测试" --scenes scenes.json --download -o draft.zip
14
5
 
15
- 环境变量:
16
- MM_API_BASE_URL - 后端 API 地址(默认: https://api-agent.remixmate.ai/api)
17
- PRIV_TOKEN - PrivToken(优先读取;未配置时提示手动输入)
18
- """
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
19
 
20
20
  from __future__ import annotations
21
21
 
@@ -33,17 +33,17 @@ import uuid
33
33
  import zipfile
34
34
 
35
35
  API_BASE_URL = os.environ.get("MM_API_BASE_URL", "https://api-agent.remixmate.ai/api")
36
- PRIVATE_TOKEN = "" # 在 main() 中通过 resolve_token() 初始化
36
+ PRIVATE_TOKEN = ""
37
37
  SKILL_NAME = "export-jianying"
38
38
  AGENT_NAME = os.environ.get("AGENT_NAME", "")
39
39
 
40
- # 任务状态常量
40
+
41
41
  STATUS_PENDING = "pending"
42
42
  STATUS_PROCESSING = "processing"
43
43
  STATUS_COMPLETED = "completed"
44
44
  STATUS_FAILED = "failed"
45
45
 
46
- # textLayer 角色预设(fontSize/color/bold/position/shadow)
46
+
47
47
  ROLE_PRESETS = {
48
48
  "headline": {"fontSize": 15, "color": "#FFFFFF", "bold": True, "position": {"x": 0, "y": -0.35}, "shadow": True},
49
49
  "subheadline": {"fontSize": 8, "color": "#F0C040", "bold": False, "position": {"x": 0, "y": 0.38}, "shadow": False},
@@ -52,7 +52,7 @@ ROLE_PRESETS = {
52
52
  "caption": {"fontSize": 6, "color": "#CCCCCC", "bold": False, "position": {"x": 0, "y": 0.44}},
53
53
  }
54
54
 
55
- # Pill 字幕样式(匹配 Remotion SubtitleBar "pill" 风格)
55
+
56
56
  PILL_SUBTITLE_STYLE = {
57
57
  "font_size": 8.0,
58
58
  "background_style": 1,
@@ -73,19 +73,19 @@ PILL_SUBTITLE_STYLE = {
73
73
 
74
74
  PILL_SUBTITLE_POSITION_Y = -0.458
75
75
 
76
- # ---------------------------------------------------------------------------
77
- # 剪映草稿根目录解析
78
- # ---------------------------------------------------------------------------
79
76
 
80
- # 剪映把 draftRootPath 原样写进 draft_meta_info.json(draft_root_path / draft_fold_path),
81
- # 所以它必须是**用户电脑上**的绝对路径。
82
- #
83
- # 2026-09-29 事故:agent 在服务器上跑本脚本,mac 预设 `~/...` 被 expanduser 成
84
- # `/root/Movies/...` 写进了草稿,用户 Mac 上根本没有这个目录。`~` / `%LOCALAPPDATA%`
85
- # 只能在「脚本就跑在那台目标电脑上」时展开,其余情况必须由调用方显式给出路径。
77
+
78
+
79
+
80
+
81
+
82
+
83
+
84
+
85
+
86
86
  DRAFT_ROOT_SUFFIX = ("JianyingPro", "User Data", "Projects", "com.lveditor.draft")
87
87
 
88
- # 报错时给调用方看的路径样例(<username> 要换成用户电脑上的真实用户名)。
88
+
89
89
  DRAFT_ROOT_TEMPLATES = {
90
90
  "mac": "/Users/<username>/Movies/JianyingPro/User Data/Projects/com.lveditor.draft",
91
91
  "windows": "C:/Users/<username>/AppData/Local/JianyingPro/User Data/Projects/com.lveditor.draft",
@@ -95,7 +95,7 @@ _WINDOWS_ABS_PATH = re.compile(r"^[A-Za-z]:[\\/]")
95
95
 
96
96
 
97
97
  def draft_root_path_error(path: str) -> str:
98
- """校验显式给出的草稿根目录;合法返回空串,否则返回原因。"""
98
+
99
99
  if "<" in path or ">" in path:
100
100
  return "still contains a placeholder such as <username>; replace it with the real value"
101
101
  if path.startswith("~"):
@@ -108,7 +108,7 @@ def draft_root_path_error(path: str) -> str:
108
108
 
109
109
 
110
110
  def _local_draft_root(system: str) -> str:
111
- """脚本恰好跑在目标系统上时,按本机环境推出草稿根目录;推不出返回空串。"""
111
+
112
112
  if system == "mac" and sys.platform == "darwin":
113
113
  return os.path.join(os.path.expanduser("~/Movies"), *DRAFT_ROOT_SUFFIX)
114
114
  if system == "windows" and os.name == "nt":
@@ -119,13 +119,13 @@ def _local_draft_root(system: str) -> str:
119
119
 
120
120
 
121
121
  def resolve_draft_root_path(explicit: str, system: str) -> str:
122
- """
123
- 解析剪映草稿根目录,按优先级:
124
- 1. 显式 --draft-root-path:原样使用(不做 expanduser),但必须是绝对路径;
125
- 2. 未给路径且脚本就跑在 --system 指定的系统上:按本机 HOME / LOCALAPPDATA 推出;
126
- 3. 其余情况(典型是服务端 agent):报错,要求调用方向用户要路径。
127
- 返回值始终作为 draftRootPath 传给 API。
128
- """
122
+
123
+
124
+
125
+
126
+
127
+
128
+
129
129
  if system not in DRAFT_ROOT_TEMPLATES:
130
130
  print(f"❌ unsupported --system value: {system} (supported: mac / windows)", file=sys.stderr)
131
131
  sys.exit(1)
@@ -133,7 +133,7 @@ def resolve_draft_root_path(explicit: str, system: str) -> str:
133
133
 
134
134
  if explicit:
135
135
  path = explicit.strip()
136
- # 去掉末尾分隔符,但保留 "C:/" 与 "/" 这种根本身。
136
+
137
137
  while len(path) > 3 and path[-1] in "/\\":
138
138
  path = path[:-1]
139
139
  reason = draft_root_path_error(path)
@@ -161,7 +161,7 @@ def resolve_draft_root_path(explicit: str, system: str) -> str:
161
161
 
162
162
 
163
163
  def resolve_token() -> str:
164
- """优先从 PRIV_TOKEN 环境变量读取 token,取不到则报错退出"""
164
+
165
165
  token = os.environ.get("PRIV_TOKEN", "")
166
166
  if not token:
167
167
  if sys.stdin.isatty():
@@ -173,12 +173,12 @@ def resolve_token() -> str:
173
173
 
174
174
 
175
175
  def gen_jianying_uuid() -> str:
176
- """生成剪映风格的大写 UUID"""
176
+
177
177
  return str(uuid.uuid4()).upper()
178
178
 
179
179
 
180
180
  def hex_to_rgb_float(hex_color: str) -> list:
181
- """'#RRGGBB' → [r, g, b](0.0~1.0)"""
181
+
182
182
  hex_color = hex_color.lstrip("#")
183
183
  r = int(hex_color[0:2], 16) / 255.0
184
184
  g = int(hex_color[2:4], 16) / 255.0
@@ -187,7 +187,7 @@ def hex_to_rgb_float(hex_color: str) -> list:
187
187
 
188
188
 
189
189
  def build_text_content_json(text: str, style: dict) -> str:
190
- """构建剪映 text material 的 content JSON 字符串(双重编码格式)"""
190
+
191
191
  rgb = hex_to_rgb_float(style.get("color", "#FFFFFF"))
192
192
  font_size = style.get("fontSize", 8)
193
193
  char_count = len(text)
@@ -212,7 +212,7 @@ def build_text_content_json(text: str, style: dict) -> str:
212
212
 
213
213
 
214
214
  def _build_headers(content_type: str = "application/json") -> dict:
215
- """构建统一请求头(X-Priv-Token 认证头)"""
215
+
216
216
  headers = {
217
217
  "X-Priv-Token": PRIVATE_TOKEN,
218
218
  "x-invoke-skill": SKILL_NAME,
@@ -225,10 +225,10 @@ def _build_headers(content_type: str = "application/json") -> dict:
225
225
 
226
226
 
227
227
  def api_request(path: str, payload: dict, *, exit_on_error: bool = True) -> dict:
228
- """
229
- 通用 API 请求函数(统一 POST 方法)
230
- exit_on_error=False 时网络/连接错误会抛出异常而非直接退出(供轮询重试使用)
231
- """
228
+
229
+
230
+
231
+
232
232
  if not PRIVATE_TOKEN:
233
233
  print("❌ PrivToken not configured: set the PRIV_TOKEN env var", file=sys.stderr)
234
234
  sys.exit(1)
@@ -254,10 +254,10 @@ def api_request(path: str, payload: dict, *, exit_on_error: bool = True) -> dict
254
254
 
255
255
 
256
256
  def build_text_material(material_id: str, text: str, style: dict) -> dict:
257
- """
258
- 构建完整的剪映 text material dict(对应 materials.texts[] 中一项)。
259
- style 包含 fontSize / color / bold / shadow / backgroundColor 等。
260
- """
257
+
258
+
259
+
260
+
261
261
  content_json = build_text_content_json(text, style)
262
262
  font_size = style.get("fontSize", 8)
263
263
  color_hex = style.get("color", "#FFFFFF")
@@ -374,10 +374,10 @@ def build_text_segment(
374
374
  duration_us: int,
375
375
  position: dict,
376
376
  ) -> dict:
377
- """
378
- 构建完整的剪映 text track segment dict。
379
- position: {"x": float, "y": float} 归一化坐标。
380
- """
377
+
378
+
379
+
380
+
381
381
  return {
382
382
  "caption_info": None,
383
383
  "check_flag": 7,
@@ -432,13 +432,13 @@ def build_text_segment(
432
432
 
433
433
 
434
434
  def convert_render_plan_to_scenes(render_plan: dict) -> tuple:
435
- """
436
- 将 RenderPlan JSON 转换为剪映草稿的 scenes 列表。
437
- 返回 (scenes, width, height)。
438
435
 
439
- 从 render-plan.json 中提取每个 scene 的素材 URL、时长、字幕文本,
440
- 自动映射为 gen_jianying_draft 所需的 scenes JSON 格式。
441
- """
436
+
437
+
438
+
439
+
440
+
441
+
442
442
  timeline = render_plan.get("timeline", [])
443
443
  resolved_assets = {a["assetId"]: a for a in render_plan.get("resolvedAssets", [])}
444
444
  render_config = render_plan.get("renderConfig", {})
@@ -453,7 +453,7 @@ def convert_render_plan_to_scenes(render_plan: dict) -> tuple:
453
453
  duration_sec = round(duration_frames / fps, 2) if fps else 5.0
454
454
  scene["duration"] = duration_sec
455
455
 
456
- # 提取视觉素材(图片或视频)
456
+
457
457
  for layer in entry.get("layers", []):
458
458
  if layer.get("type") == "visual":
459
459
  asset = resolved_assets.get(layer.get("assetId", ""))
@@ -465,7 +465,7 @@ def convert_render_plan_to_scenes(render_plan: dict) -> tuple:
465
465
  scene["imageUrl"] = asset["url"]
466
466
  break
467
467
 
468
- # 提取音频素材(TTS 旁白)
468
+
469
469
  for layer in entry.get("layers", []):
470
470
  if layer.get("type") == "audio":
471
471
  asset = resolved_assets.get(layer.get("assetId", ""))
@@ -475,14 +475,14 @@ def convert_render_plan_to_scenes(render_plan: dict) -> tuple:
475
475
  scene["audioDuration"] = round(asset["duration"] / 1000.0, 2)
476
476
  break
477
477
 
478
- # 提取字幕文本(从 subtitleSegments 拼接)
478
+
479
479
  subtitle_segments = entry.get("subtitleSegments", [])
480
480
  if subtitle_segments:
481
481
  subtitle_text = " ".join(seg.get("text", "") for seg in subtitle_segments if seg.get("text"))
482
482
  if subtitle_text.strip():
483
483
  scene["subtitleText"] = subtitle_text.strip()
484
484
 
485
- # 提取 textLayers(从 props 中的 textLayers 或 scene 级别的文本信息)
485
+
486
486
  props = entry.get("props", {})
487
487
  title_text = props.get("titleText", "")
488
488
  if title_text:
@@ -491,18 +491,18 @@ def convert_render_plan_to_scenes(render_plan: dict) -> tuple:
491
491
  "content": title_text,
492
492
  "role": "headline",
493
493
  })
494
- # 如果有 subtitleText 也加入
494
+
495
495
  if scene.get("subtitleText"):
496
496
  text_layers.append({
497
497
  "content": scene["subtitleText"],
498
498
  "role": "subheadline",
499
499
  })
500
- del scene["subtitleText"] # 避免重复
500
+ del scene["subtitleText"]
501
501
  scene["textLayers"] = text_layers
502
502
 
503
- # 如果没有视觉素材,跳过该场景(剪映需要每个场景有 videoUrl 或 imageUrl)
503
+
504
504
  if not scene.get("videoUrl") and not scene.get("imageUrl"):
505
- # 尝试用占位图
505
+
506
506
  print(f" ⚠️ scene {entry.get('sceneId', '?')} has no visual asset, skipping", file=sys.stderr)
507
507
  continue
508
508
 
@@ -515,18 +515,18 @@ def convert_render_plan_to_scenes(render_plan: dict) -> tuple:
515
515
  return scenes, width, height
516
516
 
517
517
 
518
- # --scenes 里认的字段。后端 JianYingSceneInput 只收前 8 个,textLayers 由本脚本后处理消费。
518
+
519
519
  SCENE_FIELDS = (
520
520
  "videoUrl", "imageUrl", "duration", "width", "height",
521
521
  "audioUrl", "audioDuration", "subtitleText", "textLayers",
522
522
  )
523
- # 纯标识、不影响草稿内容的字段:写了也无害,放行。
523
+
524
524
  SCENE_IGNORED_FIELDS = ("id", "sceneId")
525
- # 常见误写 → 正确字段名。
526
- #
527
- # 2026-09-29 线上会话:模型先写 image/audio 被拦,改成 imageUrl/audioUrl 后却把旁白
528
- # 写成了 text —— 这个字段以前被静默丢掉,草稿里没有字幕,模型还告诉用户「已包含字幕」。
529
- # 未知字段一律报错,比静默丢值强。
525
+
526
+
527
+
528
+
529
+
530
530
  SCENE_FIELD_ALIASES = {
531
531
  "image": "imageUrl", "img": "imageUrl", "imageURL": "imageUrl", "image_url": "imageUrl",
532
532
  "video": "videoUrl", "videoURL": "videoUrl", "video_url": "videoUrl",
@@ -538,7 +538,7 @@ SCENE_FIELD_ALIASES = {
538
538
 
539
539
 
540
540
  def unknown_field_error(index: int, scene: dict) -> None:
541
- """scene 里出现不认识的字段就退出,并指出该用哪个字段名。"""
541
+
542
542
  unknown = [k for k in scene if k not in SCENE_FIELDS and k not in SCENE_IGNORED_FIELDS]
543
543
  if not unknown:
544
544
  return
@@ -552,8 +552,8 @@ def unknown_field_error(index: int, scene: dict) -> None:
552
552
 
553
553
 
554
554
  def parse_scenes(scenes_arg: str) -> list:
555
- """解析 --scenes 参数:支持 inline JSON 字符串或文件路径"""
556
- # 尝试作为文件路径读取
555
+
556
+
557
557
  if os.path.isfile(scenes_arg):
558
558
  with open(scenes_arg, "r", encoding="utf-8") as f:
559
559
  content = f.read()
@@ -563,7 +563,7 @@ def parse_scenes(scenes_arg: str) -> list:
563
563
  print(f"❌ failed to parse scenes JSON file: {e}", file=sys.stderr)
564
564
  sys.exit(1)
565
565
  else:
566
- # 尝试作为 inline JSON 解析
566
+
567
567
  try:
568
568
  scenes = json.loads(scenes_arg)
569
569
  except json.JSONDecodeError as e:
@@ -579,7 +579,7 @@ def parse_scenes(scenes_arg: str) -> list:
579
579
  print("❌ scenes list cannot be empty", file=sys.stderr)
580
580
  sys.exit(1)
581
581
 
582
- # 基本校验
582
+
583
583
  for i, scene in enumerate(scenes):
584
584
  if not isinstance(scene, dict):
585
585
  print(f"❌ scene {i + 1} is malformed: must be an object", file=sys.stderr)
@@ -592,7 +592,7 @@ def parse_scenes(scenes_arg: str) -> list:
592
592
  print(f"❌ scene {i + 1} is missing a valid duration (must be > 0)", file=sys.stderr)
593
593
  sys.exit(1)
594
594
 
595
- # textLayers 校验
595
+
596
596
  text_layers = scene.get("textLayers")
597
597
  if text_layers is not None:
598
598
  if not isinstance(text_layers, list):
@@ -617,7 +617,7 @@ def create_jianying_task(
617
617
  height: int = 1920,
618
618
  draft_name: str = "",
619
619
  ) -> str:
620
- """创建剪映草稿生成任务,返回 taskId"""
620
+
621
621
  print("📦 Submitting Jianying draft generation task...")
622
622
  print(f" title: {title}")
623
623
  print(f" scenes: {len(scenes)}")
@@ -638,7 +638,7 @@ def create_jianying_task(
638
638
 
639
639
  result = api_request("/file/generateJianYing", payload)
640
640
 
641
- # 检查业务状态码
641
+
642
642
  if result.get("code") != 0:
643
643
  msg = result.get("msg", "未知错误")
644
644
  print(f"❌ API returned an error: {msg}", file=sys.stderr)
@@ -655,10 +655,10 @@ def create_jianying_task(
655
655
 
656
656
 
657
657
  def poll_task_status(task_id: str, poll_interval: int = 5, max_wait: int = 300) -> dict:
658
- """
659
- 轮询剪映草稿生成状态,返回最终结果。
660
- 网络瞬断时自动重试(最多连续 5 次),不会因单次网络抖动而中断。
661
- """
658
+
659
+
660
+
661
+
662
662
  print(f"\n⏳ Waiting for draft generation (up to {max_wait} s)...")
663
663
 
664
664
  start_time = time.time()
@@ -684,10 +684,10 @@ def poll_task_status(task_id: str, poll_interval: int = 5, max_wait: int = 300)
684
684
  time.sleep(poll_interval)
685
685
  continue
686
686
 
687
- # 请求成功,重置连续错误计数
687
+
688
688
  consecutive_errors = 0
689
689
 
690
- # 检查业务状态码
690
+
691
691
  if result.get("code") != 0:
692
692
  msg = result.get("msg", "未知错误")
693
693
  print(f"\n❌ status query failed: {msg}", file=sys.stderr)
@@ -696,7 +696,7 @@ def poll_task_status(task_id: str, poll_interval: int = 5, max_wait: int = 300)
696
696
  result_data = result.get("data") or {}
697
697
  status = result_data.get("status", "unknown")
698
698
 
699
- # 打印状态
699
+
700
700
  status_icons = {
701
701
  STATUS_PENDING: "⏳",
702
702
  STATUS_PROCESSING: "🔄",
@@ -707,7 +707,7 @@ def poll_task_status(task_id: str, poll_interval: int = 5, max_wait: int = 300)
707
707
  print(f"\r {icon} status: {status} | elapsed: {elapsed:.0f}s", end="", flush=True)
708
708
 
709
709
  if status == STATUS_COMPLETED:
710
- print() # 换行
710
+ print()
711
711
  return result_data
712
712
 
713
713
  if status == STATUS_FAILED:
@@ -720,32 +720,32 @@ def poll_task_status(task_id: str, poll_interval: int = 5, max_wait: int = 300)
720
720
 
721
721
 
722
722
  def _encode_url(raw_url: str) -> str:
723
- """对 URL 中的非 ASCII 字符做 percent-encoding(保留已编码部分和合法字符)"""
723
+
724
724
  parsed = urllib.parse.urlparse(raw_url)
725
- # '%' 必须留在 safe 里:ab-api 返回的地址已经是编码过的(中文文件名 → %E9%A3…),
726
- # 再编一次会变成 %25E9,对象 key 对不上;带签名的地址还会 SignatureDoesNotMatch。
725
+
726
+
727
727
  encoded_path = urllib.parse.quote(parsed.path, safe="/:@!$&'()*+,;=-._~%")
728
728
  return urllib.parse.urlunparse(parsed._replace(path=encoded_path))
729
729
 
730
730
 
731
731
  class DraftUploadError(RuntimeError):
732
- """后处理 ZIP 传回失败。调用方据此降级为交付后端原始 ZIP,而不是整次导出失败。"""
732
+ pass
733
733
 
734
734
 
735
735
  def upload_draft_zip(zip_path: str, file_name: str) -> str:
736
- """
737
- 把后处理过的 ZIP 传回 ab-api(POST /file/uploadJianYingDraft),返回带签名的下载地址。
738
- 失败抛 DraftUploadError(带 HTTP 状态码 / 业务 msg)。
739
-
740
- 为什么必须传回:后处理(字幕样式 / textLayers)是在跑脚本的这台机器上做的。
741
- 服务端 agent 的本地文件用户拿不到,不传回的话用户下载到的仍是后端生成的原始 ZIP,
742
- 后处理等于白做。
743
- """
736
+
737
+
738
+
739
+
740
+
741
+
742
+
743
+
744
744
  boundary = "----RemixmateJianYing" + uuid.uuid4().hex
745
745
  with open(zip_path, "rb") as f:
746
746
  zip_data = f.read()
747
- # filename 里放 ASCII 占位,真实(可能含中文的)文件名走独立的 fileName 字段,
748
- # 避免 multipart 头部编码在不同服务端实现间的差异。
747
+
748
+
749
749
  parts = [
750
750
  f"--{boundary}".encode(),
751
751
  b'Content-Disposition: form-data; name="fileName"',
@@ -772,7 +772,7 @@ def upload_draft_zip(zip_path: str, file_name: str) -> str:
772
772
  with urllib.request.urlopen(req, timeout=600) as resp:
773
773
  raw = resp.read().decode("utf-8", errors="replace")
774
774
  except urllib.error.HTTPError as e:
775
- # 网关(413 请求体过大、404 路由未上线)返回的是 HTML,只取一小段帮定位。
775
+
776
776
  snippet = e.read().decode("utf-8", errors="replace")[:200].replace("\n", " ")
777
777
  raise DraftUploadError(f"HTTP {e.code} {e.reason}: {snippet}") from e
778
778
  except (urllib.error.URLError, OSError, ConnectionError) as e:
@@ -792,9 +792,9 @@ def upload_draft_zip(zip_path: str, file_name: str) -> str:
792
792
 
793
793
 
794
794
  def download_zip(download_url: str, output_path: str) -> None:
795
- """下载 ZIP 文件到本地"""
796
- # 不打印地址:去掉签名的地址打开就是 403,而模型会把 stdout 里任何像链接的东西
797
- # 转交给用户(conv_U9oYmHmE1iJbx2qETDuh 就是把这一行当成了下载链接)。
795
+
796
+
797
+
798
798
  print("\n⬇️ Downloading draft ZIP for post-processing...")
799
799
  safe_url = _encode_url(download_url)
800
800
  try:
@@ -812,11 +812,11 @@ def download_zip(download_url: str, output_path: str) -> None:
812
812
 
813
813
 
814
814
  def _resolve_layer_style(layer: dict) -> dict:
815
- """将 textLayer 字段与角色预设合并,返回最终有效样式"""
815
+
816
816
  role = layer.get("role", "subheadline")
817
817
  preset = ROLE_PRESETS.get(role, ROLE_PRESETS["subheadline"])
818
- style = dict(preset) # 浅拷贝预设
819
- # 用户显式字段覆盖预设
818
+ style = dict(preset)
819
+
820
820
  for key in ("fontSize", "color", "bold", "shadow", "backgroundColor"):
821
821
  if key in layer:
822
822
  style[key] = layer[key]
@@ -826,11 +826,11 @@ def _resolve_layer_style(layer: dict) -> dict:
826
826
 
827
827
 
828
828
  def apply_text_layers(draft: dict, scenes: list) -> None:
829
- """
830
- 核心后处理:遍历场景 textLayers,修改/新增 draft 中的文本素材与轨道片段。
831
- 直接原地修改 draft dict。
832
- """
833
- # 1. 找到 video track,提取每个片段的时间范围
829
+
830
+
831
+
832
+
833
+
834
834
  video_track = None
835
835
  for track in draft.get("tracks", []):
836
836
  if track.get("type") == "video":
@@ -848,7 +848,7 @@ def apply_text_layers(draft: dict, scenes: list) -> None:
848
848
  file=sys.stderr,
849
849
  )
850
850
 
851
- # 2. 找到 text track,不存在则创建
851
+
852
852
  text_track = None
853
853
  for track in draft.get("tracks", []):
854
854
  if track.get("type") == "text":
@@ -869,21 +869,21 @@ def apply_text_layers(draft: dict, scenes: list) -> None:
869
869
  text_segments = text_track.get("segments", [])
870
870
  texts_materials = draft.get("materials", {}).get("texts", [])
871
871
 
872
- # 建立 target_timerange.start → text_segment 索引映射
872
+
873
873
  seg_by_start: dict[int, int] = {}
874
874
  for idx, seg in enumerate(text_segments):
875
875
  start = seg.get("target_timerange", {}).get("start", -1)
876
876
  seg_by_start[start] = idx
877
877
 
878
- # 建立 material_id → texts 索引映射
878
+
879
879
  mat_by_id: dict[str, int] = {}
880
880
  for idx, mat in enumerate(texts_materials):
881
881
  mat_by_id[mat["id"]] = idx
882
882
 
883
- # 额外的 text track 列表(用于放置第 2+ 个 textLayer 的片段)
883
+
884
884
  extra_text_tracks: list[dict] = []
885
885
 
886
- # 3. 遍历每个场景
886
+
887
887
  match_count = min(len(video_segments), len(scenes))
888
888
  for i in range(match_count):
889
889
  scene = scenes[i]
@@ -903,12 +903,12 @@ def apply_text_layers(draft: dict, scenes: list) -> None:
903
903
  position = style.get("position", {"x": 0, "y": 0})
904
904
 
905
905
  if layer_idx == 0 and start_us in seg_by_start:
906
- # 替换已有文本素材
906
+
907
907
  seg_idx = seg_by_start[start_us]
908
908
  existing_seg = text_segments[seg_idx]
909
909
  mat_id = existing_seg.get("material_id", "")
910
910
 
911
- # 更新素材
911
+
912
912
  if mat_id in mat_by_id:
913
913
  mat = texts_materials[mat_by_id[mat_id]]
914
914
  mat["content"] = build_text_content_json(content, style)
@@ -920,11 +920,11 @@ def apply_text_layers(draft: dict, scenes: list) -> None:
920
920
  mat["background_color"] = bg_color
921
921
  mat["background_style"] = 1 if bg_color else 0
922
922
 
923
- # 更新片段位置
923
+
924
924
  existing_seg["clip"]["transform"]["x"] = position.get("x", 0)
925
925
  existing_seg["clip"]["transform"]["y"] = position.get("y", 0)
926
926
  else:
927
- # 新增素材 + 片段
927
+
928
928
  new_mat_id = gen_jianying_uuid()
929
929
  new_seg_id = gen_jianying_uuid()
930
930
 
@@ -936,7 +936,7 @@ def apply_text_layers(draft: dict, scenes: list) -> None:
936
936
  new_seg_id, new_mat_id, start_us, duration_us, position
937
937
  )
938
938
 
939
- # 第 2+ 个 textLayer 放入额外 track(每个 layer_idx 一条 track)
939
+
940
940
  extra_idx = layer_idx - 1 if (layer_idx > 0 and start_us in seg_by_start) else layer_idx
941
941
  while extra_idx >= len(extra_text_tracks):
942
942
  new_track = {
@@ -951,30 +951,30 @@ def apply_text_layers(draft: dict, scenes: list) -> None:
951
951
  extra_text_tracks.append(new_track)
952
952
  extra_text_tracks[extra_idx]["segments"].append(new_segment)
953
953
 
954
- # 将额外 text tracks 追加到 draft
954
+
955
955
  for et in extra_text_tracks:
956
956
  if et["segments"]:
957
957
  draft["tracks"].append(et)
958
958
 
959
- # 更新 materials.texts 引用(因为可能通过 append 改变了列表内容)
959
+
960
960
  draft["materials"]["texts"] = texts_materials
961
961
 
962
962
  print(f" ✅ textLayers post-processing finished")
963
963
 
964
964
 
965
965
  def apply_subtitle_pill_style(draft: dict) -> None:
966
- """
967
- 将 Pill 字幕样式(匹配 Remotion SubtitleBar "pill" 风格)应用到 draft 中的
968
- 所有 text materials 和 text segments。直接原地修改 draft dict。
969
- """
966
+
967
+
968
+
969
+
970
970
  texts_materials = draft.get("materials", {}).get("texts", []) or []
971
971
 
972
- # 1. 覆盖每个 text material 的样式属性
972
+
973
973
  for mat in texts_materials:
974
974
  for key, value in PILL_SUBTITLE_STYLE.items():
975
975
  mat[key] = value
976
976
 
977
- # 更新 material 内嵌 content JSON 的 styles[0].size
977
+
978
978
  content_raw = mat.get("content", "")
979
979
  if content_raw:
980
980
  try:
@@ -984,10 +984,10 @@ def apply_subtitle_pill_style(draft: dict) -> None:
984
984
  styles[0]["size"] = PILL_SUBTITLE_STYLE["font_size"]
985
985
  mat["content"] = json.dumps(content_obj, ensure_ascii=False)
986
986
  except (json.JSONDecodeError, TypeError):
987
- # 若 content 非 JSON(极少数情况),跳过内嵌更新
987
+
988
988
  pass
989
989
 
990
- # 2. 将每个 text segment 的 clip.transform.y 设为 PILL_SUBTITLE_POSITION_Y
990
+
991
991
  for track in draft.get("tracks", []):
992
992
  if track.get("type") != "text":
993
993
  continue
@@ -1000,16 +1000,16 @@ def apply_subtitle_pill_style(draft: dict) -> None:
1000
1000
 
1001
1001
 
1002
1002
  def postprocess_draft_zip(zip_path: str, scenes: list) -> None:
1003
- """
1004
- 后处理剪映草稿 ZIP:读取 draft_content.json,修改文本层,重新打包。
1005
- 同时处理 draft_content.json 和 draft_info.json(两者内容相同)。
1006
- """
1003
+
1004
+
1005
+
1006
+
1007
1007
  print(f"\n🔧 Post-processing draft ZIP (applying textLayers)...")
1008
1008
 
1009
1009
  tmp_path = zip_path + ".tmp"
1010
1010
 
1011
1011
  with zipfile.ZipFile(zip_path, "r") as zin:
1012
- # 读取 draft_content.json
1012
+
1013
1013
  draft_json_name = None
1014
1014
  draft_info_name = None
1015
1015
  for name in zin.namelist():
@@ -1024,71 +1024,71 @@ def postprocess_draft_zip(zip_path: str, scenes: list) -> None:
1024
1024
 
1025
1025
  draft_data = json.loads(zin.read(draft_json_name).decode("utf-8"))
1026
1026
 
1027
- # 应用 textLayers 修改
1027
+
1028
1028
  apply_text_layers(draft_data, scenes)
1029
1029
 
1030
- # 应用 Pill 字幕样式(匹配 Remotion SubtitleBar)
1030
+
1031
1031
  apply_subtitle_pill_style(draft_data)
1032
1032
 
1033
1033
  modified_json = json.dumps(draft_data, ensure_ascii=False, indent=4).encode("utf-8")
1034
1034
 
1035
- # 写入新 ZIP
1035
+
1036
1036
  with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zout:
1037
1037
  for item in zin.infolist():
1038
1038
  if item.filename == draft_json_name:
1039
1039
  zout.writestr(item, modified_json)
1040
1040
  elif item.filename == draft_info_name:
1041
- # draft_info.json 内容同 draft_content.json
1041
+
1042
1042
  zout.writestr(item, modified_json)
1043
1043
  else:
1044
1044
  zout.writestr(item, zin.read(item.filename))
1045
1045
 
1046
- # 原子替换
1046
+
1047
1047
  os.replace(tmp_path, zip_path)
1048
1048
  print(f" ✅ draft ZIP post-processing finished: {zip_path}")
1049
1049
 
1050
1050
 
1051
1051
  def prepare_api_scenes(scenes: list) -> list:
1052
- """
1053
- 为 API 调用准备场景列表:剥离 textLayers,自动派生 subtitleText。
1054
- 返回新列表(不修改原始 scenes)。
1055
- """
1052
+
1053
+
1054
+
1055
+
1056
1056
  api_scenes = []
1057
1057
  for scene in scenes:
1058
1058
  api_scene = {k: v for k, v in scene.items() if k != "textLayers"}
1059
1059
  text_layers = scene.get("textLayers")
1060
1060
  if text_layers and not api_scene.get("subtitleText"):
1061
- # 从第一个 textLayer 派生 subtitleText
1061
+
1062
1062
  api_scene["subtitleText"] = text_layers[0].get("content", "")
1063
1063
  api_scenes.append(api_scene)
1064
1064
  return api_scenes
1065
1065
 
1066
1066
 
1067
1067
  def has_text_layers(scenes: list) -> bool:
1068
- """检查场景列表中是否有任何场景包含 textLayers"""
1068
+
1069
1069
  return any(scene.get("textLayers") for scene in scenes)
1070
1070
 
1071
1071
 
1072
1072
  def _render_job_client():
1073
- """
1074
- 导入共享的 render_job_client(在 skills/template-registry/scripts/,跨 skill 共享
1075
- Python 的约定位置,render-video 也是这么引的)。
1076
- """
1073
+
1074
+
1075
+
1076
+
1077
1077
  shared = os.path.join(os.path.dirname(__file__), "..", "..", "template-registry", "scripts")
1078
1078
  if shared not in sys.path:
1079
1079
  sys.path.insert(0, shared)
1080
- import render_job_client # noqa: PLC0415 — 路径注入之后才能导入
1080
+ import render_job_client
1081
1081
 
1082
1082
  return render_job_client
1083
1083
 
1084
1084
 
1085
1085
  def print_draft_summary(scenes: list, width: int, height: int, draft_root_path: str) -> None:
1086
- """
1087
- 打印草稿里**实际**装了什么。
1088
1086
 
1089
- 调用方(agent)要据此向用户转述,而不是凭自己传了什么去猜——线上会话里模型把
1090
- 被丢掉的字幕也说成「已包含」。
1091
- """
1087
+
1088
+
1089
+
1090
+
1091
+
1092
1092
  total = len(scenes)
1093
1093
  video = sum(1 for s in scenes if s.get("videoUrl"))
1094
1094
  image = sum(1 for s in scenes if s.get("imageUrl") and not s.get("videoUrl"))
@@ -1175,7 +1175,7 @@ Scene fields:
1175
1175
 
1176
1176
  args = parser.parse_args()
1177
1177
 
1178
- # 初始化 token
1178
+
1179
1179
  global PRIVATE_TOKEN
1180
1180
  if args.priv_token:
1181
1181
  PRIVATE_TOKEN = args.priv_token
@@ -1188,8 +1188,8 @@ Scene fields:
1188
1188
 
1189
1189
  conversation_id = os.environ.get("CONVERSATION_ID", "").strip()
1190
1190
 
1191
- # 没给任何素材来源、但处在一个会话里(ab-agent 会注入 CONVERSATION_ID):
1192
- # 默认导出这个会话最近一版已准备好素材的任务。
1191
+
1192
+
1193
1193
  if not (args.from_job_id or args.from_render_plan or args.scenes) and conversation_id:
1194
1194
  rjc = _render_job_client()
1195
1195
  try:
@@ -1204,11 +1204,11 @@ Scene fields:
1204
1204
  f"jobId={args.from_job_id} (attempt {latest.get('attempt', '?')}, updated {latest.get('updateTime', '?')})"
1205
1205
  )
1206
1206
  elif args.scenes and conversation_id:
1207
- # 手写 scenes 时模型只能从聊天记录里拼素材 URL,而多轮之间它看不到之前的工具结果,
1208
- # 很容易拼出过期的旧版素材(线上会话里就是把用户改过的 v3 配音换回了 v1)。
1209
- #
1210
- # 只在会话里**真有**准备好的任务时才提醒:纯数字人 / 纯生图的会话没有 RenderPlan,
1211
- # 手写 scenes 是唯一的路,这时提醒只会让模型怀疑自己(conv_U9oYmHmE1iJbx2qETDuh)。
1207
+
1208
+
1209
+
1210
+
1211
+
1212
1212
  try:
1213
1213
  latest = _render_job_client().latest_job(conversation_id, PRIVATE_TOKEN)
1214
1214
  except RuntimeError:
@@ -1220,7 +1220,7 @@ Scene fields:
1220
1220
  file=sys.stderr,
1221
1221
  )
1222
1222
 
1223
- # --from-job-id 模式:从数据库加载 RenderPlan
1223
+
1224
1224
  if args.from_job_id:
1225
1225
  print(f"📋 loading RenderPlan from database (jobId={args.from_job_id})...")
1226
1226
  rjc = _render_job_client()
@@ -1238,7 +1238,7 @@ Scene fields:
1238
1238
  dsl_meta = render_plan.get("dslMeta", {})
1239
1239
  args.title = dsl_meta.get("title", "") or "RenderPlan 草稿"
1240
1240
  print(f" ✅ converted {len(scenes)} scene(s), canvas {args.width}x{args.height}")
1241
- # --from-render-plan 模式:从 RenderPlan 自动转换
1241
+
1242
1242
  elif args.from_render_plan:
1243
1243
  if not os.path.isfile(args.from_render_plan):
1244
1244
  print(f"❌ RenderPlan file does not exist: {args.from_render_plan}", file=sys.stderr)
@@ -1247,17 +1247,17 @@ Scene fields:
1247
1247
  with open(args.from_render_plan, "r", encoding="utf-8") as f:
1248
1248
  render_plan = json.load(f)
1249
1249
  scenes, rp_width, rp_height = convert_render_plan_to_scenes(render_plan)
1250
- # 使用 RenderPlan 中的画布尺寸(除非用户显式指定)
1250
+
1251
1251
  if args.width == 1080 and args.height == 1920:
1252
1252
  args.width = rp_width
1253
1253
  args.height = rp_height
1254
- # 自动推导标题(优先从 dslMeta.title 读取)
1254
+
1255
1255
  if not args.title:
1256
1256
  dsl_meta = render_plan.get("dslMeta", {})
1257
1257
  args.title = dsl_meta.get("title", "") or "RenderPlan 草稿"
1258
1258
  print(f" ✅ converted {len(scenes)} scene(s), canvas {args.width}x{args.height}")
1259
1259
  else:
1260
- # 传统模式:必须提供 --scenes 和 --title
1260
+
1261
1261
  if not args.scenes:
1262
1262
  print("❌ provide --from-job-id, --from-render-plan or --scenes", file=sys.stderr)
1263
1263
  parser.print_help()
@@ -1268,18 +1268,18 @@ Scene fields:
1268
1268
  sys.exit(1)
1269
1269
  scenes = parse_scenes(args.scenes)
1270
1270
 
1271
- # 解析草稿根目录(始终作为 draftRootPath 传给 API)
1271
+
1272
1272
  draft_root_path = resolve_draft_root_path(args.draft_root_path, args.system)
1273
1273
 
1274
- # 检测是否需要后处理(textLayers 或任意字幕文本都需要应用 pill 样式)
1274
+
1275
1275
  needs_postprocess = has_text_layers(scenes) or any(s.get("subtitleText") for s in scenes)
1276
1276
  if needs_postprocess:
1277
1277
  print("📝 textLayers or subtitleText detected; post-processing after generation (incl. Pill subtitle styling)")
1278
1278
 
1279
- # 为 API 调用准备场景(剥离 textLayers,派生 subtitleText)
1279
+
1280
1280
  api_scenes = prepare_api_scenes(scenes)
1281
1281
 
1282
- # 提交任务
1282
+
1283
1283
  task_id = create_jianying_task(
1284
1284
  title=args.title,
1285
1285
  scenes=api_scenes,
@@ -1289,7 +1289,7 @@ Scene fields:
1289
1289
  draft_name=args.draft_name,
1290
1290
  )
1291
1291
 
1292
- # 轮询状态
1292
+
1293
1293
  result_data = poll_task_status(task_id, args.poll_interval, args.max_wait)
1294
1294
 
1295
1295
  download_url = result_data.get("downloadUrl", "")
@@ -1302,13 +1302,13 @@ Scene fields:
1302
1302
  print(f"\n🎉 Jianying draft generated successfully!")
1303
1303
  print(f" filename: {file_name}")
1304
1304
 
1305
- # 在 agent 里(有 CONVERSATION_ID)本地文件落在服务器上,用户拿不到:除非显式给了
1306
- # --output,否则不留副本。conv_U9oYmHmE1iJbx2qETDuh 一次导出在服务器 cwd 里留了 3 个 5MB 的 ZIP。
1305
+
1306
+
1307
1307
  keep_local = bool(args.output) or (not args.no_download and not conversation_id)
1308
1308
  styled = True
1309
1309
  output_path = ""
1310
1310
  if needs_postprocess:
1311
- # 后处理必须先把 ZIP 拿到本地;不保留副本时落到临时文件,用完删掉。
1311
+
1312
1312
  if keep_local:
1313
1313
  output_path = args.output or f"jianying_draft_{int(time.time())}.zip"
1314
1314
  else:
@@ -1317,12 +1317,12 @@ Scene fields:
1317
1317
  try:
1318
1318
  download_zip(download_url, output_path)
1319
1319
  postprocess_draft_zip(output_path, scenes)
1320
- # 用户拿到的链接必须指向后处理之后的版本,否则字幕样式 / textLayers 全部丢失。
1320
+
1321
1321
  download_url = upload_draft_zip(output_path, file_name)
1322
1322
  except DraftUploadError as e:
1323
- # 降级而不是失败:后端生成的原始 ZIP 已经包含素材、配音和字幕文本,只是没有
1324
- # 套字幕样式 / textLayers。整次失败的话模型只会原样重试(每次再生成一遍草稿),
1325
- # 用户什么都拿不到。
1323
+
1324
+
1325
+
1326
1326
  styled = False
1327
1327
  print(f"⚠️ could not upload the styled draft ({e}); delivering the backend draft instead.", file=sys.stderr)
1328
1328
  print(" subtitle text is included, but the pill subtitle style / textLayers were NOT applied. "