@remixmate/cli 0.1.0

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 (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +166 -0
  3. package/dist/argv.d.ts +15 -0
  4. package/dist/argv.js +51 -0
  5. package/dist/cli.d.ts +13 -0
  6. package/dist/cli.js +83 -0
  7. package/dist/handlers/gen-image.d.ts +13 -0
  8. package/dist/handlers/gen-image.js +161 -0
  9. package/dist/handlers/gen-voice.d.ts +16 -0
  10. package/dist/handlers/gen-voice.js +110 -0
  11. package/dist/handlers/index.d.ts +13 -0
  12. package/dist/handlers/index.js +14 -0
  13. package/dist/handlers/template-bind.d.ts +15 -0
  14. package/dist/handlers/template-bind.js +149 -0
  15. package/dist/http.d.ts +52 -0
  16. package/dist/http.js +110 -0
  17. package/dist/index.d.ts +9 -0
  18. package/dist/index.js +8 -0
  19. package/dist/manifest.json +260 -0
  20. package/dist/progress.d.ts +23 -0
  21. package/dist/progress.js +15 -0
  22. package/dist/registry.d.ts +33 -0
  23. package/dist/registry.js +72 -0
  24. package/dist/runner.d.ts +17 -0
  25. package/dist/runner.js +63 -0
  26. package/package.json +54 -0
  27. package/skills/export-jianying/SKILL.md +251 -0
  28. package/skills/export-jianying/scripts/gen_jianying_draft.py +1099 -0
  29. package/skills/export-jianying/skill.json +24 -0
  30. package/skills/export-jianying/version.json +7 -0
  31. package/skills/gen-digital-human/SKILL.md +211 -0
  32. package/skills/gen-digital-human/scripts/gen_digital_human_video.py +574 -0
  33. package/skills/gen-digital-human/skill.json +23 -0
  34. package/skills/gen-digital-human/version.json +7 -0
  35. package/skills/gen-image/SKILL.md +130 -0
  36. package/skills/gen-image/skill.json +22 -0
  37. package/skills/gen-image/version.json +7 -0
  38. package/skills/gen-script/SKILL.md +283 -0
  39. package/skills/gen-script/scripts/gen_script.py +1208 -0
  40. package/skills/gen-script/skill.json +41 -0
  41. package/skills/gen-script/version.json +7 -0
  42. package/skills/gen-video/SKILL.md +170 -0
  43. package/skills/gen-video/scripts/gen_video.py +591 -0
  44. package/skills/gen-video/skill.json +24 -0
  45. package/skills/gen-video/version.json +7 -0
  46. package/skills/gen-voice/SKILL.md +104 -0
  47. package/skills/gen-voice/skill.json +21 -0
  48. package/skills/gen-voice/version.json +7 -0
  49. package/skills/prepare-video-assets/SKILL.md +192 -0
  50. package/skills/prepare-video-assets/scripts/prepare_video_assets.py +57 -0
  51. package/skills/prepare-video-assets/skill.json +23 -0
  52. package/skills/prepare-video-assets/version.json +7 -0
  53. package/skills/render-video/SKILL.md +187 -0
  54. package/skills/render-video/scripts/_chrome_vendor.py +305 -0
  55. package/skills/render-video/scripts/_video_probe.py +174 -0
  56. package/skills/render-video/scripts/_vod_polling.py +86 -0
  57. package/skills/render-video/scripts/remote_renderer_client.py +236 -0
  58. package/skills/render-video/scripts/render_video.py +2288 -0
  59. package/skills/render-video/scripts/upload_video.py +193 -0
  60. package/skills/render-video/skill.json +21 -0
  61. package/skills/render-video/version.json +7 -0
  62. package/skills/template-bind/README.md +63 -0
  63. package/skills/template-bind/SKILL.md +234 -0
  64. package/skills/template-bind/scripts/check_contracts.py +196 -0
  65. package/skills/template-bind/scripts/match_template.py +215 -0
  66. package/skills/template-bind/scripts/registry_loader.py +379 -0
  67. package/skills/template-bind/scripts/render_job_client.py +162 -0
  68. package/skills/template-bind/scripts/sync_registry.py +44 -0
  69. package/skills/template-bind/scripts/template_paths.py +72 -0
  70. package/skills/template-bind/skill.json +20 -0
  71. package/skills/template-bind/version.json +7 -0
  72. package/skills/template-bind/video_dsl/README.md +97 -0
  73. package/skills/template-bind/video_dsl/runtime/__init__.py +11 -0
  74. package/skills/template-bind/video_dsl/runtime/dsl_validator.py +495 -0
  75. package/skills/template-bind/video_dsl/runtime/prompt_enhancer.py +274 -0
  76. package/skills/template-bind/video_dsl/runtime/template_binder.py +87 -0
  77. package/skills/template-bind/video_dsl/runtime/timeline_compiler.py +312 -0
  78. package/skills/template-bind/video_dsl/schema/render-plan-v1alpha1.json +193 -0
  79. package/skills/template-bind/video_dsl/schema/template-binding-v1alpha1.json +122 -0
  80. package/skills/template-bind/video_dsl/schema/template-definition-v1alpha1.json +247 -0
  81. package/skills/template-bind/video_dsl/schema/video-dsl-v1alpha1.json +378 -0
  82. package/skills/video-parser/SKILL.md +238 -0
  83. package/skills/video-parser/scripts/analyze_video.py +1038 -0
  84. package/skills/video-parser/scripts/deconstruct_video.py +602 -0
  85. package/skills/video-parser/scripts/parse_via_render.py +148 -0
  86. package/skills/video-parser/skill.json +21 -0
  87. package/skills/video-parser/version.json +7 -0
  88. package/skills/web-capture/SKILL.md +183 -0
  89. package/skills/web-capture/scripts/web_capture.py +461 -0
  90. package/skills/web-capture/skill.json +29 -0
  91. package/skills/web-capture/version.json +7 -0
@@ -0,0 +1,1099 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 剪映草稿生成脚本 — 调用 ab-api POST /file/generateJianYing
4
+ 通过 PrivToken(X-Priv-Token)认证。
5
+
6
+ 将素材 URL(视频/图片/音频)+ 时长描述打包为剪映可导入的草稿 ZIP。
7
+
8
+ 默认行为(非 --download):
9
+ 生成完成后直接输出下载 URL,供调用方直接展示,无需落盘。
10
+
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
+
15
+ 环境变量:
16
+ MM_API_BASE_URL - 后端 API 地址(默认: http://localhost:3001/api)
17
+ PRIV_TOKEN - PrivToken(优先读取;未配置时提示手动输入)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import os
25
+ import sys
26
+ import time
27
+ import urllib.error
28
+ import urllib.parse
29
+ import urllib.request
30
+ import uuid
31
+ import zipfile
32
+
33
+ API_BASE_URL = os.environ.get("MM_API_BASE_URL", "http://localhost:3001/api")
34
+ PRIVATE_TOKEN = "" # 在 main() 中通过 resolve_token() 初始化
35
+ SKILL_NAME = "export-jianying"
36
+ AGENT_NAME = os.environ.get("AGENT_NAME", "")
37
+
38
+ # 任务状态常量
39
+ STATUS_PENDING = "pending"
40
+ STATUS_PROCESSING = "processing"
41
+ STATUS_COMPLETED = "completed"
42
+ STATUS_FAILED = "failed"
43
+
44
+ # textLayer 角色预设(fontSize/color/bold/position/shadow)
45
+ ROLE_PRESETS = {
46
+ "headline": {"fontSize": 15, "color": "#FFFFFF", "bold": True, "position": {"x": 0, "y": -0.35}, "shadow": True},
47
+ "subheadline": {"fontSize": 8, "color": "#F0C040", "bold": False, "position": {"x": 0, "y": 0.38}, "shadow": False},
48
+ "badge": {"fontSize": 6, "color": "#FFFFFF", "bold": True, "position": {"x": -0.3, "y": -0.40}, "backgroundColor": "#D4A017"},
49
+ "cta": {"fontSize": 10, "color": "#F0C040", "bold": True, "position": {"x": 0, "y": 0.42}, "backgroundColor": "#2563EB"},
50
+ "caption": {"fontSize": 6, "color": "#CCCCCC", "bold": False, "position": {"x": 0, "y": 0.44}},
51
+ }
52
+
53
+ # Pill 字幕样式(匹配 Remotion SubtitleBar "pill" 风格)
54
+ PILL_SUBTITLE_STYLE = {
55
+ "font_size": 8.0,
56
+ "background_style": 1,
57
+ "background_color": "#000000",
58
+ "background_alpha": 0.65,
59
+ "background_round_radius": 0.15,
60
+ "has_shadow": False,
61
+ "shadow_alpha": 0,
62
+ "shadow_color": "",
63
+ "shadow_distance": 0,
64
+ "shadow_point": {"x": 0, "y": 0},
65
+ "shadow_smoothing": 0,
66
+ "letter_spacing": 0.0,
67
+ "line_max_width": 0.85,
68
+ "line_spacing": 0.05,
69
+ "force_apply_line_max_width": True,
70
+ }
71
+
72
+ PILL_SUBTITLE_POSITION_Y = -0.458
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # 剪映草稿根目录解析
76
+ # ---------------------------------------------------------------------------
77
+
78
+ DRAFT_ROOT_PRESETS = {
79
+ "mac": "~/Movies/JianyingPro/User Data/Projects/com.lveditor.draft",
80
+ # Windows 预设保留字面量 %LOCALAPPDATA%:在 Mac 上执行时不展开,
81
+ # 原样传给 API;用户在 Windows 解压时目录会自然匹配。
82
+ "windows": "%LOCALAPPDATA%/JianyingPro/User Data/Projects/com.lveditor.draft",
83
+ }
84
+
85
+
86
+ def resolve_draft_root_path(explicit: str, system: str) -> str:
87
+ """
88
+ 解析剪映草稿根目录,按优先级:
89
+ 1. 显式 --draft-root-path(做 expanduser)
90
+ 2. --system 预设(mac → expanduser,windows → 字面量)
91
+ 返回值始终作为 draftRootPath 传给 API。
92
+ """
93
+ if explicit:
94
+ return os.path.expanduser(explicit)
95
+ preset = DRAFT_ROOT_PRESETS.get(system)
96
+ if not preset:
97
+ print(f"❌ unsupported --system value: {system} (supported: mac / windows)", file=sys.stderr)
98
+ sys.exit(1)
99
+ if system == "mac":
100
+ return os.path.expanduser(preset)
101
+ return preset
102
+
103
+
104
+ def resolve_token() -> str:
105
+ """优先从 PRIV_TOKEN 环境变量读取 token,取不到则报错退出"""
106
+ token = os.environ.get("PRIV_TOKEN", "")
107
+ if not token:
108
+ if sys.stdin.isatty():
109
+ token = input("请输入 PrivToken: ").strip()
110
+ else:
111
+ print("❌ PRIV_TOKEN env var not configured; cannot authenticate", file=sys.stderr)
112
+ sys.exit(1)
113
+ return token
114
+
115
+
116
+ def gen_jianying_uuid() -> str:
117
+ """生成剪映风格的大写 UUID"""
118
+ return str(uuid.uuid4()).upper()
119
+
120
+
121
+ def hex_to_rgb_float(hex_color: str) -> list:
122
+ """'#RRGGBB' → [r, g, b](0.0~1.0)"""
123
+ hex_color = hex_color.lstrip("#")
124
+ r = int(hex_color[0:2], 16) / 255.0
125
+ g = int(hex_color[2:4], 16) / 255.0
126
+ b = int(hex_color[4:6], 16) / 255.0
127
+ return [round(r, 6), round(g, 6), round(b, 6)]
128
+
129
+
130
+ def build_text_content_json(text: str, style: dict) -> str:
131
+ """构建剪映 text material 的 content JSON 字符串(双重编码格式)"""
132
+ rgb = hex_to_rgb_float(style.get("color", "#FFFFFF"))
133
+ font_size = style.get("fontSize", 8)
134
+ char_count = len(text)
135
+ content_obj = {
136
+ "styles": [
137
+ {
138
+ "fill": {
139
+ "alpha": 1,
140
+ "content": {
141
+ "render_type": "solid",
142
+ "solid": {"alpha": 1, "color": rgb},
143
+ },
144
+ },
145
+ "font": {"id": "", "path": ""},
146
+ "range": [0, char_count],
147
+ "size": font_size,
148
+ }
149
+ ],
150
+ "text": text,
151
+ }
152
+ return json.dumps(content_obj, ensure_ascii=False)
153
+
154
+
155
+ def _build_headers(content_type: str = "application/json") -> dict:
156
+ """构建统一请求头(X-Priv-Token 认证头)"""
157
+ headers = {
158
+ "X-Priv-Token": PRIVATE_TOKEN,
159
+ "x-invoke-skill": SKILL_NAME,
160
+ }
161
+ if content_type:
162
+ headers["Content-Type"] = content_type
163
+ if AGENT_NAME:
164
+ headers["x-invoke-agent"] = AGENT_NAME
165
+ return headers
166
+
167
+
168
+ def api_request(path: str, payload: dict, *, exit_on_error: bool = True) -> dict:
169
+ """
170
+ 通用 API 请求函数(统一 POST 方法)
171
+ exit_on_error=False 时网络/连接错误会抛出异常而非直接退出(供轮询重试使用)
172
+ """
173
+ if not PRIVATE_TOKEN:
174
+ print("❌ PrivToken not configured: set the PRIV_TOKEN env var", file=sys.stderr)
175
+ sys.exit(1)
176
+
177
+ url = f"{API_BASE_URL}{path}"
178
+ data = json.dumps(payload).encode("utf-8")
179
+ headers = _build_headers()
180
+
181
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
182
+
183
+ try:
184
+ with urllib.request.urlopen(req, timeout=120) as resp:
185
+ return json.loads(resp.read().decode("utf-8"))
186
+ except urllib.error.HTTPError as e:
187
+ body = e.read().decode("utf-8")
188
+ print(f"❌ API request failed (HTTP {e.code}): {body}", file=sys.stderr)
189
+ sys.exit(1)
190
+ except (urllib.error.URLError, OSError, ConnectionError) as e:
191
+ if exit_on_error:
192
+ print(f"❌ network error: {e}", file=sys.stderr)
193
+ sys.exit(1)
194
+ raise
195
+
196
+
197
+ def build_text_material(material_id: str, text: str, style: dict) -> dict:
198
+ """
199
+ 构建完整的剪映 text material dict(对应 materials.texts[] 中一项)。
200
+ style 包含 fontSize / color / bold / shadow / backgroundColor 等。
201
+ """
202
+ content_json = build_text_content_json(text, style)
203
+ font_size = style.get("fontSize", 8)
204
+ color_hex = style.get("color", "#FFFFFF")
205
+ bold = style.get("bold", False)
206
+ has_shadow = style.get("shadow", False)
207
+ bg_color = style.get("backgroundColor") or ""
208
+ bg_style = 1 if bg_color else 0
209
+
210
+ return {
211
+ "add_type": 0,
212
+ "alignment": 1,
213
+ "background_alpha": 1,
214
+ "background_color": bg_color,
215
+ "background_height": 0.14,
216
+ "background_horizontal_offset": 0,
217
+ "background_round_radius": 0,
218
+ "background_style": bg_style,
219
+ "background_vertical_offset": 0,
220
+ "background_width": 0.14,
221
+ "base_content": "",
222
+ "bold_width": 0.04 if bold else 0,
223
+ "border_alpha": 1,
224
+ "border_color": "",
225
+ "border_width": 0.08,
226
+ "caption_template_info": {
227
+ "category_id": "",
228
+ "category_name": "",
229
+ "effect_id": "",
230
+ "is_new": False,
231
+ "path": "",
232
+ "request_id": "",
233
+ "resource_id": "",
234
+ "resource_name": "",
235
+ "source_platform": 0,
236
+ },
237
+ "check_flag": 7,
238
+ "combo_info": {"text_templates": []},
239
+ "content": content_json,
240
+ "fixed_height": -1,
241
+ "fixed_width": -1,
242
+ "font_category_id": "",
243
+ "font_category_name": "",
244
+ "font_id": "",
245
+ "font_name": "",
246
+ "font_path": "",
247
+ "font_resource_id": "",
248
+ "font_size": font_size,
249
+ "font_source_platform": 0,
250
+ "font_team_id": "",
251
+ "font_title": "none",
252
+ "font_url": "",
253
+ "fonts": [],
254
+ "force_apply_line_max_width": False,
255
+ "global_alpha": 1,
256
+ "group_id": "",
257
+ "has_shadow": has_shadow,
258
+ "id": material_id,
259
+ "initial_scale": 1,
260
+ "inner_padding": -1,
261
+ "is_rich_text": False,
262
+ "italic_degree": 0,
263
+ "ktv_color": "",
264
+ "language": "",
265
+ "layer_weight": 1,
266
+ "letter_spacing": 0,
267
+ "line_feed": 1,
268
+ "line_max_width": 0.82,
269
+ "line_spacing": 0.02,
270
+ "multi_language_current": "none",
271
+ "name": "",
272
+ "original_size": [],
273
+ "preset_category": "",
274
+ "preset_category_id": "",
275
+ "preset_has_set_alignment": False,
276
+ "preset_id": "",
277
+ "preset_index": 0,
278
+ "preset_name": "",
279
+ "recognize_task_id": "",
280
+ "recognize_type": 0,
281
+ "relevance_segment": [],
282
+ "shadow_alpha": 0.9,
283
+ "shadow_angle": -45,
284
+ "shadow_color": "",
285
+ "shadow_distance": 8,
286
+ "shadow_point": {"x": 0.6363961030678928, "y": -0.6363961030678928},
287
+ "shadow_smoothing": 1,
288
+ "shape_clip_x": False,
289
+ "shape_clip_y": False,
290
+ "style_name": "",
291
+ "sub_type": 0,
292
+ "subtitle_keywords": None,
293
+ "subtitle_template_original_fontsize": 0,
294
+ "text_alpha": 1,
295
+ "text_color": color_hex,
296
+ "text_curve": None,
297
+ "text_preset_resource_id": "",
298
+ "text_size": 30,
299
+ "text_to_audio_ids": [],
300
+ "tts_auto_update": False,
301
+ "type": "text",
302
+ "typesetting": 0,
303
+ "underline": False,
304
+ "underline_offset": 0.22,
305
+ "underline_width": 0.05,
306
+ "use_effect_default_color": True,
307
+ "words": {"end_time": [], "start_time": [], "text": []},
308
+ }
309
+
310
+
311
+ def build_text_segment(
312
+ segment_id: str,
313
+ material_id: str,
314
+ start_us: int,
315
+ duration_us: int,
316
+ position: dict,
317
+ ) -> dict:
318
+ """
319
+ 构建完整的剪映 text track segment dict。
320
+ position: {"x": float, "y": float} 归一化坐标。
321
+ """
322
+ return {
323
+ "caption_info": None,
324
+ "check_flag": 7,
325
+ "clip": {
326
+ "alpha": 1,
327
+ "flip": {"horizontal": False, "vertical": False},
328
+ "rotation": 0,
329
+ "scale": {"x": 1, "y": 1},
330
+ "transform": {
331
+ "x": position.get("x", 0),
332
+ "y": position.get("y", 0),
333
+ },
334
+ },
335
+ "common_keyframes": [],
336
+ "enable_adjust": False,
337
+ "enable_color_correct_adjust": False,
338
+ "enable_color_curves": True,
339
+ "enable_color_match_adjust": False,
340
+ "enable_color_wheels": True,
341
+ "enable_lut": False,
342
+ "enable_smart_color_adjust": False,
343
+ "extra_material_refs": [],
344
+ "group_id": "",
345
+ "hdr_settings": None,
346
+ "id": segment_id,
347
+ "intensifies_audio": False,
348
+ "is_placeholder": False,
349
+ "is_tone_modify": False,
350
+ "keyframe_refs": [],
351
+ "last_nonzero_volume": 1,
352
+ "material_id": material_id,
353
+ "render_index": 11000,
354
+ "responsive_layout": {
355
+ "enable": False,
356
+ "horizontal_pos_layout": 0,
357
+ "size_layout": 0,
358
+ "target_follow": "",
359
+ "vertical_pos_layout": 0,
360
+ },
361
+ "reverse": False,
362
+ "source_timerange": {"duration": duration_us, "start": 0},
363
+ "speed": 1,
364
+ "target_timerange": {"duration": duration_us, "start": start_us},
365
+ "template_id": "",
366
+ "template_scene": "default",
367
+ "track_attribute": 0,
368
+ "track_render_index": 0,
369
+ "uniform_scale": {"on": True, "value": 1},
370
+ "visible": True,
371
+ "volume": 1,
372
+ }
373
+
374
+
375
+ def convert_render_plan_to_scenes(render_plan: dict) -> tuple:
376
+ """
377
+ 将 RenderPlan JSON 转换为剪映草稿的 scenes 列表。
378
+ 返回 (scenes, width, height)。
379
+
380
+ 从 render-plan.json 中提取每个 scene 的素材 URL、时长、字幕文本,
381
+ 自动映射为 gen_jianying_draft 所需的 scenes JSON 格式。
382
+ """
383
+ timeline = render_plan.get("timeline", [])
384
+ resolved_assets = {a["assetId"]: a for a in render_plan.get("resolvedAssets", [])}
385
+ render_config = render_plan.get("renderConfig", {})
386
+ fps = render_config.get("fps", 30)
387
+ width = render_config.get("width", 1080)
388
+ height = render_config.get("height", 1920)
389
+
390
+ scenes = []
391
+ for entry in timeline:
392
+ scene: dict = {}
393
+ duration_frames = entry.get("durationFrames", 0)
394
+ duration_sec = round(duration_frames / fps, 2) if fps else 5.0
395
+ scene["duration"] = duration_sec
396
+
397
+ # 提取视觉素材(图片或视频)
398
+ for layer in entry.get("layers", []):
399
+ if layer.get("type") == "visual":
400
+ asset = resolved_assets.get(layer.get("assetId", ""))
401
+ if asset and asset.get("url"):
402
+ asset_type = asset.get("type", "image")
403
+ if asset_type == "video":
404
+ scene["videoUrl"] = asset["url"]
405
+ else:
406
+ scene["imageUrl"] = asset["url"]
407
+ break
408
+
409
+ # 提取音频素材(TTS 旁白)
410
+ for layer in entry.get("layers", []):
411
+ if layer.get("type") == "audio":
412
+ asset = resolved_assets.get(layer.get("assetId", ""))
413
+ if asset and asset.get("url"):
414
+ scene["audioUrl"] = asset["url"]
415
+ if asset.get("duration"):
416
+ scene["audioDuration"] = round(asset["duration"] / 1000.0, 2)
417
+ break
418
+
419
+ # 提取字幕文本(从 subtitleSegments 拼接)
420
+ subtitle_segments = entry.get("subtitleSegments", [])
421
+ if subtitle_segments:
422
+ subtitle_text = " ".join(seg.get("text", "") for seg in subtitle_segments if seg.get("text"))
423
+ if subtitle_text.strip():
424
+ scene["subtitleText"] = subtitle_text.strip()
425
+
426
+ # 提取 textLayers(从 props 中的 textLayers 或 scene 级别的文本信息)
427
+ props = entry.get("props", {})
428
+ title_text = props.get("titleText", "")
429
+ if title_text:
430
+ text_layers = []
431
+ text_layers.append({
432
+ "content": title_text,
433
+ "role": "headline",
434
+ })
435
+ # 如果有 subtitleText 也加入
436
+ if scene.get("subtitleText"):
437
+ text_layers.append({
438
+ "content": scene["subtitleText"],
439
+ "role": "subheadline",
440
+ })
441
+ del scene["subtitleText"] # 避免重复
442
+ scene["textLayers"] = text_layers
443
+
444
+ # 如果没有视觉素材,跳过该场景(剪映需要每个场景有 videoUrl 或 imageUrl)
445
+ if not scene.get("videoUrl") and not scene.get("imageUrl"):
446
+ # 尝试用占位图
447
+ print(f" ⚠️ scene {entry.get('sceneId', '?')} has no visual asset, skipping", file=sys.stderr)
448
+ continue
449
+
450
+ scenes.append(scene)
451
+
452
+ if not scenes:
453
+ print("❌ no convertible scenes in the RenderPlan (all scenes lack a visual asset)", file=sys.stderr)
454
+ sys.exit(1)
455
+
456
+ return scenes, width, height
457
+
458
+
459
+ def parse_scenes(scenes_arg: str) -> list:
460
+ """解析 --scenes 参数:支持 inline JSON 字符串或文件路径"""
461
+ # 尝试作为文件路径读取
462
+ if os.path.isfile(scenes_arg):
463
+ with open(scenes_arg, "r", encoding="utf-8") as f:
464
+ content = f.read()
465
+ try:
466
+ scenes = json.loads(content)
467
+ except json.JSONDecodeError as e:
468
+ print(f"❌ failed to parse scenes JSON file: {e}", file=sys.stderr)
469
+ sys.exit(1)
470
+ else:
471
+ # 尝试作为 inline JSON 解析
472
+ try:
473
+ scenes = json.loads(scenes_arg)
474
+ except json.JSONDecodeError as e:
475
+ print(f"❌ failed to parse scenes JSON: {e}", file=sys.stderr)
476
+ print(" hint: --scenes accepts an inline JSON string or a JSON file path", file=sys.stderr)
477
+ sys.exit(1)
478
+
479
+ if not isinstance(scenes, list):
480
+ print("❌ scenes JSON must be an array", file=sys.stderr)
481
+ sys.exit(1)
482
+
483
+ if len(scenes) == 0:
484
+ print("❌ scenes list cannot be empty", file=sys.stderr)
485
+ sys.exit(1)
486
+
487
+ # 基本校验
488
+ for i, scene in enumerate(scenes):
489
+ if not isinstance(scene, dict):
490
+ print(f"❌ scene {i + 1} is malformed: must be an object", file=sys.stderr)
491
+ sys.exit(1)
492
+ if not scene.get("videoUrl") and not scene.get("imageUrl"):
493
+ print(f"❌ scene {i + 1} is missing videoUrl or imageUrl", file=sys.stderr)
494
+ sys.exit(1)
495
+ if not scene.get("duration") or scene["duration"] <= 0:
496
+ print(f"❌ scene {i + 1} is missing a valid duration (must be > 0)", file=sys.stderr)
497
+ sys.exit(1)
498
+
499
+ # textLayers 校验
500
+ text_layers = scene.get("textLayers")
501
+ if text_layers is not None:
502
+ if not isinstance(text_layers, list):
503
+ print(f"❌ scene {i + 1} textLayers must be an array", file=sys.stderr)
504
+ sys.exit(1)
505
+ for j, layer in enumerate(text_layers):
506
+ if not isinstance(layer, dict):
507
+ print(f"❌ scene {i + 1} textLayers[{j}] must be an object", file=sys.stderr)
508
+ sys.exit(1)
509
+ if not layer.get("content"):
510
+ print(f"❌ scene {i + 1} textLayers[{j}] missing 'content'", file=sys.stderr)
511
+ sys.exit(1)
512
+
513
+ return scenes
514
+
515
+
516
+ def create_jianying_task(
517
+ title: str,
518
+ scenes: list,
519
+ draft_root_path: str,
520
+ width: int = 1080,
521
+ height: int = 1920,
522
+ draft_name: str = "",
523
+ ) -> str:
524
+ """创建剪映草稿生成任务,返回 taskId"""
525
+ print("📦 Submitting Jianying draft generation task...")
526
+ print(f" title: {title}")
527
+ print(f" scenes: {len(scenes)}")
528
+ print(f" canvas: {width}x{height}")
529
+ print(f" draft root: {draft_root_path}")
530
+ if draft_name:
531
+ print(f" draft name: {draft_name}")
532
+
533
+ payload: dict = {
534
+ "title": title,
535
+ "scenes": scenes,
536
+ "width": width,
537
+ "height": height,
538
+ "draftRootPath": draft_root_path,
539
+ }
540
+ if draft_name:
541
+ payload["draftName"] = draft_name
542
+
543
+ result = api_request("/file/generateJianYing", payload)
544
+
545
+ # 检查业务状态码
546
+ if result.get("code") != 0:
547
+ msg = result.get("msg", "未知错误")
548
+ print(f"❌ API returned an error: {msg}", file=sys.stderr)
549
+ sys.exit(1)
550
+
551
+ result_data = result.get("data") or {}
552
+ task_id = result_data.get("taskId")
553
+ if not task_id:
554
+ print(f"❌ could not parse task id; API returned: {json.dumps(result, ensure_ascii=False)}", file=sys.stderr)
555
+ sys.exit(1)
556
+
557
+ print(f"✅ task submitted, task id: {task_id}")
558
+ return task_id
559
+
560
+
561
+ def poll_task_status(task_id: str, poll_interval: int = 5, max_wait: int = 300) -> dict:
562
+ """
563
+ 轮询剪映草稿生成状态,返回最终结果。
564
+ 网络瞬断时自动重试(最多连续 5 次),不会因单次网络抖动而中断。
565
+ """
566
+ print(f"\n⏳ Waiting for draft generation (up to {max_wait} s)...")
567
+
568
+ start_time = time.time()
569
+ consecutive_errors = 0
570
+ max_consecutive_errors = 5
571
+
572
+ while True:
573
+ elapsed = time.time() - start_time
574
+ if elapsed > max_wait:
575
+ print(f"\n❌ wait timed out (waited {elapsed:.0f} s)", file=sys.stderr)
576
+ print(f" task id: {task_id}", file=sys.stderr)
577
+ sys.exit(1)
578
+
579
+ try:
580
+ result = api_request("/file/generateJianYingQuery", {"taskId": task_id}, exit_on_error=False)
581
+ except (urllib.error.URLError, OSError, ConnectionError) as e:
582
+ consecutive_errors += 1
583
+ print(f"\n ⚠️ polling network error ({consecutive_errors}/{max_consecutive_errors}): {e}", flush=True)
584
+ if consecutive_errors >= max_consecutive_errors:
585
+ print(f"❌ {max_consecutive_errors} consecutive network errors, giving up on polling", file=sys.stderr)
586
+ print(f" task id: {task_id} (the job may still be running on the backend)", file=sys.stderr)
587
+ sys.exit(1)
588
+ time.sleep(poll_interval)
589
+ continue
590
+
591
+ # 请求成功,重置连续错误计数
592
+ consecutive_errors = 0
593
+
594
+ # 检查业务状态码
595
+ if result.get("code") != 0:
596
+ msg = result.get("msg", "未知错误")
597
+ print(f"\n❌ status query failed: {msg}", file=sys.stderr)
598
+ sys.exit(1)
599
+
600
+ result_data = result.get("data") or {}
601
+ status = result_data.get("status", "unknown")
602
+
603
+ # 打印状态
604
+ status_icons = {
605
+ STATUS_PENDING: "⏳",
606
+ STATUS_PROCESSING: "🔄",
607
+ STATUS_COMPLETED: "✅",
608
+ STATUS_FAILED: "❌",
609
+ }
610
+ icon = status_icons.get(status, "❓")
611
+ print(f"\r {icon} status: {status} | elapsed: {elapsed:.0f}s", end="", flush=True)
612
+
613
+ if status == STATUS_COMPLETED:
614
+ print() # 换行
615
+ return result_data
616
+
617
+ if status == STATUS_FAILED:
618
+ print()
619
+ error_msg = result_data.get("errorMsg") or "未知错误"
620
+ print(f"❌ draft generation failed: {error_msg}", file=sys.stderr)
621
+ sys.exit(1)
622
+
623
+ time.sleep(poll_interval)
624
+
625
+
626
+ def _encode_url(raw_url: str) -> str:
627
+ """对 URL 中的非 ASCII 字符做 percent-encoding(保留已编码部分和合法字符)"""
628
+ parsed = urllib.parse.urlparse(raw_url)
629
+ encoded_path = urllib.parse.quote(parsed.path, safe="/:@!$&'()*+,;=-._~")
630
+ return urllib.parse.urlunparse(parsed._replace(path=encoded_path))
631
+
632
+
633
+ def download_zip(download_url: str, output_path: str) -> None:
634
+ """下载 ZIP 文件到本地"""
635
+ print(f"\n⬇️ Downloading draft ZIP: {download_url[:80]}...")
636
+ safe_url = _encode_url(download_url)
637
+ try:
638
+ urllib.request.urlretrieve(safe_url, output_path)
639
+ except OSError as e:
640
+ print(f"❌ download failed: {e}", file=sys.stderr)
641
+ sys.exit(1)
642
+
643
+ if os.path.exists(output_path):
644
+ file_size = os.path.getsize(output_path)
645
+ print(f"✅ draft ZIP saved: {output_path} ({file_size / 1024 / 1024:.1f} MB)")
646
+ else:
647
+ print(f"❌ file not found: {output_path}", file=sys.stderr)
648
+ sys.exit(1)
649
+
650
+
651
+ def _resolve_layer_style(layer: dict) -> dict:
652
+ """将 textLayer 字段与角色预设合并,返回最终有效样式"""
653
+ role = layer.get("role", "subheadline")
654
+ preset = ROLE_PRESETS.get(role, ROLE_PRESETS["subheadline"])
655
+ style = dict(preset) # 浅拷贝预设
656
+ # 用户显式字段覆盖预设
657
+ for key in ("fontSize", "color", "bold", "shadow", "backgroundColor"):
658
+ if key in layer:
659
+ style[key] = layer[key]
660
+ if "position" in layer:
661
+ style["position"] = layer["position"]
662
+ return style
663
+
664
+
665
+ def apply_text_layers(draft: dict, scenes: list) -> None:
666
+ """
667
+ 核心后处理:遍历场景 textLayers,修改/新增 draft 中的文本素材与轨道片段。
668
+ 直接原地修改 draft dict。
669
+ """
670
+ # 1. 找到 video track,提取每个片段的时间范围
671
+ video_track = None
672
+ for track in draft.get("tracks", []):
673
+ if track.get("type") == "video":
674
+ video_track = track
675
+ break
676
+ if not video_track:
677
+ print(" ⚠️ video track not found; skipping textLayers post-processing", file=sys.stderr)
678
+ return
679
+
680
+ video_segments = video_track.get("segments", [])
681
+ if len(video_segments) != len(scenes):
682
+ print(
683
+ f" ⚠️ video segments ({len(video_segments)}) 与 scenes ({len(scenes)}) 数量不一致,"
684
+ "按最小数量匹配",
685
+ file=sys.stderr,
686
+ )
687
+
688
+ # 2. 找到 text track,不存在则创建
689
+ text_track = None
690
+ for track in draft.get("tracks", []):
691
+ if track.get("type") == "text":
692
+ text_track = track
693
+ break
694
+ if not text_track:
695
+ text_track = {
696
+ "attribute": 0,
697
+ "flag": 0,
698
+ "id": gen_jianying_uuid(),
699
+ "is_default_name": True,
700
+ "name": "",
701
+ "segments": [],
702
+ "type": "text",
703
+ }
704
+ draft["tracks"].append(text_track)
705
+
706
+ text_segments = text_track.get("segments", [])
707
+ texts_materials = draft.get("materials", {}).get("texts", [])
708
+
709
+ # 建立 target_timerange.start → text_segment 索引映射
710
+ seg_by_start: dict[int, int] = {}
711
+ for idx, seg in enumerate(text_segments):
712
+ start = seg.get("target_timerange", {}).get("start", -1)
713
+ seg_by_start[start] = idx
714
+
715
+ # 建立 material_id → texts 索引映射
716
+ mat_by_id: dict[str, int] = {}
717
+ for idx, mat in enumerate(texts_materials):
718
+ mat_by_id[mat["id"]] = idx
719
+
720
+ # 额外的 text track 列表(用于放置第 2+ 个 textLayer 的片段)
721
+ extra_text_tracks: list[dict] = []
722
+
723
+ # 3. 遍历每个场景
724
+ match_count = min(len(video_segments), len(scenes))
725
+ for i in range(match_count):
726
+ scene = scenes[i]
727
+ text_layers = scene.get("textLayers")
728
+ if not text_layers:
729
+ continue
730
+
731
+ v_seg = video_segments[i]
732
+ start_us = v_seg["target_timerange"]["start"]
733
+ duration_us = v_seg["target_timerange"]["duration"]
734
+
735
+ for layer_idx, layer in enumerate(text_layers):
736
+ content = layer.get("content", "")
737
+ if not content:
738
+ continue
739
+ style = _resolve_layer_style(layer)
740
+ position = style.get("position", {"x": 0, "y": 0})
741
+
742
+ if layer_idx == 0 and start_us in seg_by_start:
743
+ # 替换已有文本素材
744
+ seg_idx = seg_by_start[start_us]
745
+ existing_seg = text_segments[seg_idx]
746
+ mat_id = existing_seg.get("material_id", "")
747
+
748
+ # 更新素材
749
+ if mat_id in mat_by_id:
750
+ mat = texts_materials[mat_by_id[mat_id]]
751
+ mat["content"] = build_text_content_json(content, style)
752
+ mat["font_size"] = style.get("fontSize", 8)
753
+ mat["text_color"] = style.get("color", "#FFFFFF")
754
+ mat["has_shadow"] = style.get("shadow", False)
755
+ mat["bold_width"] = 0.04 if style.get("bold", False) else 0
756
+ bg_color = style.get("backgroundColor") or ""
757
+ mat["background_color"] = bg_color
758
+ mat["background_style"] = 1 if bg_color else 0
759
+
760
+ # 更新片段位置
761
+ existing_seg["clip"]["transform"]["x"] = position.get("x", 0)
762
+ existing_seg["clip"]["transform"]["y"] = position.get("y", 0)
763
+ else:
764
+ # 新增素材 + 片段
765
+ new_mat_id = gen_jianying_uuid()
766
+ new_seg_id = gen_jianying_uuid()
767
+
768
+ new_material = build_text_material(new_mat_id, content, style)
769
+ texts_materials.append(new_material)
770
+ mat_by_id[new_mat_id] = len(texts_materials) - 1
771
+
772
+ new_segment = build_text_segment(
773
+ new_seg_id, new_mat_id, start_us, duration_us, position
774
+ )
775
+
776
+ # 第 2+ 个 textLayer 放入额外 track(每个 layer_idx 一条 track)
777
+ extra_idx = layer_idx - 1 if (layer_idx > 0 and start_us in seg_by_start) else layer_idx
778
+ while extra_idx >= len(extra_text_tracks):
779
+ new_track = {
780
+ "attribute": 0,
781
+ "flag": 0,
782
+ "id": gen_jianying_uuid(),
783
+ "is_default_name": True,
784
+ "name": "",
785
+ "segments": [],
786
+ "type": "text",
787
+ }
788
+ extra_text_tracks.append(new_track)
789
+ extra_text_tracks[extra_idx]["segments"].append(new_segment)
790
+
791
+ # 将额外 text tracks 追加到 draft
792
+ for et in extra_text_tracks:
793
+ if et["segments"]:
794
+ draft["tracks"].append(et)
795
+
796
+ # 更新 materials.texts 引用(因为可能通过 append 改变了列表内容)
797
+ draft["materials"]["texts"] = texts_materials
798
+
799
+ print(f" ✅ textLayers post-processing finished")
800
+
801
+
802
+ def apply_subtitle_pill_style(draft: dict) -> None:
803
+ """
804
+ 将 Pill 字幕样式(匹配 Remotion SubtitleBar "pill" 风格)应用到 draft 中的
805
+ 所有 text materials 和 text segments。直接原地修改 draft dict。
806
+ """
807
+ texts_materials = draft.get("materials", {}).get("texts", []) or []
808
+
809
+ # 1. 覆盖每个 text material 的样式属性
810
+ for mat in texts_materials:
811
+ for key, value in PILL_SUBTITLE_STYLE.items():
812
+ mat[key] = value
813
+
814
+ # 更新 material 内嵌 content JSON 的 styles[0].size
815
+ content_raw = mat.get("content", "")
816
+ if content_raw:
817
+ try:
818
+ content_obj = json.loads(content_raw)
819
+ styles = content_obj.get("styles", [])
820
+ if styles:
821
+ styles[0]["size"] = PILL_SUBTITLE_STYLE["font_size"]
822
+ mat["content"] = json.dumps(content_obj, ensure_ascii=False)
823
+ except (json.JSONDecodeError, TypeError):
824
+ # 若 content 非 JSON(极少数情况),跳过内嵌更新
825
+ pass
826
+
827
+ # 2. 将每个 text segment 的 clip.transform.y 设为 PILL_SUBTITLE_POSITION_Y
828
+ for track in draft.get("tracks", []):
829
+ if track.get("type") != "text":
830
+ continue
831
+ for seg in track.get("segments", []):
832
+ clip = seg.setdefault("clip", {})
833
+ transform = clip.setdefault("transform", {"x": 0, "y": 0})
834
+ transform["y"] = PILL_SUBTITLE_POSITION_Y
835
+
836
+ print(f" ✅ Pill subtitle style post-processing finished")
837
+
838
+
839
+ def postprocess_draft_zip(zip_path: str, scenes: list) -> None:
840
+ """
841
+ 后处理剪映草稿 ZIP:读取 draft_content.json,修改文本层,重新打包。
842
+ 同时处理 draft_content.json 和 draft_info.json(两者内容相同)。
843
+ """
844
+ print(f"\n🔧 Post-processing draft ZIP (applying textLayers)...")
845
+
846
+ tmp_path = zip_path + ".tmp"
847
+
848
+ with zipfile.ZipFile(zip_path, "r") as zin:
849
+ # 读取 draft_content.json
850
+ draft_json_name = None
851
+ draft_info_name = None
852
+ for name in zin.namelist():
853
+ if name.endswith("draft_content.json"):
854
+ draft_json_name = name
855
+ elif name.endswith("draft_info.json"):
856
+ draft_info_name = name
857
+
858
+ if not draft_json_name:
859
+ print(" ⚠️ draft_content.json not found in the ZIP; skipping post-processing", file=sys.stderr)
860
+ return
861
+
862
+ draft_data = json.loads(zin.read(draft_json_name).decode("utf-8"))
863
+
864
+ # 应用 textLayers 修改
865
+ apply_text_layers(draft_data, scenes)
866
+
867
+ # 应用 Pill 字幕样式(匹配 Remotion SubtitleBar)
868
+ apply_subtitle_pill_style(draft_data)
869
+
870
+ modified_json = json.dumps(draft_data, ensure_ascii=False, indent=4).encode("utf-8")
871
+
872
+ # 写入新 ZIP
873
+ with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zout:
874
+ for item in zin.infolist():
875
+ if item.filename == draft_json_name:
876
+ zout.writestr(item, modified_json)
877
+ elif item.filename == draft_info_name:
878
+ # draft_info.json 内容同 draft_content.json
879
+ zout.writestr(item, modified_json)
880
+ else:
881
+ zout.writestr(item, zin.read(item.filename))
882
+
883
+ # 原子替换
884
+ os.replace(tmp_path, zip_path)
885
+ print(f" ✅ draft ZIP post-processing finished: {zip_path}")
886
+
887
+
888
+ def prepare_api_scenes(scenes: list) -> list:
889
+ """
890
+ 为 API 调用准备场景列表:剥离 textLayers,自动派生 subtitleText。
891
+ 返回新列表(不修改原始 scenes)。
892
+ """
893
+ api_scenes = []
894
+ for scene in scenes:
895
+ api_scene = {k: v for k, v in scene.items() if k != "textLayers"}
896
+ text_layers = scene.get("textLayers")
897
+ if text_layers and not api_scene.get("subtitleText"):
898
+ # 从第一个 textLayer 派生 subtitleText
899
+ api_scene["subtitleText"] = text_layers[0].get("content", "")
900
+ api_scenes.append(api_scene)
901
+ return api_scenes
902
+
903
+
904
+ def has_text_layers(scenes: list) -> bool:
905
+ """检查场景列表中是否有任何场景包含 textLayers"""
906
+ return any(scene.get("textLayers") for scene in scenes)
907
+
908
+
909
+ def main() -> None:
910
+ parser = argparse.ArgumentParser(
911
+ description="Jianying draft generator — packages asset URLs into a Jianying-importable draft ZIP",
912
+ formatter_class=argparse.RawDescriptionHelpFormatter,
913
+ epilog="""
914
+ Scene JSON example:
915
+ [
916
+ {"videoUrl": "https://example.com/video.mp4", "duration": 5},
917
+ {"imageUrl": "https://example.com/img.jpg", "duration": 3, "subtitleText": "Product spotlight"},
918
+ {"videoUrl": "https://example.com/v2.mp4", "duration": 8, "audioUrl": "https://example.com/bgm.mp3", "audioDuration": 8}
919
+ ]
920
+
921
+ Scene fields:
922
+ videoUrl Video material URL (mutually exclusive with imageUrl)
923
+ imageUrl Image material URL (mutually exclusive with videoUrl)
924
+ duration Scene duration in seconds, required
925
+ width/height Material width/height (optional)
926
+ audioUrl Audio URL (optional)
927
+ audioDuration Audio duration (optional, defaults to duration)
928
+ subtitleText Subtitle text (optional)
929
+ textLayers Multi-layer text style array (optional, supports custom font size / color / position)
930
+ """,
931
+ )
932
+
933
+ parser.add_argument("--scenes", required=False, help="Scenes JSON (inline string or file path)")
934
+ parser.add_argument("--title", required=False, help="Draft title")
935
+ parser.add_argument(
936
+ "--from-render-plan",
937
+ default="",
938
+ help="Auto-convert a RenderPlan JSON file into a Jianying draft (replaces --scenes; pulls material URL / duration / subtitle automatically)",
939
+ )
940
+ parser.add_argument(
941
+ "--from-job-id",
942
+ type=int,
943
+ default=0,
944
+ help="Load the RenderPlan from the database (pass a jobId, replaces --from-render-plan; requires PRIV_TOKEN)",
945
+ )
946
+ parser.add_argument("--width", type=int, default=1080, help="Canvas width (default 1080)")
947
+ parser.add_argument("--height", type=int, default=1920, help="Canvas height (default 1920)")
948
+ parser.add_argument("--draft-name", default="", help="Jianying draft name (defaults to title)")
949
+ parser.add_argument(
950
+ "--system",
951
+ choices=["mac", "windows"],
952
+ default="mac",
953
+ help="Pick the draft-root preset by OS (default: mac)",
954
+ )
955
+ parser.add_argument(
956
+ "--draft-root-path",
957
+ default="",
958
+ help="Explicitly specify the Jianying draft root path (overrides the --system preset)",
959
+ )
960
+ parser.add_argument(
961
+ "--no-download",
962
+ action="store_true",
963
+ help="Skip the ZIP download; only print the download URL (ignored when scenes include textLayers / subtitleText)",
964
+ )
965
+ parser.add_argument("-o", "--output", default=None, help="Download path (default jianying_draft_{timestamp}.zip, prevents multi-user collisions)")
966
+ parser.add_argument("--poll-interval", type=int, default=5, help="Polling interval (seconds, default 5)")
967
+ parser.add_argument("--max-wait", type=int, default=300, help="Maximum wait time (seconds, default 300)")
968
+ parser.add_argument("--priv-token", default="", help="Override the auth token")
969
+
970
+ args = parser.parse_args()
971
+
972
+ # 初始化 token
973
+ global PRIVATE_TOKEN
974
+ if args.priv_token:
975
+ PRIVATE_TOKEN = args.priv_token
976
+ else:
977
+ PRIVATE_TOKEN = resolve_token()
978
+
979
+ if not PRIVATE_TOKEN:
980
+ print("❌ PrivToken not configured", file=sys.stderr)
981
+ sys.exit(1)
982
+
983
+ # --from-job-id 模式:从数据库加载 RenderPlan
984
+ if args.from_job_id:
985
+ print(f"📋 loading RenderPlan from database (jobId={args.from_job_id})...")
986
+ if not PRIVATE_TOKEN:
987
+ print("❌ --from-job-id mode requires PRIV_TOKEN", file=sys.stderr)
988
+ sys.exit(1)
989
+ # render_job_client lives in skills/template-bind/scripts/ — the
990
+ # shared location for cross-skill Python helpers (matches the pattern
991
+ # render-video uses for the same import).
992
+ _template_bind_scripts = os.path.join(
993
+ os.path.dirname(__file__), "..", "..", "template-bind", "scripts"
994
+ )
995
+ sys.path.insert(0, _template_bind_scripts)
996
+ from render_job_client import get_plan as rjc_get_plan
997
+ try:
998
+ render_plan_str = rjc_get_plan(args.from_job_id, PRIVATE_TOKEN)
999
+ except RuntimeError as e:
1000
+ print(f"❌ failed to fetch RenderPlan: {e}", file=sys.stderr)
1001
+ sys.exit(1)
1002
+ render_plan = json.loads(render_plan_str)
1003
+ scenes, rp_width, rp_height = convert_render_plan_to_scenes(render_plan)
1004
+ if args.width == 1080 and args.height == 1920:
1005
+ args.width = rp_width
1006
+ args.height = rp_height
1007
+ if not args.title:
1008
+ dsl_meta = render_plan.get("dslMeta", {})
1009
+ args.title = dsl_meta.get("title", "") or "RenderPlan 草稿"
1010
+ print(f" ✅ converted {len(scenes)} scene(s), canvas {args.width}x{args.height}")
1011
+ # --from-render-plan 模式:从 RenderPlan 自动转换
1012
+ elif args.from_render_plan:
1013
+ if not os.path.isfile(args.from_render_plan):
1014
+ print(f"❌ RenderPlan file does not exist: {args.from_render_plan}", file=sys.stderr)
1015
+ sys.exit(1)
1016
+ print(f"📋 converting from RenderPlan: {args.from_render_plan}")
1017
+ with open(args.from_render_plan, "r", encoding="utf-8") as f:
1018
+ render_plan = json.load(f)
1019
+ scenes, rp_width, rp_height = convert_render_plan_to_scenes(render_plan)
1020
+ # 使用 RenderPlan 中的画布尺寸(除非用户显式指定)
1021
+ if args.width == 1080 and args.height == 1920:
1022
+ args.width = rp_width
1023
+ args.height = rp_height
1024
+ # 自动推导标题(优先从 dslMeta.title 读取)
1025
+ if not args.title:
1026
+ dsl_meta = render_plan.get("dslMeta", {})
1027
+ args.title = dsl_meta.get("title", "") or "RenderPlan 草稿"
1028
+ print(f" ✅ converted {len(scenes)} scene(s), canvas {args.width}x{args.height}")
1029
+ else:
1030
+ # 传统模式:必须提供 --scenes 和 --title
1031
+ if not args.scenes:
1032
+ print("❌ provide --scenes or --from-render-plan", file=sys.stderr)
1033
+ parser.print_help()
1034
+ sys.exit(1)
1035
+ if not args.title:
1036
+ print("❌ provide --title", file=sys.stderr)
1037
+ parser.print_help()
1038
+ sys.exit(1)
1039
+ scenes = parse_scenes(args.scenes)
1040
+
1041
+ # 解析草稿根目录(始终作为 draftRootPath 传给 API)
1042
+ draft_root_path = resolve_draft_root_path(args.draft_root_path, args.system)
1043
+
1044
+ # 检测是否需要后处理(textLayers 或任意字幕文本都需要应用 pill 样式)
1045
+ needs_postprocess = has_text_layers(scenes) or any(s.get("subtitleText") for s in scenes)
1046
+ if needs_postprocess:
1047
+ print("📝 textLayers or subtitleText detected; post-processing after generation (incl. Pill subtitle styling)")
1048
+
1049
+ # 为 API 调用准备场景(剥离 textLayers,派生 subtitleText)
1050
+ api_scenes = prepare_api_scenes(scenes)
1051
+
1052
+ # 提交任务
1053
+ task_id = create_jianying_task(
1054
+ title=args.title,
1055
+ scenes=api_scenes,
1056
+ draft_root_path=draft_root_path,
1057
+ width=args.width,
1058
+ height=args.height,
1059
+ draft_name=args.draft_name,
1060
+ )
1061
+
1062
+ # 轮询状态
1063
+ result_data = poll_task_status(task_id, args.poll_interval, args.max_wait)
1064
+
1065
+ download_url = result_data.get("downloadUrl", "")
1066
+ file_name = result_data.get("fileName", "")
1067
+
1068
+ if not download_url:
1069
+ print("❌ generation finished but no download URL was returned", file=sys.stderr)
1070
+ sys.exit(1)
1071
+
1072
+ print(f"\n🎉 Jianying draft generated successfully!")
1073
+ if file_name:
1074
+ print(f" filename: {file_name}")
1075
+ print(f" download URL: {download_url}")
1076
+
1077
+ # 默认下载 ZIP;含 textLayers/subtitleText 时强制下载以便后处理
1078
+ if args.no_download and not needs_postprocess:
1079
+ print(f"\n💡 To download the ZIP, drop --no-download (or open the URL above)")
1080
+ return
1081
+
1082
+ if args.no_download and needs_postprocess:
1083
+ print("⚠️ textLayers/subtitleText detected; --no-download is ignored (post-processing is local)")
1084
+
1085
+ output_path = args.output or f"jianying_draft_{int(time.time())}.zip"
1086
+ download_zip(download_url, output_path)
1087
+
1088
+ # 后处理 textLayers / Pill 字幕样式
1089
+ if needs_postprocess:
1090
+ postprocess_draft_zip(output_path, scenes)
1091
+
1092
+ print(f"\n💡 How to import into Jianying:")
1093
+ print(f" 1. Extract {output_path} into the Jianying draft directory:")
1094
+ print(f" {draft_root_path}")
1095
+ print(f" 2. Start or restart Jianying; the draft will appear in your project list")
1096
+
1097
+
1098
+ if __name__ == "__main__":
1099
+ main()