@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,2288 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ่ง†้ข‘ๆธฒๆŸ“ๅทฅๅ…ท - ๆŽฅๆ”ถ DSL + TemplateBinding๏ผŒ่กฅ้ฝ็ด ๆ๏ผŒ่ฐƒ็”จ Remotion ๆธฒๆŸ“
4
+
5
+ ้ป˜่ฎค่กŒไธบ:
6
+ ่ฏปๅ– DSL ไธŽ TemplateBinding๏ผŒ่งฃๆž็ผบๅคฑ็ด ๆ๏ผŒ่ฐƒ็”จๅŽŸๅญ Skills ็”Ÿๆˆ๏ผŒ
7
+ ็ผ–่ฏ‘ๆ—ถ้—ด็บฟ๏ผŒๆœ€็ปˆ่ฐƒ็”จ Remotion ๆธฒๆŸ“ๅผ•ๆ“Ž่พ“ๅ‡บ่ง†้ข‘ใ€‚
8
+
9
+ ็”จๆณ•:
10
+ python render_video.py --dsl video.dsl.json --template-id screen-walkthrough
11
+ python render_video.py --dsl video.dsl.json --template-id screen-walkthrough --resolve-only
12
+ python render_video.py --render-plan video.render-plan.json
13
+
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
+ """
25
+
26
+ import argparse
27
+ import builtins
28
+ import json
29
+ import os
30
+ import re
31
+ import subprocess
32
+ import sys
33
+ import tempfile
34
+ import time
35
+ import urllib.error
36
+ import urllib.request
37
+ from datetime import datetime, timezone
38
+ from typing import Optional
39
+
40
+ # โ”€โ”€ Cross-skill dependency โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
41
+ # Reach into `skills/template-bind/` โ€” that directory is the de-facto home
42
+ # for shared Python code in ab-skill (timeline-compilation, registry loader,
43
+ # render-job HTTP client, etc.). Naming it `_SHARED_LIB_DIR` rather than
44
+ # `_TEMPLATE_BIND_DIR` reflects its actual role: it hosts much more than
45
+ # template-binding now (see AGENTS.md "skills/template-bind/scripts/" note).
46
+ #
47
+ # If signatures of the functions below change, also update:
48
+ # - skills/template-bind/video_dsl/runtime/timeline_compiler.py (the source)
49
+ # - any other caller discoverable via `grep -r "split_subtitle\|segment_narration"`
50
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
51
+ _SHARED_LIB_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "template-bind")
52
+ # Single sys.path setup for both cross-skill import surfaces of the shared lib:
53
+ # - <shared> for `video_dsl.runtime.*`
54
+ # - <shared>/scripts for `registry_loader`, `match_template`,
55
+ # `render_job_client`, `template_paths`
56
+ # Each deeper function used to repeat its own sys.path.insert; consolidating
57
+ # here keeps the module's import side-effects in one place and matches
58
+ # Python's "set up sys.path once at module top" idiom.
59
+ sys.path.insert(0, _SHARED_LIB_DIR)
60
+ sys.path.insert(0, os.path.join(_SHARED_LIB_DIR, "scripts"))
61
+ from video_dsl.runtime.timeline_compiler import (
62
+ split_subtitle,
63
+ split_subtitle_from_lines,
64
+ segment_narration,
65
+ )
66
+
67
+ # ๅญ—ๅน•ๆœซๅฐพๆ ‡็‚นๅŽป้™ค๏ผˆไธŽ timeline_compiler ไธญ็š„ _strip_trailing_punct ๅŒ้€ป่พ‘๏ผ‰
68
+ _TRAILING_PUNCT_RE = re.compile(r"[ใ€‚๏ผ๏ผŸ๏ผ›๏ผŒ,ใ€๏ผš:๏ผŽ.โ€ฆ]+$")
69
+
70
+ def _strip_subtitle_trailing_punct(text: str) -> str:
71
+ """ๅŽปๆމๅญ—ๅน•ๆฎตๆœซๅฐพ็š„ๆ ‡็‚น็ฌฆๅท๏ผŒ่ฎฉ็”ป้ขๆ›ดๅนฒๅ‡€ใ€‚"""
72
+ return _TRAILING_PUNCT_RE.sub("", text)
73
+
74
+
75
+ # โ”€โ”€ ๅญ—ๅน•ๆฎตไบŒๆฌกๅนณ่กก๏ผˆๅˆ‡้•ฟ + ๅˆ็Ÿญ๏ผ‰ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
76
+
77
+ _SUB_MAX_CHARS = 18 # ่ถ…่ฟ‡ๆญค้•ฟๅบฆ็š„ๆฎตๅฐ่ฏ•ไบŒๆฌกๅˆ‡ๅˆ†
78
+ _SUB_MIN_CHARS = 7 # ็ŸญไบŽๆญค้•ฟๅบฆ็š„ๆฎตๅฐ่ฏ•ๅˆๅนถๅˆฐ็›ธ้‚ปๆฎต
79
+ _SUB_SPLIT_RE = re.compile(r"(?<=[๏ผŒ,ใ€๏ผ›;])") # ไบŒๆฌกๅˆ‡ๅˆ†็‚น๏ผš้กฟๅท/้€—ๅท/ๅˆ†ๅทๅŽ
80
+
81
+
82
+ def _rebalance_subtitle_segments(segs: list[dict]) -> list[dict]:
83
+ """ๅฏน TTS ่ฟ”ๅ›ž็š„ๅญ—ๅน•ๆฎตๅšไบŒๆฌกๅนณ่กก๏ผš
84
+ 1. ่ฟ‡้•ฟ็š„ๆฎต๏ผˆ> _SUB_MAX_CHARS๏ผ‰ๅœจ้€—ๅท/้กฟๅทๅค„ๅˆ‡ๅผ€๏ผŒๆ—ถ้—ดๆŒ‰ๅญ—็ฌฆๆฏ”ไพ‹ๅˆ†้…
85
+ 2. ่ฟ‡็Ÿญ็š„ๆฎต๏ผˆ< _SUB_MIN_CHARS๏ผ‰ๅˆๅนถๅˆฐๅ‰ไธ€ๆฎต๏ผˆๅ…ฑไบซๆ—ถ้—ด็ช—ๅฃ๏ผ‰
86
+ """
87
+ if not segs:
88
+ return segs
89
+
90
+ # Phase 1: ๅˆ‡้•ฟ
91
+ expanded: list[dict] = []
92
+ for seg in segs:
93
+ text = seg["text"]
94
+ if len(text) <= _SUB_MAX_CHARS:
95
+ expanded.append(seg)
96
+ continue
97
+ # ๅฐ่ฏ•ๅœจ้€—ๅท/้กฟๅทๅค„ๅˆ‡ๅˆ†
98
+ parts = _SUB_SPLIT_RE.split(text)
99
+ parts = [p for p in parts if p.strip()]
100
+ if len(parts) <= 1:
101
+ # ๆฒกๆœ‰ๅˆ้€‚็š„ๅˆ‡ๅˆ†็‚น๏ผŒไฟๆŒๅŽŸๆ ท
102
+ expanded.append(seg)
103
+ continue
104
+ # ๆŒ‰ๅญ—็ฌฆๆฏ”ไพ‹ๅˆ†้…ๆ—ถ้—ด
105
+ total_chars = max(sum(len(p) for p in parts), 1)
106
+ total_frames = seg["endFrame"] - seg["startFrame"]
107
+ cur_frame = seg["startFrame"]
108
+ for p in parts:
109
+ ratio = len(p) / total_chars
110
+ frames = max(int(total_frames * ratio), 1)
111
+ expanded.append({
112
+ "text": _strip_subtitle_trailing_punct(p.strip()),
113
+ "startFrame": cur_frame,
114
+ "endFrame": cur_frame + frames,
115
+ })
116
+ cur_frame += frames
117
+ # ไฟฎๆญฃๆœ€ๅŽไธ€ๆฎต็š„ endFrame ๅฏน้ฝ
118
+ if expanded:
119
+ expanded[-1]["endFrame"] = seg["endFrame"]
120
+
121
+ # Phase 2: ๅˆ็Ÿญ๏ผˆๆŠŠ่ฟ‡็Ÿญ็š„ๆฎตๅˆๅนถๅˆฐๅ‰ไธ€ๆฎต๏ผ‰
122
+ if len(expanded) <= 1:
123
+ return expanded
124
+ merged: list[dict] = [expanded[0]]
125
+ for seg in expanded[1:]:
126
+ if len(seg["text"]) < _SUB_MIN_CHARS and merged:
127
+ # ๅˆๅนถๅˆฐๅ‰ไธ€ๆฎต๏ผšๆ–‡ๆœฌๆ‹ผๆŽฅ๏ผŒendFrame ๅ–ๅŽ่€…
128
+ merged[-1]["text"] = merged[-1]["text"] + seg["text"]
129
+ merged[-1]["endFrame"] = seg["endFrame"]
130
+ else:
131
+ merged.append(seg)
132
+ # ๆœ€ๅŽไธ€ๆฎตๅฆ‚ๆžœๅคช็ŸญไนŸๅˆๅนถ
133
+ if len(merged) > 1 and len(merged[-1]["text"]) < _SUB_MIN_CHARS:
134
+ merged[-2]["text"] = merged[-2]["text"] + merged[-1]["text"]
135
+ merged[-2]["endFrame"] = merged[-1]["endFrame"]
136
+ merged.pop()
137
+
138
+ return merged
139
+
140
+ # remotion-renderer ไฝไบŽ monorepo ็š„ apps/ab-render/ ็›ฎๅฝ•ใ€‚่„šๆœฌๆŒ‰็›ธๅฏนไฝ็ฝฎๆŽจๆ–ญ
141
+ # (skills/render-video/scripts/ โ†’ ../../../../apps/ab-render)ใ€‚
142
+ # ่‹ฅ skill ่ขซๅ•็‹ฌ clone ๆˆ–่ทฏๅพ„ไธๅŒ๏ผŒ้€š่ฟ‡ REMOTION_RENDERER_DIR ็Žฏๅขƒๅ˜้‡่ฆ†็›–ใ€‚
143
+ _DEFAULT_RENDERER_DIR = os.path.abspath(
144
+ os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "apps", "ab-render")
145
+ )
146
+ REMOTION_RENDERER_DIR = os.environ.get("REMOTION_RENDERER_DIR", _DEFAULT_RENDERER_DIR)
147
+ # ้ป˜่ฎคๆŒ‰่ฐƒ็”จๅˆ›ๅปบ็‹ฌ็ซ‹ tempdir๏ผŒ้ฟๅ…ๅคš็”จๆˆทๅนถๅ‘ๆ—ถ่ฝ็›˜ๆ–‡ไปถไบ’็›ธ่ฆ†็›–ใ€‚
148
+ # ่ฐƒ่ฏ•ๅœบๆ™ฏๅฏๆ˜พๅผ่ฎพ็ฝฎ REMOTION_OUTPUT_DIR ๆŒ‡ๅ‘ๅ›บๅฎš่ทฏๅพ„ใ€‚
149
+ OUTPUT_DIR = os.environ.get("REMOTION_OUTPUT_DIR") or tempfile.mkdtemp(prefix="ab-render-")
150
+ ASSET_CACHE_DIR = os.environ.get("ASSET_CACHE_DIR", "./.asset-cache")
151
+
152
+ RESOLUTION_MAP = {
153
+ "16:9": {"1080p": (1920, 1080), "720p": (1280, 720), "4k": (3840, 2160)},
154
+ "9:16": {"1080p": (1080, 1920), "720p": (720, 1280), "4k": (2160, 3840)},
155
+ "1:1": {"1080p": (1080, 1080), "720p": (720, 720), "4k": (2160, 2160)},
156
+ "4:3": {"1080p": (1440, 1080), "720p": (960, 720), "4k": (2880, 2160)},
157
+ "3:4": {"1080p": (1080, 1440), "720p": (720, 960), "4k": (2160, 2880)},
158
+ }
159
+
160
+
161
+ def extract_narration_lines(narration: dict) -> Optional[tuple[list[str], int, list[Optional[float]]]]:
162
+ """If narration uses the structured {intro, items, outro} form, return
163
+ (lines, intro_line_count, at_sec_list). `at_sec_list` is parallel to
164
+ `lines` and contains the per-line `atSec` hint (video-timeline offset
165
+ in seconds) when the DSL author specified one, or None otherwise.
166
+ Returns None if the narration only uses the flat `text` form.
167
+ """
168
+ if not narration:
169
+ return None
170
+ items = narration.get("items")
171
+ if not items or not isinstance(items, list):
172
+ return None
173
+ intro = (narration.get("intro") or "").strip()
174
+ outro = (narration.get("outro") or "").strip()
175
+
176
+ def _coerce_item(x) -> tuple[str, Optional[float]]:
177
+ if isinstance(x, dict):
178
+ text = str(x.get("text", "")).strip()
179
+ at = x.get("atSec")
180
+ at_val = float(at) if isinstance(at, (int, float)) else None
181
+ return text, at_val
182
+ return str(x).strip(), None
183
+
184
+ cleaned: list[tuple[str, Optional[float]]] = [
185
+ (t, a) for (t, a) in (_coerce_item(x) for x in items) if t
186
+ ]
187
+ if not cleaned:
188
+ return None
189
+ lines: list[str] = []
190
+ at_secs: list[Optional[float]] = []
191
+ intro_lines = 0
192
+ if intro:
193
+ lines.append(intro)
194
+ at_secs.append(None)
195
+ intro_lines = 1
196
+ for text, at in cleaned:
197
+ lines.append(text)
198
+ at_secs.append(at)
199
+ if outro:
200
+ lines.append(outro)
201
+ at_secs.append(None)
202
+ return lines, intro_lines, at_secs
203
+
204
+ _SKILLS_BASE_DIR = os.environ.get(
205
+ "SKILLS_BASE_DIR",
206
+ os.path.join(os.path.dirname(__file__), "..", "..")
207
+ )
208
+
209
+
210
+ def _resolve_ab_skill_cli_path() -> str | None:
211
+ """Locate the compiled ab-skill CLI (dist/cli.js).
212
+
213
+ Search order:
214
+ 1. AB_SKILL_CLI_PATH env (ab-agent always sets this)
215
+ 2. <_SKILLS_BASE_DIR>/../dist/cli.js (ab-skill source layout)
216
+ 3. <_SKILLS_BASE_DIR>/../skills-cli/cli.js (ab-agent bundled layout)
217
+
218
+ Returns None when nothing is found. The caller's error message then
219
+ says exactly what's missing.
220
+ """
221
+ explicit = os.environ.get("AB_SKILL_CLI_PATH", "").strip()
222
+ if explicit and os.path.exists(explicit):
223
+ return explicit
224
+ candidates = [
225
+ os.path.normpath(os.path.join(_SKILLS_BASE_DIR, "..", "dist", "cli.js")),
226
+ os.path.normpath(os.path.join(_SKILLS_BASE_DIR, "..", "skills-cli", "cli.js")),
227
+ ]
228
+ for c in candidates:
229
+ if os.path.exists(c):
230
+ return c
231
+ return None
232
+
233
+
234
+ def build_skill_command(skill_name: str) -> list[str]:
235
+ """Return the argv prefix to invoke sibling skill `skill_name`.
236
+
237
+ Reads <_SKILLS_BASE_DIR>/<skill_name>/skill.json once and dispatches by
238
+ entry.type:
239
+ - python โ†’ [sys.executable, "<skillDir>/<scriptPath>"]
240
+ - http / builtin โ†’ [node, "<ab-skill-cli>", skill_name]
241
+
242
+ Centralizing the dispatch here means the 4 resolve_asset_* functions
243
+ only need to append their per-call --flag value pairs to the prefix โ€”
244
+ they don't have to know whether the sibling is Python or TS.
245
+
246
+ Raises RuntimeError with a precise message when skill.json / script
247
+ file / CLI binary is missing, so subprocess.run gets a useful failure
248
+ rather than a generic "file not found".
249
+ """
250
+ skill_dir = os.path.join(_SKILLS_BASE_DIR, skill_name)
251
+ skill_json_path = os.path.join(skill_dir, "skill.json")
252
+ if not os.path.exists(skill_json_path):
253
+ raise RuntimeError(f"skill.json not found: {skill_json_path}")
254
+ with open(skill_json_path, "r", encoding="utf-8") as f:
255
+ meta = json.load(f)
256
+
257
+ entry = meta.get("entry")
258
+ if not entry and meta.get("scriptPath"):
259
+ entry = {"type": "python", "scriptPath": meta["scriptPath"]}
260
+ if not isinstance(entry, dict):
261
+ raise RuntimeError(f"skill {skill_name}: missing entry/scriptPath in skill.json")
262
+
263
+ entry_type = entry.get("type")
264
+ if entry_type == "python":
265
+ script_rel = entry.get("scriptPath")
266
+ if not script_rel:
267
+ raise RuntimeError(f"skill {skill_name}: entry.scriptPath empty")
268
+ script_abs = os.path.join(skill_dir, script_rel)
269
+ if not os.path.exists(script_abs):
270
+ raise RuntimeError(f"Script not found: {script_abs}")
271
+ return [sys.executable, script_abs]
272
+
273
+ if entry_type in ("http", "builtin"):
274
+ cli_path = _resolve_ab_skill_cli_path()
275
+ if not cli_path:
276
+ raise RuntimeError(
277
+ f"skill {skill_name} has entry.type={entry_type} but ab-skill CLI not found. "
278
+ f"Set AB_SKILL_CLI_PATH or rebuild via `npm run install-skills` in ab-agent."
279
+ )
280
+ return ["node", cli_path, skill_name]
281
+
282
+ raise RuntimeError(f"skill {skill_name}: unknown entry.type={entry_type!r}")
283
+
284
+
285
+ def resolve_remotion_entry(template_id: str, aspect_ratio: str) -> str:
286
+ """Look up the top-level Remotion composition for (templateId, aspectRatio).
287
+
288
+ Reads ``remotionEntry[aspect_ratio]`` from the template's registry entry.
289
+ The registry is the canonical source (matches what ab-render's manifest
290
+ consumes); the previous implementation read a local ``template.json`` via
291
+ a ``TEMPLATES_DIR`` env override that pointed at a directory which no
292
+ longer exists in the repo โ€” so the function always fell back to the
293
+ hard-coded defaults regardless of what the template declared. Switching
294
+ to the registry preserves the fallback for templates that don't declare
295
+ ``remotionEntry`` and lets ones that DO declare it take effect (e.g.
296
+ screen-walkthrough's 9:16 entry).
297
+
298
+ Falls back to ``MainVideo16x9`` / ``MainVideo`` when the template isn't
299
+ found, the registry can't be loaded, or the aspect ratio isn't keyed.
300
+ """
301
+ if template_id:
302
+ try:
303
+ from registry_loader import get_template # type: ignore
304
+ tpl = get_template(template_id)
305
+ if tpl:
306
+ entry_map = tpl.get("remotionEntry") or {}
307
+ hit = entry_map.get(aspect_ratio)
308
+ if isinstance(hit, str) and hit:
309
+ return hit
310
+ except Exception:
311
+ pass
312
+ return "MainVideo16x9" if aspect_ratio == "16:9" else "MainVideo"
313
+
314
+
315
+ def resolve_cover_composition_id(template_id: str) -> Optional[str]:
316
+ """Look up the cover compositionId for a template via the registry.
317
+
318
+ Each template's `compositions[]` array may contain at most one entry with
319
+ `slot == "cover"`. We return its compositionId, or None if the template
320
+ doesn't ship a cover.
321
+
322
+ Reading from the registry rather than the template.json file directly so
323
+ behaviour matches what ab-render's manifest exposes (the registry is the
324
+ aggregated truth used by both ab-render and ab-skill).
325
+ """
326
+ if not template_id:
327
+ return None
328
+ try:
329
+ # sys.path setup happens once at module top โ€” see header.
330
+ from registry_loader import get_template # type: ignore
331
+
332
+ tpl = get_template(template_id)
333
+ if not tpl:
334
+ return None
335
+ for comp in tpl.get("compositions", []) or []:
336
+ if comp.get("slot") == "cover":
337
+ cid = comp.get("compositionId")
338
+ if isinstance(cid, str) and cid:
339
+ return cid
340
+ except Exception:
341
+ pass
342
+ return None
343
+
344
+
345
+ def now_iso():
346
+ return datetime.now(timezone.utc).isoformat()
347
+
348
+
349
+ def LogPrint(*args, sep=" ", end="\n", file=None, flush=False):
350
+ """ๅธฆๆœฌๅœฐๆ—ถ้—ดๆˆณ็š„ stderr/stdout ๆ—ฅๅฟ—๏ผŒๆ ผๅผ yyyyMMdd HHmmss:SSS๏ผˆๆฏซ็ง’๏ผ‰ใ€‚"""
351
+ now = datetime.now()
352
+ stamp = now.strftime("%Y%m%d %H%M%S") + f":{now.microsecond // 1000:03d}"
353
+ if file is None:
354
+ file = sys.stdout
355
+ message = sep.join(str(a) for a in args)
356
+ builtins.print(f"[{stamp}] {message}", end=end, file=file, flush=flush)
357
+
358
+
359
+ def sync_chrome_headless_vendor(renderer_dir: str, render_plan: dict) -> None:
360
+ """Chrome Headless vendor ๅŒๆญฅโ€”โ€”ๅฎž็ŽฐๅทฒๆŠฝๅˆฐ็‹ฌ็ซ‹ๆจกๅ— ``_chrome_vendor``ใ€‚
361
+
362
+ ไฟ็•™่ฟ™ไธช thin wrapper ๆ˜ฏไธบไบ†๏ผš
363
+ 1. ``render_with_local_cli`` ็Žฐๆœ‰่ฐƒ็”จ็‚นๆ— ้œ€ๆ”นๅ๏ผ›
364
+ 2. ่ฎฉ render_video.py ่‡ช่บซๅชๅ…ณๅฟƒ"ๆธฒๆŸ“่ฐƒๅบฆ"๏ผ›vendor ้ƒจ็ฝฒ็ป†่Š‚็•™็ป™็‹ฌ็ซ‹ๆจกๅ—ใ€‚
365
+ """
366
+ from _chrome_vendor import sync_chrome_headless_vendor as _sync
367
+ _sync(renderer_dir, render_plan)
368
+
369
+
370
+ def load_json(path: str) -> dict:
371
+ with open(path, "r", encoding="utf-8") as f:
372
+ return json.load(f)
373
+
374
+
375
+ def save_json(data: dict, path: str):
376
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
377
+ with open(path, "w", encoding="utf-8") as f:
378
+ json.dump(data, f, ensure_ascii=False, indent=2)
379
+
380
+
381
+ def validate_dsl(dsl: dict) -> list:
382
+ """Integrity DSL validation โ€” delegates to the unified validator.
383
+
384
+ Historical rule set (preserved verbatim by ``validate_integrity``):
385
+ version + scene count + assetId duplicates + ``scene.audio.narration
386
+ .assetRef`` / ``scene.visuals.background.assetRef`` reference integrity
387
+ + structured narration items count vs templateData. ``meta.title`` and
388
+ ``global`` presence are intentionally NOT enforced here โ€” by the time
389
+ a DSL reaches render_video those have already been gated upstream, and
390
+ enforcing them again would change historical behavior.
391
+ """
392
+ from video_dsl.runtime.dsl_validator import ( # noqa: E402
393
+ validate_integrity,
394
+ errors_as_strings,
395
+ )
396
+ return errors_as_strings(validate_integrity(dsl))
397
+
398
+
399
+ def resolve_dimensions(dsl: dict) -> tuple:
400
+ ratio = dsl.get("global", {}).get("aspectRatio", "16:9")
401
+ resolution = dsl.get("global", {}).get("resolution", "1080p")
402
+ dims = RESOLUTION_MAP.get(ratio, RESOLUTION_MAP["16:9"])
403
+ return dims.get(resolution, dims.get("1080p", (1920, 1080)))
404
+
405
+
406
+ def validate_and_fix_render_plan(render_plan: dict) -> list:
407
+ """Validate RenderPlan integrity and auto-fix recoverable issues.
408
+
409
+ Checks performed:
410
+ 1. subtitleSegments frame numbers within scene [startFrame, endFrame]
411
+ 2. Video assets have non-null duration (required for screen-walkthrough)
412
+ 3. Timeline frame continuity (prev.endFrame == next.startFrame)
413
+ 4. All generated assets have a URL
414
+
415
+ Returns list of warning messages (empty = all good).
416
+ Mutates render_plan in-place to fix issues.
417
+ """
418
+ warnings: list[str] = []
419
+ fps = render_plan.get("renderConfig", {}).get("fps", 30)
420
+
421
+ # โ”€โ”€ Check 1: subtitle frame bounds โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
422
+ for entry in render_plan.get("timeline", []):
423
+ scene_id = entry.get("sceneId", "?")
424
+ scene_start = entry.get("startFrame", 0)
425
+ scene_end = entry.get("endFrame", scene_start + entry.get("durationFrames", 0))
426
+
427
+ for seg in entry.get("subtitleSegments", []):
428
+ fixed = False
429
+ if seg["startFrame"] < scene_start:
430
+ warnings.append(
431
+ f"[fix] {scene_id}: subtitle startFrame {seg['startFrame']} < scene start {scene_start}, clamped"
432
+ )
433
+ seg["startFrame"] = scene_start
434
+ fixed = True
435
+ if seg["endFrame"] > scene_end:
436
+ warnings.append(
437
+ f"[fix] {scene_id}: subtitle endFrame {seg['endFrame']} > scene end {scene_end}, clamped"
438
+ )
439
+ seg["endFrame"] = scene_end
440
+ fixed = True
441
+ if seg["startFrame"] >= seg["endFrame"]:
442
+ # Degenerate segment after clamping โ€” give it at least 1 frame
443
+ seg["endFrame"] = min(seg["startFrame"] + max(1, int(fps * 0.5)), scene_end)
444
+
445
+ # โ”€โ”€ Check 2: video asset duration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
446
+ # Whether the template needs every video asset to declare a non-null
447
+ # duration is a TEMPLATE CAPABILITY, not a property of ab-skill. The
448
+ # capability lives in template.json under ``capabilities.needsVideoDuration``;
449
+ # ab-skill simply reads + applies it. New templates that need this guarantee
450
+ # only have to declare the field โ€” they don't have to touch this code.
451
+ template_id = render_plan.get("templateId", "")
452
+ needs_video_duration = False
453
+ if template_id:
454
+ try:
455
+ from registry_loader import get_template # type: ignore
456
+ _tpl = get_template(template_id)
457
+ needs_video_duration = bool(
458
+ ((_tpl or {}).get("capabilities") or {}).get("needsVideoDuration")
459
+ )
460
+ except Exception:
461
+ needs_video_duration = False
462
+
463
+ for asset in render_plan.get("resolvedAssets", []):
464
+ if asset.get("type") != "video":
465
+ continue
466
+ if asset.get("duration") is None and needs_video_duration:
467
+ # Fallback: use the scene duration of whichever scene references this asset
468
+ # via props.videoAssetId / backgroundAssetId / imageAssetId.
469
+ fallback_dur = None
470
+ for entry in render_plan.get("timeline", []):
471
+ props = entry.get("props") or {}
472
+ refs = (
473
+ props.get("videoAssetId"),
474
+ props.get("backgroundAssetId"),
475
+ props.get("imageAssetId"),
476
+ )
477
+ if asset.get("assetId") in refs:
478
+ fallback_dur = entry.get("durationFrames", 150) / fps
479
+ break
480
+ if fallback_dur:
481
+ asset["duration"] = fallback_dur
482
+ warnings.append(
483
+ f"[fix] asset {asset.get('assetId')}: duration was null, set to scene duration {fallback_dur:.1f}s"
484
+ )
485
+
486
+ # โ”€โ”€ Check 3: timeline frame continuity โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
487
+ timeline = render_plan.get("timeline", [])
488
+ for i in range(1, len(timeline)):
489
+ prev_end = timeline[i - 1].get("endFrame", 0)
490
+ curr_start = timeline[i].get("startFrame", 0)
491
+ if curr_start != prev_end:
492
+ warnings.append(
493
+ f"[fix] timeline gap: {timeline[i-1].get('sceneId')}.endFrame={prev_end} != "
494
+ f"{timeline[i].get('sceneId')}.startFrame={curr_start}, correcting"
495
+ )
496
+ # Shift current and all subsequent scenes
497
+ offset = prev_end - curr_start
498
+ for j in range(i, len(timeline)):
499
+ timeline[j]["startFrame"] += offset
500
+ timeline[j]["endFrame"] += offset
501
+ timeline[j]["startTime"] = round(timeline[j]["startFrame"] / fps, 2)
502
+ timeline[j]["endTime"] = round(timeline[j]["endFrame"] / fps, 2)
503
+ for seg in timeline[j].get("subtitleSegments", []):
504
+ seg["startFrame"] += offset
505
+ seg["endFrame"] += offset
506
+ # Update total
507
+ last = timeline[-1]
508
+ render_plan["renderConfig"]["totalFrames"] = last["endFrame"]
509
+ render_plan["renderConfig"]["totalDuration"] = round(last["endFrame"] / fps, 2)
510
+ break # re-check from start would be needed for multiple gaps, but rare
511
+
512
+ # โ”€โ”€ Check 4: generated assets have URL โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
513
+ for asset in render_plan.get("resolvedAssets", []):
514
+ if asset.get("status") == "generated" and not asset.get("url"):
515
+ warnings.append(
516
+ f"[warn] asset {asset.get('assetId')}: status=generated but url is empty"
517
+ )
518
+
519
+ # Log warnings into render_plan
520
+ if warnings:
521
+ for w in warnings:
522
+ render_plan.setdefault("logs", []).append({
523
+ "phase": "validate-fix",
524
+ "message": w,
525
+ "timestamp": now_iso(),
526
+ })
527
+ LogPrint(f"โš ๏ธ RenderPlan validation found {len(warnings)} issue(s) (auto-fixed):", file=sys.stderr)
528
+ for w in warnings:
529
+ LogPrint(f" {w}", file=sys.stderr)
530
+
531
+ return warnings
532
+
533
+
534
+ def _build_bgm_props(dsl: dict) -> dict:
535
+ """Extract BGM config from DSL global settings and return as Remotion props."""
536
+ bgm = dsl.get("global", {}).get("bgm", {})
537
+ if bgm.get("enabled") and bgm.get("url"):
538
+ return {"bgm": {"url": bgm["url"], "volume": bgm.get("volume", 0.15)}}
539
+ return {}
540
+
541
+
542
+ def _probe_video_duration(url: str):
543
+ """Backwards-compat wrapper โ€” implementation lives in ``_video_probe`` module."""
544
+ from _video_probe import probe_video_duration
545
+ return probe_video_duration(url)
546
+
547
+
548
+ def build_render_plan(dsl: dict, binding: dict) -> dict:
549
+ """Build a RenderPlan from DSL and TemplateBinding."""
550
+ fps = dsl.get("global", {}).get("fps", 30)
551
+ width, height = resolve_dimensions(dsl)
552
+
553
+ binding_map = {}
554
+ for b in binding.get("bindings", []):
555
+ binding_map[b["sceneId"]] = b
556
+
557
+ # Pre-step: copy narration text from scenes[].audio.narration.text down
558
+ # into the matching audio asset's payload, so resolve_asset_audio (which
559
+ # only sees the asset, not the scene) can call gen-voice with the right
560
+ # text. narration.text is the single source of truth โ€” DSL authors do
561
+ # NOT need to populate audio asset payload.text themselves.
562
+ #
563
+ # Structured narration form ({intro, items, outro}) is also expanded here
564
+ # so adjust_timeline_to_audio can later auto-derive highlightMap from
565
+ # per-line TTS timestamps.
566
+ dsl_assets_by_id = {a["assetId"]: a for a in dsl.get("assets", [])}
567
+ for scene in dsl.get("scenes", []):
568
+ narration = (scene.get("audio") or {}).get("narration") or {}
569
+ ref = narration.get("assetRef")
570
+ asset = dsl_assets_by_id.get(ref)
571
+ if not asset:
572
+ continue
573
+ payload = asset.setdefault("payload", {})
574
+ extracted = extract_narration_lines(narration)
575
+ if extracted:
576
+ lines, intro_lines, _ = extracted
577
+ payload["narrationItems"] = lines
578
+ payload["narrationIntroLines"] = intro_lines
579
+ payload["text"] = "\n".join(lines)
580
+ else:
581
+ text = narration.get("text", "")
582
+ if text:
583
+ payload["text"] = text
584
+
585
+ assets_by_id = {}
586
+ for asset in dsl.get("assets", []):
587
+ # url ไผ˜ๅ…ˆ่ฏป้กถๅฑ‚ๅญ—ๆฎต๏ผŒๅ…ถๆฌก fallback ๅˆฐ payload.url๏ผˆๅ…ผๅฎน source=url ๅ†™ๆณ•๏ผ‰
588
+ asset_url = asset.get("url", "") or (asset.get("payload") or {}).get("url", "")
589
+ assets_by_id[asset["assetId"]] = {
590
+ "assetId": asset["assetId"],
591
+ "type": asset.get("type", ""),
592
+ "source": asset.get("source", "existing"),
593
+ "status": "pending" if asset.get("status") in ("planned", "missing") else asset.get("status", "pending"),
594
+ "url": asset_url,
595
+ "localPath": asset.get("localPath", ""),
596
+ "duration": asset.get("duration"),
597
+ "width": asset.get("width"),
598
+ "height": asset.get("height"),
599
+ "mimeType": asset.get("mimeType", ""),
600
+ "generatedBy": {},
601
+ "retryCount": 0,
602
+ "maxRetries": 3,
603
+ }
604
+
605
+ # โ”€โ”€ Probe duration for existing video assets without duration โ”€โ”€โ”€โ”€โ”€โ”€
606
+ # screen-walkthrough's adaptStrategy needs videoDurationSec to avoid
607
+ # falling back to static-fallback. Probe via partial download + ffprobe
608
+ # or fall back to a heuristic based on Content-Length.
609
+ for aid, asset in assets_by_id.items():
610
+ if asset["type"] == "video" and asset["source"] == "existing" and not asset.get("duration") and asset.get("url"):
611
+ probed = _probe_video_duration(asset["url"])
612
+ if probed:
613
+ asset["duration"] = probed
614
+
615
+ timeline = []
616
+ current_frame = 0
617
+
618
+ for idx, scene in enumerate(dsl.get("scenes", [])):
619
+ scene_id = scene.get("id") or scene.get("sceneId") or f"scene-{idx:03d}"
620
+ duration = scene.get("duration", 5)
621
+ duration_frames = int(duration * fps)
622
+
623
+ scene_binding = binding_map.get(scene_id, {})
624
+
625
+ # P2.3: ไธๅ†ไบงๅ‡บ entry.layersใ€‚่ƒŒๆ™ฏ่ง†่ง‰่ต„ไบงใ€ๆ—็™ฝ้Ÿณ้ข‘ใ€ๆ–‡ๆœฌๅ›พๅฑ‚่ฟ™ไบ›
626
+ # ไฟกๆฏๅ…จ้ƒฝ้€š่ฟ‡ propExtractors โ†’ binding.props ๆ˜พๅผไผ ็ป™ๆจกๆฟ๏ผˆไพ‹ๅฆ‚
627
+ # backgroundAssetId / narrationAssetId / titleText๏ผ‰๏ผŒๆจกๆฟ็”จ props ๅณๅฏใ€‚
628
+
629
+ narration = (scene.get("audio") or {}).get("narration") or {}
630
+
631
+ narration_text = narration.get("text", "")
632
+ extracted = extract_narration_lines(narration)
633
+ subtitle_at_sec: list[Optional[float]] = []
634
+ if extracted:
635
+ # Honour the authored line boundaries exactly (no secondary
636
+ # comma-split, no short-fragment merge) so the fallback
637
+ # subtitle count equals len(lines) โ€” which is what
638
+ # _auto_highlight_map expects when TTS timestamps are absent.
639
+ lines, _intro_lines, subtitle_at_sec = extracted
640
+ local_subs = split_subtitle_from_lines(lines, duration_frames, fps)
641
+ else:
642
+ local_subs = split_subtitle(narration_text, duration_frames, fps)
643
+ subtitle_segments = [
644
+ {
645
+ "text": s["text"],
646
+ "startFrame": s["startFrame"] + current_frame,
647
+ "endFrame": s["endFrame"] + current_frame,
648
+ }
649
+ for s in local_subs
650
+ ]
651
+
652
+ transition_config = dsl.get("transitions", {})
653
+ trans_type = transition_config.get("default", "fade")
654
+ trans_dur = int(transition_config.get("duration", 0.5) * fps)
655
+
656
+ # ้ฆ–ๅœบๆ™ฏไธๅš fade-in ่ฝฌๅœบ๏ผŒ้ฟๅ…ๅผ€ๅคด้ป‘ๅฑ
657
+ scene_trans_type = "cut" if idx == 0 else trans_type
658
+ scene_trans_dur = 0 if idx == 0 else trans_dur
659
+
660
+ # ้€ไผ  scene.customPayload.minDurationSec ๅˆฐ entry.minDurationFrames๏ผŒ
661
+ # ไพ› adjust_timeline_to_audio ๅœจ audio-driven ่ฎก็ฎ—ๆ—ถๅ–ไธ‹้™๏ผˆไธ่ฎฉ scene
662
+ # ่ขซๆ—็™ฝๆ—ถ้•ฟๆ‹็Ÿญ๏ผ‰ใ€‚
663
+ custom_payload = scene.get("customPayload") or {}
664
+ min_dur_sec = custom_payload.get("minDurationSec")
665
+ min_dur_frames = (
666
+ int(round(float(min_dur_sec) * fps))
667
+ if isinstance(min_dur_sec, (int, float)) and min_dur_sec > 0
668
+ else 0
669
+ )
670
+ # ้€ไผ  customPayload.tailPadSec ๅˆฐ entry.tailPadFrames๏ผŒ่ฆ†็›–
671
+ # adjust_timeline_to_audio ้ป˜่ฎค็š„ 1.5s ๆ—็™ฝๅฐพๅทดใ€‚่ฎพ 0 = ๆฒกๆœ‰ๅฐพๅทด๏ผŒ
672
+ # scene ่ดด้Ÿณ้ข‘็ป“ๆŸ็ซ‹ๅˆปๅˆ‡่ตฐใ€‚ๆœชๆŒ‡ๅฎšๆ—ถ๏ผˆNone๏ผ‰่ตฐๅ…จๅฑ€้ป˜่ฎค 1.5sใ€‚
673
+ tail_pad_sec = custom_payload.get("tailPadSec")
674
+ tail_pad_frames = (
675
+ int(round(float(tail_pad_sec) * fps))
676
+ if isinstance(tail_pad_sec, (int, float)) and tail_pad_sec >= 0
677
+ else None
678
+ )
679
+
680
+ entry = {
681
+ "sceneId": scene_id,
682
+ "startFrame": current_frame,
683
+ "endFrame": current_frame + duration_frames,
684
+ "durationFrames": duration_frames,
685
+ "minDurationFrames": min_dur_frames,
686
+ "tailPadFrames": tail_pad_frames,
687
+ "startTime": round(current_frame / fps, 2),
688
+ "endTime": round((current_frame + duration_frames) / fps, 2),
689
+ "compositionId": scene_binding.get("compositionId", "GenericScene"),
690
+ "props": scene_binding.get("props", {}),
691
+ "subtitleSegments": subtitle_segments,
692
+ "subtitleAtSec": subtitle_at_sec,
693
+ "transition": {"type": scene_trans_type, "durationFrames": scene_trans_dur},
694
+ }
695
+ timeline.append(entry)
696
+ current_frame += duration_frames
697
+
698
+ total_frames = current_frame
699
+
700
+ return {
701
+ "version": "v1alpha1",
702
+ "createdAt": now_iso(),
703
+ "status": "planning",
704
+ "templateId": binding.get("templateId", ""),
705
+ # ๆธฒๆŸ“ๆ—ถๅธธ็”จ็š„ DSL ๆ‘˜่ฆๅญ—ๆฎต๏ผŒ้ฟๅ…ไธ‹ๆธธๅ†ๅŽป dsl ๅ…จๆ–‡้‡Œๆžใ€‚
706
+ # ไธๅค็”จ DSL ็š„ meta๏ผˆdsl ๆ˜ฏๅ•ไธ€ไบ‹ๅฎžๆฅๆบ๏ผ‰๏ผŒๅชๆŒ‘ๅฟ…่ฆ็š„ๅ‡ ไธชไพ› UI/ไธŠไผ ไฝฟ็”จใ€‚
707
+ "title": dsl.get("meta", {}).get("title", ""),
708
+ "targetDuration": dsl.get("meta", {}).get("targetDuration"),
709
+ "resolvedAssets": list(assets_by_id.values()),
710
+ "timeline": timeline,
711
+ "renderConfig": {
712
+ "width": width,
713
+ "height": height,
714
+ "fps": fps,
715
+ "totalFrames": total_frames,
716
+ "totalDuration": round(total_frames / fps, 2),
717
+ "codec": "h264",
718
+ "crf": 18,
719
+ "outputFormat": "mp4",
720
+ },
721
+ "remotionProps": {
722
+ "compositionId": resolve_remotion_entry(
723
+ binding.get("templateId", ""),
724
+ dsl.get("global", {}).get("aspectRatio", "9:16"),
725
+ ),
726
+ "inputProps": {
727
+ "globalTypography": binding.get("globalOverrides", {}).get("typography", {}),
728
+ "motionPreset": binding.get("globalOverrides", {}).get("motionPreset", "smooth"),
729
+ "colorScheme": binding.get("globalOverrides", {}).get("colorScheme", []),
730
+ **({"variantId": binding["variantId"]} if binding.get("variantId") else {}),
731
+ **(_build_bgm_props(dsl)),
732
+ },
733
+ },
734
+ "errors": [],
735
+ "logs": [
736
+ {"phase": "validate", "message": "DSL schema validation passed", "timestamp": now_iso()},
737
+ {"phase": "template-bind", "message": f"Using template: {binding.get('templateId', 'unknown')}", "timestamp": now_iso()},
738
+ ],
739
+ }
740
+
741
+
742
+ def resolve_asset_image(asset: dict, private_token: str, timeout: int) -> dict:
743
+ """Resolve a single image asset by calling gen-image skill."""
744
+ payload = asset.get("payload", {}) if "payload" not in asset else asset["payload"]
745
+
746
+ try:
747
+ cmd = build_skill_command("gen-image")
748
+ except RuntimeError as e:
749
+ return {"status": "failed", "error": str(e)}
750
+
751
+ cmd.extend(["--prompt", payload.get("prompt", "placeholder image")])
752
+ if payload.get("ratio"):
753
+ cmd.extend(["--size", payload["ratio"]])
754
+ if payload.get("model"):
755
+ cmd.extend(["--model", payload["model"]])
756
+ if private_token:
757
+ cmd.extend(["--priv-token", private_token])
758
+
759
+ try:
760
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
761
+ if result.returncode == 0:
762
+ for line in reversed(result.stdout.strip().split("\n")):
763
+ line = line.strip()
764
+ if line.startswith("http"):
765
+ return {"status": "generated", "url": line}
766
+ return {"status": "failed", "error": result.stderr.strip()[:1000]}
767
+ except subprocess.TimeoutExpired:
768
+ return {"status": "failed", "error": "Asset generation timed out"}
769
+ except Exception as e:
770
+ return {"status": "failed", "error": str(e)[:1000]}
771
+
772
+
773
+ def resolve_asset_audio(asset: dict, private_token: str, timeout: int) -> dict:
774
+ """Resolve a single audio asset by calling gen-voice skill.
775
+
776
+ Pre-processes text with segment_narration() and newlines so Minimax
777
+ returns per-segment timestamps. Uses --json-output to capture metadata.
778
+
779
+ If the payload carries `narrationItems` (structured {intro,items,outro}
780
+ form), those lines are fed verbatim to Minimax so the returned subtitle
781
+ count matches the authored line count โ€” enabling an automatic 1:1
782
+ subtitle โ†’ card highlightMap in adjust_timeline_to_audio().
783
+ """
784
+ payload = asset.get("payload", {})
785
+
786
+ try:
787
+ cmd = build_skill_command("gen-voice")
788
+ except RuntimeError as e:
789
+ return {"status": "failed", "error": str(e)}
790
+
791
+ narration_items = payload.get("narrationItems")
792
+ if isinstance(narration_items, list) and narration_items:
793
+ cleaned = [str(s).strip() for s in narration_items if str(s).strip()]
794
+ tts_text = "\n".join(cleaned)
795
+ if not tts_text:
796
+ return {"status": "failed", "error": "No text provided for TTS"}
797
+ else:
798
+ text = payload.get("text", "")
799
+ if not text:
800
+ return {"status": "failed", "error": "No text provided for TTS"}
801
+ segments = segment_narration(text)
802
+ tts_text = "\n".join(s.strip() for s in segments) if segments else text
803
+
804
+ cmd.extend(["--text", tts_text, "--json-output"])
805
+ if payload.get("voiceId"):
806
+ cmd.extend(["--voice-id", payload["voiceId"]])
807
+ if private_token:
808
+ cmd.extend(["--priv-token", private_token])
809
+
810
+ try:
811
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
812
+ if result.returncode == 0:
813
+ for line in reversed(result.stdout.strip().split("\n")):
814
+ line = line.strip()
815
+ if not line:
816
+ continue
817
+ try:
818
+ meta = json.loads(line)
819
+ if meta.get("url"):
820
+ return {
821
+ "status": "generated",
822
+ "url": meta["url"],
823
+ "audio_length_ms": meta.get("audio_length_ms"),
824
+ "subtitles": meta.get("subtitles", []),
825
+ }
826
+ except json.JSONDecodeError:
827
+ if line.startswith("http"):
828
+ return {"status": "generated", "url": line}
829
+ return {"status": "failed", "error": result.stderr.strip()[:1000]}
830
+ except subprocess.TimeoutExpired:
831
+ return {"status": "failed", "error": "Asset generation timed out"}
832
+ except Exception as e:
833
+ return {"status": "failed", "error": str(e)[:1000]}
834
+
835
+
836
+ def resolve_asset_video(asset: dict, private_token: str, timeout: int) -> dict:
837
+ """Resolve a single video asset by calling gen-video skill."""
838
+ payload = asset.get("payload", {})
839
+
840
+ try:
841
+ cmd = build_skill_command("gen-video")
842
+ except RuntimeError as e:
843
+ return {"status": "failed", "error": str(e)}
844
+
845
+ cmd.extend(["--prompt", payload.get("prompt", "")])
846
+ if payload.get("duration"):
847
+ cmd.extend(["--duration", str(int(payload["duration"]))])
848
+ if payload.get("ratio"):
849
+ cmd.extend(["--ratio", payload["ratio"]])
850
+ if payload.get("model"):
851
+ cmd.extend(["--model", payload["model"]])
852
+ if private_token:
853
+ cmd.extend(["--priv-token", private_token])
854
+
855
+ try:
856
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
857
+ if result.returncode == 0:
858
+ for line in reversed(result.stdout.strip().split("\n")):
859
+ line = line.strip()
860
+ if line.startswith("http"):
861
+ return {"status": "generated", "url": line}
862
+ return {"status": "failed", "error": result.stderr.strip()[:1000]}
863
+ except subprocess.TimeoutExpired:
864
+ return {"status": "failed", "error": "Asset generation timed out"}
865
+ except Exception as e:
866
+ return {"status": "failed", "error": str(e)[:1000]}
867
+
868
+
869
+ def resolve_asset_digital_human(asset: dict, private_token: str, timeout: int) -> dict:
870
+ """Resolve a single digital-human avatar asset by calling gen-digital-human skill."""
871
+ payload = asset.get("payload", {})
872
+
873
+ try:
874
+ cmd = build_skill_command("gen-digital-human")
875
+ except RuntimeError as e:
876
+ return {"status": "failed", "error": str(e)}
877
+
878
+ if payload.get("avatarId"):
879
+ cmd.extend(["--avatar-id", str(payload["avatarId"])])
880
+ if payload.get("text"):
881
+ cmd.extend(["--text", payload["text"]])
882
+ if payload.get("voiceId"):
883
+ cmd.extend(["--voice-id", payload["voiceId"]])
884
+ if payload.get("audioUrl"):
885
+ cmd.extend(["--audio-url", payload["audioUrl"]])
886
+ if payload.get("source"):
887
+ cmd.extend(["--source", payload["source"]])
888
+ if payload.get("ratio"):
889
+ cmd.extend(["--aspect-ratio", payload["ratio"]])
890
+ if private_token:
891
+ cmd.extend(["--priv-token", private_token])
892
+
893
+ dh_timeout = max(timeout, 660)
894
+ try:
895
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=dh_timeout, stdin=subprocess.DEVNULL)
896
+ if result.returncode == 0:
897
+ for line in reversed(result.stdout.strip().split("\n")):
898
+ line = line.strip()
899
+ if line.startswith("http"):
900
+ return {"status": "generated", "url": line}
901
+ return {"status": "failed", "error": result.stderr.strip()[:1000]}
902
+ except subprocess.TimeoutExpired:
903
+ return {"status": "failed", "error": f"Digital human generation timed out after {dh_timeout}s"}
904
+ except Exception as e:
905
+ return {"status": "failed", "error": str(e)[:1000]}
906
+
907
+
908
+ ASSET_RESOLVERS = {
909
+ "gen-image": resolve_asset_image,
910
+ "gen-voice": resolve_asset_audio,
911
+ "gen-video": resolve_asset_video,
912
+ "gen-digital-human": resolve_asset_digital_human,
913
+ }
914
+
915
+
916
+ def _resolve_single_asset(asset: dict, render_plan: dict, private_token: str, max_retries: int, timeout: int) -> bool:
917
+ """Resolve one asset. Returns True if generated, False if failed/skipped."""
918
+ source = asset.get("source", "")
919
+ resolver = ASSET_RESOLVERS.get(source)
920
+ if not resolver:
921
+ asset["status"] = "skipped"
922
+ render_plan["logs"].append({
923
+ "phase": "asset-resolve",
924
+ "message": f"No resolver for source '{source}', skipping {asset['assetId']}",
925
+ "timestamp": now_iso(),
926
+ })
927
+ return False
928
+
929
+ dsl_asset = None
930
+ for a in render_plan.get("_dsl_assets", []):
931
+ if a.get("assetId") == asset["assetId"]:
932
+ dsl_asset = a
933
+ break
934
+
935
+ asset_with_payload = asset.copy()
936
+ if dsl_asset and "payload" in dsl_asset:
937
+ asset_with_payload["payload"] = dsl_asset["payload"]
938
+
939
+ # For digital-human assets: inject audioUrl from a resolved TTS narration asset
940
+ if source == "gen-digital-human":
941
+ payload = asset_with_payload.get("payload", {})
942
+ if not payload.get("audioUrl"):
943
+ # Find the first generated TTS audio asset and use its URL
944
+ for ra in render_plan["resolvedAssets"]:
945
+ if ra.get("type") == "audio" and ra.get("source") == "gen-voice" and ra.get("status") == "generated" and ra.get("url"):
946
+ payload["audioUrl"] = ra["url"]
947
+ asset_with_payload["payload"] = payload
948
+ LogPrint(f" ๐Ÿ”— Injecting audio URL into digital-human asset: {ra['assetId']}", file=sys.stderr)
949
+ break
950
+
951
+ for attempt in range(max_retries):
952
+ asset["retryCount"] = attempt
953
+ asset["generatedBy"] = {
954
+ "skill": source,
955
+ "startedAt": now_iso(),
956
+ }
957
+
958
+ LogPrint(f" ๐Ÿ”„ Generating asset {asset['assetId']} (attempt {attempt + 1}/{max_retries})...", file=sys.stderr)
959
+ result = resolver(asset_with_payload, private_token, timeout)
960
+
961
+ if result["status"] == "generated":
962
+ asset["status"] = "generated"
963
+ asset["url"] = result["url"]
964
+ asset["generatedBy"]["completedAt"] = now_iso()
965
+ if result.get("audio_length_ms") is not None:
966
+ asset["duration"] = result["audio_length_ms"]
967
+ if result.get("subtitles"):
968
+ asset["ttsSubtitles"] = result["subtitles"]
969
+ # Propagate structured-narration metadata onto the resolved
970
+ # asset so adjust_timeline_to_audio can auto-derive highlightMap.
971
+ src_payload = asset_with_payload.get("payload", {}) or {}
972
+ if isinstance(src_payload.get("narrationItems"), list):
973
+ asset["narrationLineCount"] = len(src_payload["narrationItems"])
974
+ asset["narrationIntroLines"] = int(src_payload.get("narrationIntroLines") or 0)
975
+ LogPrint(f" โœ… {asset['assetId']} generated", file=sys.stderr)
976
+ return True
977
+ else:
978
+ error_msg = result.get("error", "Unknown error")
979
+ if attempt == max_retries - 1:
980
+ asset["status"] = "failed"
981
+ render_plan["errors"].append({
982
+ "phase": "asset-resolve",
983
+ "message": error_msg,
984
+ "assetId": asset["assetId"],
985
+ "timestamp": now_iso(),
986
+ })
987
+ LogPrint(f" โŒ {asset['assetId']} generation failed: {error_msg[:300]}", file=sys.stderr)
988
+ return False
989
+ else:
990
+ LogPrint(f" โš ๏ธ {asset['assetId']} retrying...", file=sys.stderr)
991
+ time.sleep(2)
992
+ return False
993
+
994
+
995
+ def apply_stub_urls(render_plan: dict, stub_image_url: str = "", stub_video_url: str = "") -> int:
996
+ """Short-circuit pending image/video assets with a stub URL (test mode, no API cost).
997
+
998
+ For every pending asset whose source is gen-image / gen-video and whose type is image
999
+ / video, replace with the stub URL in-place (status โ†’ generated). Returns the number
1000
+ of assets that were stubbed.
1001
+ """
1002
+ if not stub_image_url and not stub_video_url:
1003
+ return 0
1004
+
1005
+ ts = now_iso()
1006
+ stubbed = 0
1007
+ for asset in render_plan.get("resolvedAssets", []):
1008
+ if asset.get("status") != "pending":
1009
+ continue
1010
+ atype = asset.get("type", "")
1011
+ source = asset.get("source", "")
1012
+ url = ""
1013
+ if stub_image_url and atype == "image" and source == "gen-image":
1014
+ url = stub_image_url
1015
+ elif stub_video_url and atype == "video" and source == "gen-video":
1016
+ url = stub_video_url
1017
+ if not url:
1018
+ continue
1019
+ asset["status"] = "generated"
1020
+ asset["url"] = url
1021
+ asset["generatedBy"] = {
1022
+ "skill": "stub",
1023
+ "startedAt": ts,
1024
+ "completedAt": ts,
1025
+ }
1026
+ stubbed += 1
1027
+
1028
+ if stubbed:
1029
+ render_plan.setdefault("logs", []).append({
1030
+ "phase": "asset-resolve",
1031
+ "message": f"Stub mode: short-circuited {stubbed} asset(s) without calling generation API",
1032
+ "timestamp": ts,
1033
+ })
1034
+ return stubbed
1035
+
1036
+
1037
+ def resolve_assets(render_plan: dict, private_token: str, max_retries: int, timeout: int) -> dict:
1038
+ """Resolve all pending assets in the render plan.
1039
+
1040
+ Two-phase resolution:
1041
+ Phase 1: resolve non-avatar assets (TTS, images, video) โ€” **in parallel**
1042
+ Phase 2: resolve avatar/digital-human assets which may depend on generated audio URLs โ€” serial
1043
+ """
1044
+ from concurrent.futures import ThreadPoolExecutor, as_completed
1045
+
1046
+ render_plan["status"] = "resolving-assets"
1047
+ render_plan["logs"].append({
1048
+ "phase": "asset-resolve",
1049
+ "message": f"Starting asset resolution for {len(render_plan['resolvedAssets'])} assets",
1050
+ "timestamp": now_iso(),
1051
+ })
1052
+
1053
+ pending = [a for a in render_plan["resolvedAssets"] if a["status"] == "pending"]
1054
+ # Phase 1: resolve non-digital-human assets first (TTS audio needed by avatar)
1055
+ phase1 = [a for a in pending if a.get("source") != "gen-digital-human"]
1056
+ # Phase 2: resolve digital-human assets (can now use generated audio URLs)
1057
+ phase2 = [a for a in pending if a.get("source") == "gen-digital-human"]
1058
+
1059
+ generated = 0
1060
+ failed = 0
1061
+
1062
+ # Phase 1: parallel resolution (concurrency configurable via env)
1063
+ if phase1:
1064
+ default_parallelism = 8
1065
+ max_workers = min(
1066
+ int(os.environ.get("REMOTION_ASSET_PARALLELISM", str(default_parallelism))),
1067
+ len(phase1),
1068
+ )
1069
+ LogPrint(f" โšก Phase 1: generating {len(phase1)} asset(s) in parallel (concurrency={max_workers})", file=sys.stderr)
1070
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
1071
+ future_to_asset = {
1072
+ executor.submit(
1073
+ _resolve_single_asset, asset, render_plan, private_token, max_retries, timeout
1074
+ ): asset
1075
+ for asset in phase1
1076
+ }
1077
+ for future in as_completed(future_to_asset):
1078
+ asset = future_to_asset[future]
1079
+ try:
1080
+ ok = future.result()
1081
+ if ok:
1082
+ generated += 1
1083
+ elif asset["status"] == "failed":
1084
+ failed += 1
1085
+ except Exception as exc:
1086
+ asset["status"] = "failed"
1087
+ failed += 1
1088
+ LogPrint(f" โŒ {asset['assetId']} exception: {exc}", file=sys.stderr)
1089
+
1090
+ # Phase 2: serial resolution (digital-human depends on TTS audio)
1091
+ for asset in phase2:
1092
+ ok = _resolve_single_asset(asset, render_plan, private_token, max_retries, timeout)
1093
+ if ok:
1094
+ generated += 1
1095
+ elif asset["status"] == "failed":
1096
+ failed += 1
1097
+
1098
+ render_plan["status"] = "assets-ready" if failed == 0 else "failed"
1099
+ render_plan["logs"].append({
1100
+ "phase": "asset-resolve",
1101
+ "message": f"Asset resolution complete: {generated} generated, {failed} failed",
1102
+ "timestamp": now_iso(),
1103
+ })
1104
+
1105
+ return render_plan
1106
+
1107
+
1108
+ def _auto_highlight_map(entry: dict, narration_asset: Optional[dict], render_plan: dict) -> None:
1109
+ """Auto-fill ``templateData.highlightMap`` for any template that uses
1110
+ structured ``{intro, items, outro}`` narration and ships per-line cards.
1111
+
1112
+ Originally written for html-slide's multi-item slides โ€” that's still the
1113
+ primary consumer โ€” but the trigger is capability-driven, not templateId-
1114
+ keyed: any template whose binding pushes a ``narrationLineCount`` onto
1115
+ the narration asset and whose subtitle segments line up 1-per-line gets
1116
+ this auto-fill. Adding a new template that follows the same pattern needs
1117
+ NO change to this function.
1118
+
1119
+ Skipped silently when:
1120
+ - narration asset has no narrationLineCount (structured form wasn't used)
1121
+ - author already supplied a highlightMap (explicit wins)
1122
+ - subtitleSegments count โ‰  narrationLineCount (emits a warn log)
1123
+ """
1124
+ if not narration_asset:
1125
+ return
1126
+ line_count = narration_asset.get("narrationLineCount")
1127
+ if not isinstance(line_count, int) or line_count <= 0:
1128
+ return
1129
+
1130
+ props = entry.get("props") or {}
1131
+ tdata = props.get("templateData")
1132
+ if not isinstance(tdata, dict):
1133
+ return
1134
+ if tdata.get("highlightMap"):
1135
+ return # respect author-provided map
1136
+
1137
+ segs = entry.get("subtitleSegments") or []
1138
+ if len(segs) != line_count:
1139
+ render_plan.setdefault("logs", []).append({
1140
+ "phase": "highlight",
1141
+ "level": "warn",
1142
+ "message": (
1143
+ f"Scene {entry.get('sceneId')}: narrationItems has {line_count} lines "
1144
+ f"but got {len(segs)} subtitle segments โ€” skipping auto highlightMap"
1145
+ ),
1146
+ "timestamp": now_iso(),
1147
+ })
1148
+ return
1149
+
1150
+ intro_lines = int(narration_asset.get("narrationIntroLines") or 0)
1151
+ item_count = line_count - intro_lines
1152
+ # outro counts toward line_count too; cap item_count at the authored
1153
+ # items-array length so trailing outro lines don't spill into card
1154
+ # indices.
1155
+ items_key = next(
1156
+ (k for k in ("concepts", "pillars", "eras", "items") if isinstance(tdata.get(k), list)),
1157
+ None,
1158
+ )
1159
+ if items_key:
1160
+ item_count = min(item_count, len(tdata[items_key]))
1161
+
1162
+ highlight_map = {str(intro_lines + i): i for i in range(item_count) if item_count > 0}
1163
+ tdata["highlightMap"] = highlight_map
1164
+ props["templateData"] = tdata
1165
+ entry["props"] = props
1166
+
1167
+ render_plan.setdefault("logs", []).append({
1168
+ "phase": "highlight",
1169
+ "message": (
1170
+ f"Scene {entry.get('sceneId')}: auto highlightMap={highlight_map} "
1171
+ f"(intro={intro_lines}, items={item_count}, total_segments={line_count})"
1172
+ ),
1173
+ "timestamp": now_iso(),
1174
+ })
1175
+
1176
+
1177
+ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1178
+ """Post-process: use real TTS audio durations and timestamps to fix
1179
+ scene durations and subtitle segments."""
1180
+ import math
1181
+
1182
+ fps = render_plan["renderConfig"]["fps"]
1183
+ assets_map = {a["assetId"]: a for a in render_plan["resolvedAssets"]}
1184
+ current_frame = 0
1185
+ adjusted = 0
1186
+
1187
+ for entry in render_plan["timeline"]:
1188
+ # narration assetId ็Žฐๅœจ็›ดๆŽฅ็”ฑ propExtractors ๆณจๅ…ฅๅˆฐ props.narrationAssetId๏ผŒ
1189
+ # ไธๅ†ไพ่ต– entry.layers[type=audio] ่ฟ™ๆก้—ดๆŽฅ่ทฏๅพ„ใ€‚
1190
+ narration_asset = None
1191
+ nar_id = (entry.get("props") or {}).get("narrationAssetId")
1192
+ if nar_id:
1193
+ narration_asset = assets_map.get(nar_id)
1194
+
1195
+ old_dur = entry["durationFrames"]
1196
+
1197
+ if narration_asset and narration_asset.get("duration"):
1198
+ audio_ms = narration_asset["duration"]
1199
+ if audio_ms <= 0:
1200
+ # Sanity check: TTS returned 0 or negative duration โ€” keep original estimate
1201
+ render_plan.setdefault("logs", []).append({
1202
+ "phase": "audio-adjust",
1203
+ "level": "warn",
1204
+ "message": f"Scene {entry.get('sceneId')}: TTS duration={audio_ms}ms is invalid, keeping estimated {old_dur} frames",
1205
+ "timestamp": now_iso(),
1206
+ })
1207
+ else:
1208
+ audio_frames = math.ceil(audio_ms / 1000.0 * fps)
1209
+ # DSL ้‡Œ scene.customPayload.tailPadSec ๅฏ่ฆ†็›–้ป˜่ฎค 1.5s ๅฐพๅทด
1210
+ # ๏ผˆๆฏ”ๅฆ‚ๅผ€ๅœบๅก็‰‡่ฆ็ดงๆŽฅไธ‹ไธ€ๅœบ๏ผŒ่ฎพ 0 ็›ดๆŽฅ่ดด้Ÿณ้ข‘็ป“ๆŸ๏ผ‰ใ€‚
1211
+ tail_pad_override = entry.get("tailPadFrames")
1212
+ tail_pad = (
1213
+ int(tail_pad_override)
1214
+ if isinstance(tail_pad_override, int) and tail_pad_override >= 0
1215
+ else int(fps * 1.5)
1216
+ )
1217
+ min_dur = int(fps * 2) # minimum 2 seconds per scene
1218
+ # ็”จๆˆทๅœจ DSL ้‡ŒๆŒ‡ๅฎš็š„ scene ไธ‹้™๏ผˆminDurationSec โ†’ minDurationFrames๏ผ‰๏ผŒ
1219
+ # ็”จไบŽ"่ง†้ข‘ๆฏ”ๆ—็™ฝ้•ฟ"็š„ๅœบๆ™ฏ๏ผš่ฆๆŠŠ่ง†้ข‘ๅฎŒๆ•ดๆ’ญๅฎŒ๏ผŒscene ๆ—ถ้•ฟไธ่ƒฝ่ขซๆ—็™ฝๆ‹็Ÿญใ€‚
1220
+ scene_min_dur = int(entry.get("minDurationFrames") or 0)
1221
+ new_dur = max(min_dur, scene_min_dur, audio_frames + tail_pad)
1222
+
1223
+ if new_dur != old_dur:
1224
+ entry["durationFrames"] = new_dur
1225
+ adjusted += 1
1226
+
1227
+ tts_subs = narration_asset.get("ttsSubtitles", [])
1228
+ if tts_subs:
1229
+ entry["subtitleSegments"] = [
1230
+ {
1231
+ "text": _strip_subtitle_trailing_punct(s["text"].strip()),
1232
+ "startFrame": current_frame + int(s["timeBegin"] / 1000.0 * fps),
1233
+ "endFrame": current_frame + int(s["timeEnd"] / 1000.0 * fps),
1234
+ }
1235
+ for s in tts_subs
1236
+ ]
1237
+ else:
1238
+ # No TTS timestamps โ€” re-distribute existing subtitle
1239
+ # lines across the REAL audio duration, by character
1240
+ # length ratio. Avoid going through
1241
+ # split_subtitle_from_lines() because its internal
1242
+ # estimated_audio_frames heuristic can clip
1243
+ # `usable_frames` below the actual audio length.
1244
+ # ๅญ—ๅน•่ทŸ้š็œŸๅฎž้Ÿณ้ข‘ๆ—ถ้•ฟๅˆ†ๅธƒ๏ผŒๆ’‘ๅคง scene๏ผˆๅ›  minDuration๏ผ‰
1245
+ # ๆ—ถๅฐพๅทด้™้Ÿณๆฎตๆ— ๅญ—ๅน•ใ€‚
1246
+ old_subs = entry.get("subtitleSegments", [])
1247
+ at_sec_list = entry.get("subtitleAtSec") or []
1248
+ # ไป…ๅฝ“ๆŸๆกๅญ—ๅน•ๆ˜พๅผ็ป™ไบ† atSec๏ผˆ่ง†้ข‘ๆ—ถ้—ด่ฝด็š„ๆ็คบ็‚น๏ผ‰
1249
+ # ๆ—ถ๏ผŒๆŠŠๅญ—ๅน•้”šๅฎšๅˆฐๅฏนๅบ”็š„่ง†้ข‘ๅธง๏ผ›่ฟ™ๆ ทๅญ—ๅน•่Š‚ๅฅ่ทŸ้š
1250
+ # ็”ป้ข่€Œไธๆ˜ฏๆ—็™ฝ้•ฟๅบฆใ€‚ๆœ€ๅŽไธ€ๆกๅญ—ๅน•ไผšไธ€็›ดๆ˜พ็คบๅˆฐ
1251
+ # scene ็ป“ๅฐพ๏ผˆ่ฎฉ"็œ‹ไธ‹ๆ•ˆๆžœ"็ญ‰ๆ”ถๅฐพๅฅไฟๆŒๅœจๅฑ๏ผ‰ใ€‚
1252
+ has_at_sec = any(
1253
+ isinstance(a, (int, float)) and a is not None
1254
+ for a in at_sec_list
1255
+ ) and len(at_sec_list) == len(old_subs)
1256
+
1257
+ if has_at_sec and old_subs:
1258
+ scene_dur = entry["durationFrames"]
1259
+ # ็ฎ—ๆฏๆกๅญ—ๅน•็š„่ตทๅง‹ๅธง๏ผˆ็›ธๅฏน scene๏ผ‰ใ€‚
1260
+ # atSec ็ผบๅคฑๆ—ถไปฅๅ‰ไธ€ๆก็ป“ๆŸไฝ็ฝฎไธบๅ‡†ใ€‚
1261
+ starts: list[int] = []
1262
+ for i, s in enumerate(old_subs):
1263
+ at = at_sec_list[i] if i < len(at_sec_list) else None
1264
+ if isinstance(at, (int, float)):
1265
+ f = max(0, int(round(at * fps)))
1266
+ else:
1267
+ f = starts[i - 1] + (fps // 2) if i > 0 else 0
1268
+ # ๅ•่ฐƒ้€’ๅขž๏ผŒ้ฟๅ…ๅŽ็ป™็š„ atSec ๆฏ”ๅ‰ไธ€ๆกๅฐ
1269
+ if i > 0 and f <= starts[i - 1]:
1270
+ f = starts[i - 1] + (fps // 2)
1271
+ starts.append(min(f, scene_dur - 1))
1272
+ new_subs = []
1273
+ for i, s in enumerate(old_subs):
1274
+ text = s.get("text", "")
1275
+ start_f = starts[i]
1276
+ # ๆœ€ๅŽไธ€ๆกๅปถ็ปญๅˆฐ scene ็ป“ๅฐพ๏ผ›ๅ…ถไฝ™ไปฅไธ‹ไธ€ๆก
1277
+ # ่ตท็‚นไธบ็ปˆ็‚นใ€‚
1278
+ end_f = scene_dur if i == len(old_subs) - 1 else starts[i + 1]
1279
+ new_subs.append({
1280
+ "text": text,
1281
+ "startFrame": current_frame + start_f,
1282
+ "endFrame": current_frame + end_f,
1283
+ })
1284
+ entry["subtitleSegments"] = new_subs
1285
+ elif old_subs:
1286
+ sub_span = audio_frames # use exact audio length
1287
+ # Use plain char count (markdown ** stripped) for
1288
+ # ratio. Fall back to 1 to avoid div-by-zero.
1289
+ def _plain(t: str) -> int:
1290
+ return len(re.sub(r"\*+", "", t or ""))
1291
+ total_chars = max(sum(_plain(s.get("text", "")) for s in old_subs), 1)
1292
+ new_subs = []
1293
+ cursor = 0
1294
+ for s in old_subs:
1295
+ text = s.get("text", "")
1296
+ ratio = _plain(text) / total_chars
1297
+ seg_frames = max(int(sub_span * ratio), fps // 2)
1298
+ new_subs.append({
1299
+ "text": text,
1300
+ "startFrame": current_frame + cursor,
1301
+ "endFrame": current_frame + cursor + seg_frames,
1302
+ })
1303
+ cursor += seg_frames
1304
+ entry["subtitleSegments"] = new_subs
1305
+
1306
+ _auto_highlight_map(entry, narration_asset, render_plan)
1307
+
1308
+ # Re-anchor subtitles for scenes that weren't handled in the narration block above.
1309
+ # This covers scenes without narration or with invalid duration.
1310
+ old_scene_start = entry.get("startFrame", 0)
1311
+ if not (narration_asset and narration_asset.get("duration") and narration_asset["duration"] > 0):
1312
+ # Subtitles weren't touched above โ€” re-anchor if position shifted
1313
+ if old_scene_start != current_frame:
1314
+ old_subs = entry.get("subtitleSegments", [])
1315
+ if old_subs:
1316
+ new_subs = []
1317
+ for s in old_subs:
1318
+ rel_start = s["startFrame"] - old_scene_start
1319
+ rel_end = s["endFrame"] - old_scene_start
1320
+ new_subs.append({
1321
+ "text": s["text"],
1322
+ "startFrame": current_frame + rel_start,
1323
+ "endFrame": current_frame + rel_end,
1324
+ })
1325
+ entry["subtitleSegments"] = new_subs
1326
+
1327
+ entry["startFrame"] = current_frame
1328
+ entry["endFrame"] = current_frame + entry["durationFrames"]
1329
+ entry["startTime"] = round(current_frame / fps, 2)
1330
+ entry["endTime"] = round(entry["endFrame"] / fps, 2)
1331
+
1332
+ # P2.3: ไธๅ†็ปดๆŠค entry.layers โ€”โ€” ๆ—ง็š„ layers ๆ•ฐ็ป„ๅชๆ˜ฏ็ป™ ab-render ๆจกๆฟ"้—ดๆŽฅ"
1333
+ # ๆ‰พ background asset ็”จ๏ผŒ็Žฐๅทฒๆ”ถๆ•›ๅˆฐ propExtractors ๅ†™ๅˆฐ props.backgroundAssetId
1334
+ # / props.narrationAssetId ็ญ‰ๆ˜พๅผๅญ—ๆฎตใ€‚ๆ–ฐ็‰ˆ build_render_plan ไธๅ†ๅ†™ๅ…ฅ
1335
+ # entry.layers๏ผ›ๅฆ‚ๆžœไธŠๆธธ็œŸ็š„ๅกžไบ† layers ่ฟ›ๆฅ๏ผˆๆ—ง RenderPlan ๆ–‡ไปถ่ขซ
1336
+ # --render-plan ๅ–‚่ฟ›ๆฅ๏ผ‰๏ผŒๅนฒ่„†ๆ‰”ๆމ๏ผŒ้ฟๅ…ๅธงๅทๅๆ ‡็ณปๆททไนฑใ€‚
1337
+ entry.pop("layers", None)
1338
+
1339
+ # ไธบ props.slides ไธญ็š„ๆฏๅผ ๅนป็ฏ็‰‡ๅˆ†้… durationFrames๏ผˆRemotion Sequence ๅฟ…้œ€๏ผ‰
1340
+ slides = entry.get("props", {}).get("slides")
1341
+ if slides and isinstance(slides, list) and len(slides) > 0:
1342
+ scene_dur = entry["durationFrames"]
1343
+ per_slide = scene_dur // len(slides)
1344
+ remainder = scene_dur - per_slide * len(slides)
1345
+ for si, slide in enumerate(slides):
1346
+ if not isinstance(slide, dict):
1347
+ continue
1348
+ slide["durationFrames"] = per_slide + (1 if si < remainder else 0)
1349
+
1350
+ current_frame += entry["durationFrames"]
1351
+
1352
+ render_plan["renderConfig"]["totalFrames"] = current_frame
1353
+ render_plan["renderConfig"]["totalDuration"] = round(current_frame / fps, 2)
1354
+
1355
+ if adjusted:
1356
+ render_plan["logs"].append({
1357
+ "phase": "audio-adjust",
1358
+ "message": f"Adjusted {adjusted} scenes to match TTS audio durations, total {current_frame} frames ({current_frame/fps:.1f}s)",
1359
+ "timestamp": now_iso(),
1360
+ })
1361
+ LogPrint(f"๐Ÿ”ง Adjusted {adjusted} scene(s) to TTS audio duration; total {current_frame/fps:.1f}s", file=sys.stderr)
1362
+
1363
+ return render_plan
1364
+
1365
+
1366
+
1367
+
1368
+ def render_with_local_cli(render_plan: dict, output_path: str) -> bool:
1369
+ """Call Remotion CLI to render the video locally."""
1370
+ render_plan["status"] = "rendering"
1371
+ render_plan["logs"].append({
1372
+ "phase": "render",
1373
+ "message": "Starting Remotion render",
1374
+ "timestamp": now_iso(),
1375
+ })
1376
+
1377
+ # Absolutise paths so they survive the cwd switch to REMOTION_RENDERER_DIR
1378
+ output_path = os.path.abspath(output_path)
1379
+ os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
1380
+
1381
+ # ็›ดๆŽฅไฝฟ็”จ resolvedAssets ไธญ็š„ HTTPS URL๏ผŒ่ฎฉ Chrome headless ่‡ช่กŒๅŠ ่ฝฝๅค–็ฝ‘ๅ›พ็‰‡ใ€‚
1382
+ # ่‹ฅ render-plan.json ๆ›พ่ขซๆœฌๅœฐๅŒ–ๅค„็†่ฟ‡๏ผˆurl ไปฅ "/" ๅผ€ๅคด๏ผ‰๏ผŒไปŽ originalUrl ๆขๅคใ€‚
1383
+ https_assets = []
1384
+ for asset in render_plan.get("resolvedAssets", []):
1385
+ asset = asset.copy()
1386
+ if asset.get("url", "").startswith("/") and asset.get("originalUrl"):
1387
+ asset["url"] = asset["originalUrl"]
1388
+ https_assets.append(asset)
1389
+
1390
+ props_path = os.path.abspath(os.path.join(OUTPUT_DIR, "remotion-props.json"))
1391
+ save_json({
1392
+ "timeline": render_plan["timeline"],
1393
+ "renderConfig": render_plan["renderConfig"],
1394
+ "resolvedAssets": https_assets,
1395
+ **(render_plan.get("remotionProps", {}).get("inputProps", {})),
1396
+ }, props_path)
1397
+
1398
+ composition_id = render_plan.get("remotionProps", {}).get("compositionId", "MainVideo")
1399
+ config = render_plan["renderConfig"]
1400
+
1401
+ renderer_dir = os.path.abspath(REMOTION_RENDERER_DIR)
1402
+ remotion_cli = os.path.join(renderer_dir, "node_modules", ".bin", "remotion")
1403
+ if not os.path.exists(remotion_cli):
1404
+ LogPrint("โš ๏ธ node_modules not found; running npm install automatically (needs network on first run)...", file=sys.stderr)
1405
+ npm_check = subprocess.run(["which", "npm"], capture_output=True)
1406
+ if npm_check.returncode != 0:
1407
+ LogPrint("โŒ npm not found โ€” please install Node.js (18+ required)", file=sys.stderr)
1408
+ render_plan["status"] = "failed"
1409
+ render_plan["errors"].append({
1410
+ "phase": "render",
1411
+ "message": "npm not found. Please install Node.js 18+.",
1412
+ "timestamp": now_iso(),
1413
+ })
1414
+ return False
1415
+ install_result = subprocess.run(
1416
+ ["npm", "install"],
1417
+ cwd=renderer_dir,
1418
+ capture_output=True,
1419
+ text=True,
1420
+ )
1421
+ if install_result.returncode != 0:
1422
+ LogPrint(f"โŒ npm install failed:\n{install_result.stderr[:500]}", file=sys.stderr)
1423
+ render_plan["status"] = "failed"
1424
+ render_plan["errors"].append({
1425
+ "phase": "render",
1426
+ "message": f"npm install failed: {install_result.stderr[:500]}",
1427
+ "timestamp": now_iso(),
1428
+ })
1429
+ return False
1430
+ LogPrint("โœ… dependencies installed", file=sys.stderr)
1431
+ if not os.path.exists(remotion_cli):
1432
+ LogPrint("โŒ remotion CLI still missing after npm install", file=sys.stderr)
1433
+ render_plan["status"] = "failed"
1434
+ render_plan["errors"].append({
1435
+ "phase": "render",
1436
+ "message": "remotion CLI not found after npm install",
1437
+ "timestamp": now_iso(),
1438
+ })
1439
+ return False
1440
+
1441
+ sync_chrome_headless_vendor(renderer_dir, render_plan)
1442
+
1443
+ cmd = [
1444
+ remotion_cli, "render",
1445
+ "src/index.ts",
1446
+ composition_id,
1447
+ output_path,
1448
+ "--props", props_path,
1449
+ # ไธไฝฟ็”จ --public-dir๏ผŒๅ›พ็‰‡็›ดๆŽฅ้€š่ฟ‡ HTTPS URL ็”ฑ Chrome headless ๅŠ ่ฝฝใ€‚
1450
+ "--width", str(config.get("width", 1920)),
1451
+ "--height", str(config.get("height", 1080)),
1452
+ "--fps", str(config.get("fps", 30)),
1453
+ "--codec", config.get("codec", "h264"),
1454
+ "--crf", str(config.get("crf", 18)),
1455
+ "--timeout", str(config.get("timeoutPerFrame", 120000)),
1456
+ ]
1457
+
1458
+ total_frames = config.get("totalFrames", 0)
1459
+ LogPrint(f"๐ŸŽฌ Starting Remotion render...", file=sys.stderr)
1460
+ LogPrint(f" Composition: {composition_id}", file=sys.stderr)
1461
+ LogPrint(f" size: {config.get('width')}x{config.get('height')}", file=sys.stderr)
1462
+ LogPrint(f" total frames: {total_frames}", file=sys.stderr)
1463
+ LogPrint(f" output: {output_path}", file=sys.stderr)
1464
+ LogPrint(f" command: {' '.join(cmd)}", file=sys.stderr)
1465
+ sys.stderr.flush()
1466
+
1467
+ try:
1468
+ # Stream output to stderr so progress is visible in real-time
1469
+ proc = subprocess.Popen(
1470
+ cmd,
1471
+ stdout=subprocess.PIPE,
1472
+ stderr=subprocess.STDOUT,
1473
+ text=True,
1474
+ cwd=renderer_dir,
1475
+ )
1476
+
1477
+ last_rendered = 0
1478
+ stderr_tail = []
1479
+ while True:
1480
+ line = proc.stdout.readline()
1481
+ if not line and proc.poll() is not None:
1482
+ break
1483
+ if not line:
1484
+ continue
1485
+ stderr_tail.append(line)
1486
+ if len(stderr_tail) > 50:
1487
+ stderr_tail.pop(0)
1488
+
1489
+ # Parse and print compact progress: "Rendered 100/2617"
1490
+ stripped = line.strip()
1491
+ if stripped.startswith("Rendered "):
1492
+ try:
1493
+ parts = stripped.split()
1494
+ frac = parts[1].rstrip(",") # "100/2617"
1495
+ current = int(frac.split("/")[0])
1496
+ # Print progress every 10% or every 100 frames
1497
+ if total_frames and (current - last_rendered >= max(total_frames // 10, 1)):
1498
+ pct = int(current / total_frames * 100)
1499
+ LogPrint(f" ๐ŸŽž๏ธ Render progress: {current}/{total_frames} ({pct}%)", file=sys.stderr)
1500
+ sys.stderr.flush()
1501
+ # ็ป“ๆž„ๅŒ–่ฟ›ๅบฆ๏ผŒไพ›ไธŠๅฑ‚่งฃๆžๅนถ่ฝฌๅ‘ๅˆฐๅ‰็ซฏ
1502
+ print(
1503
+ json.dumps({
1504
+ "__progress__": True,
1505
+ "phase": "render",
1506
+ "progress": round(current / total_frames, 4),
1507
+ "current": current,
1508
+ "total": total_frames,
1509
+ }, ensure_ascii=False),
1510
+ flush=True,
1511
+ )
1512
+ last_rendered = current
1513
+ except (ValueError, IndexError):
1514
+ pass
1515
+ elif "error" in stripped.lower() or stripped.startswith("Error"):
1516
+ LogPrint(f" โš ๏ธ {stripped[:200]}", file=sys.stderr)
1517
+ sys.stderr.flush()
1518
+
1519
+ returncode = proc.wait()
1520
+
1521
+ if returncode == 0:
1522
+ render_plan["status"] = "completed"
1523
+ render_plan["logs"].append({
1524
+ "phase": "render",
1525
+ "message": f"Render completed successfully: {output_path}",
1526
+ "timestamp": now_iso(),
1527
+ })
1528
+ return True
1529
+ else:
1530
+ error_output = "".join(stderr_tail).strip()[-1000:]
1531
+ render_plan["status"] = "failed"
1532
+ render_plan["errors"].append({
1533
+ "phase": "render",
1534
+ "message": error_output,
1535
+ "timestamp": now_iso(),
1536
+ })
1537
+ return False
1538
+ except subprocess.TimeoutExpired:
1539
+ proc.kill()
1540
+ render_plan["status"] = "failed"
1541
+ render_plan["errors"].append({
1542
+ "phase": "render",
1543
+ "message": "Remotion render timed out",
1544
+ "timestamp": now_iso(),
1545
+ })
1546
+ return False
1547
+ except FileNotFoundError:
1548
+ render_plan["status"] = "failed"
1549
+ render_plan["errors"].append({
1550
+ "phase": "render",
1551
+ "message": "Remotion CLI not found",
1552
+ "timestamp": now_iso(),
1553
+ })
1554
+ return False
1555
+
1556
+
1557
+ def render_with_remote_api(
1558
+ render_plan: dict,
1559
+ output_path: str,
1560
+ *,
1561
+ private_token: str,
1562
+ poll_timeout: float = 1800.0,
1563
+ poll_interval: float = 5.0,
1564
+ upload_title: Optional[str] = None,
1565
+ conversation_id: Optional[str] = None,
1566
+ ) -> bool:
1567
+ """Submit render to ab-api /tool/renderVideo and poll until completion.
1568
+
1569
+ On success, writes upload.fileUrl into render_plan and skips local MP4 download.
1570
+ """
1571
+ import remote_renderer_client
1572
+
1573
+ render_plan["status"] = "rendering"
1574
+ render_plan["renderMode"] = "remote"
1575
+ render_plan["logs"].append({
1576
+ "phase": "render",
1577
+ "message": "Submitting render to remote API",
1578
+ "timestamp": now_iso(),
1579
+ })
1580
+
1581
+ https_assets = []
1582
+ for asset in render_plan.get("resolvedAssets", []):
1583
+ asset = asset.copy()
1584
+ if asset.get("url", "").startswith("/") and asset.get("originalUrl"):
1585
+ asset["url"] = asset["originalUrl"]
1586
+ https_assets.append(asset)
1587
+
1588
+ composition_id = render_plan.get("remotionProps", {}).get("compositionId", "MainVideo")
1589
+ config = render_plan["renderConfig"]
1590
+ input_props = {
1591
+ "timeline": render_plan["timeline"],
1592
+ "renderConfig": config,
1593
+ "resolvedAssets": https_assets,
1594
+ **(render_plan.get("remotionProps", {}).get("inputProps", {})),
1595
+ }
1596
+ effective_title = upload_title or os.path.splitext(os.path.basename(output_path))[0]
1597
+
1598
+ # โ”€โ”€โ”€ Cover composition resolution โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1599
+ # ไผ˜ๅ…ˆ็บง๏ผš
1600
+ # 1) render_plan.remotionProps.coverCompositionId๏ผˆไธŠๆธธๆ˜พๅผๆŒ‡ๅฎš๏ผ‰
1601
+ # 2) registry.compositions[].slot=="cover" ๅๆŸฅ๏ผˆ่‡ชๅŠจๅŒน้…๏ผ‰
1602
+ # ไธไผ  โ†’ ab-render ไธๆธฒๅฐ้ข๏ผŒ่กŒไธบไธŽๆ”น้€ ๅ‰ไธ€่‡ดใ€‚
1603
+ cover_composition_id = (
1604
+ render_plan.get("remotionProps", {}).get("coverCompositionId")
1605
+ )
1606
+ if not cover_composition_id:
1607
+ template_id = render_plan.get("templateId", "")
1608
+ cover_composition_id = resolve_cover_composition_id(template_id)
1609
+
1610
+ payload = {
1611
+ "compositionId": composition_id,
1612
+ "renderConfig": config,
1613
+ "inputProps": input_props,
1614
+ "uploadTitle": effective_title,
1615
+ }
1616
+ if cover_composition_id:
1617
+ payload["coverCompositionId"] = cover_composition_id
1618
+
1619
+ total_frames = config.get("totalFrames", 0)
1620
+ LogPrint(f"๐ŸŽฌ Submitting remote render task...", file=sys.stderr)
1621
+ LogPrint(f" Composition: {composition_id}", file=sys.stderr)
1622
+ if cover_composition_id:
1623
+ LogPrint(f" Cover: {cover_composition_id}", file=sys.stderr)
1624
+ LogPrint(f" size: {config.get('width')}x{config.get('height')}", file=sys.stderr)
1625
+ LogPrint(f" total frames: {total_frames}", file=sys.stderr)
1626
+ sys.stderr.flush()
1627
+
1628
+ try:
1629
+ task_id = remote_renderer_client.start_render(payload, private_token=private_token, conversation_id=conversation_id)
1630
+ except Exception as exc:
1631
+ LogPrint(f"โŒ remote render submission failed: {exc}", file=sys.stderr)
1632
+ render_plan["status"] = "failed"
1633
+ render_plan["errors"].append({
1634
+ "phase": "render",
1635
+ "message": f"remote start_render failed: {exc}",
1636
+ "timestamp": now_iso(),
1637
+ })
1638
+ return False
1639
+
1640
+ LogPrint(f" โœ… task submitted, taskId={task_id}", file=sys.stderr)
1641
+ render_plan["remoteTaskId"] = task_id
1642
+ render_plan["logs"].append({
1643
+ "phase": "render",
1644
+ "message": f"Remote task submitted: {task_id}",
1645
+ "timestamp": now_iso(),
1646
+ })
1647
+
1648
+ last_progress = -1.0
1649
+ render_start_time = time.time()
1650
+
1651
+ def _on_progress(status_data: dict):
1652
+ nonlocal last_progress
1653
+ progress = float(status_data.get("progress") or 0.0)
1654
+ if progress - last_progress >= 0.1 or progress >= 1.0:
1655
+ pct = int(progress * 100)
1656
+ elapsed = time.time() - render_start_time
1657
+ eta_remaining = 0.0
1658
+ if progress > 0.05 and progress < 1.0:
1659
+ eta_total = elapsed / progress
1660
+ eta_remaining = eta_total - elapsed
1661
+ eta_min = int(eta_remaining) // 60
1662
+ eta_sec = int(eta_remaining) % 60
1663
+ LogPrint(f" ๐ŸŽž๏ธ Remote render progress: {pct}% (eta {eta_min}m{eta_sec:02d}s)", file=sys.stderr)
1664
+ else:
1665
+ LogPrint(f" ๐ŸŽž๏ธ Remote render progress: {pct}%", file=sys.stderr)
1666
+ sys.stderr.flush()
1667
+ # ็ป“ๆž„ๅŒ–่ฟ›ๅบฆ๏ผŒไพ›ไธŠๅฑ‚่งฃๆžๅนถ่ฝฌๅ‘ๅˆฐๅ‰็ซฏ
1668
+ print(
1669
+ json.dumps({
1670
+ "__progress__": True,
1671
+ "phase": "render-remote",
1672
+ "progress": round(progress, 4),
1673
+ "etaSeconds": int(eta_remaining) if eta_remaining > 0 else None,
1674
+ "elapsedSeconds": int(elapsed),
1675
+ "taskId": task_id,
1676
+ }, ensure_ascii=False),
1677
+ flush=True,
1678
+ )
1679
+ last_progress = progress
1680
+
1681
+ try:
1682
+ result = remote_renderer_client.poll_render(
1683
+ task_id,
1684
+ private_token=private_token,
1685
+ timeout=poll_timeout,
1686
+ interval=poll_interval,
1687
+ on_progress=_on_progress,
1688
+ adaptive_interval=True,
1689
+ )
1690
+ except Exception as exc:
1691
+ LogPrint(f"โŒ remote render polling failed: {exc}", file=sys.stderr)
1692
+ render_plan["status"] = "failed"
1693
+ render_plan["errors"].append({
1694
+ "phase": "render",
1695
+ "message": f"remote poll_render failed: {exc}",
1696
+ "timestamp": now_iso(),
1697
+ })
1698
+ return False
1699
+
1700
+ file_url = (result or {}).get("fileUrl") or (result or {}).get("videoUrl")
1701
+ if not file_url:
1702
+ LogPrint(f"โŒ remote render finished but did not return a fileUrl: {result}", file=sys.stderr)
1703
+ render_plan["status"] = "failed"
1704
+ render_plan["errors"].append({
1705
+ "phase": "render",
1706
+ "message": f"remote render returned no fileUrl: {result}",
1707
+ "timestamp": now_iso(),
1708
+ })
1709
+ return False
1710
+
1711
+ # ๆๅ– VOD ๅ…ƒๆ•ฐๆฎ
1712
+ video_id = (result or {}).get("videoId", "")
1713
+ file_id = (result or {}).get("fileId")
1714
+
1715
+ # ๆๅ–ๅฐ้ข๏ผˆๅฏ้€‰๏ผ›ab-render ๅœจ่ง†้ข‘ๆˆๅŠŸๅŽไธฒ่กŒๆธฒไธ€ๅธง้™ๆ€ๅฐ้ข๏ผ‰
1716
+ # ๅคฑ่ดฅไธๅฝฑๅ“่ง†้ข‘ๆœฌ่บซ็š„ๆˆ่ดฅโ€”โ€”cover_url ไธบ็ฉบๅˆ™ไธŠๆธธๅฏไปฅ้€‰ๆ‹ฉๅ›ž้€€ๅˆฐๅ ไฝๅ›พ
1717
+ # ๆˆ–้‡่ฏ•ใ€‚
1718
+ cover_url = (result or {}).get("coverUrl")
1719
+ cover_error = (result or {}).get("coverError")
1720
+
1721
+ render_plan["status"] = "completed"
1722
+ render_plan["upload"] = {
1723
+ "fileUrl": file_url,
1724
+ "title": effective_title,
1725
+ "uploadedAt": now_iso(),
1726
+ "source": "remote-renderer",
1727
+ "videoId": video_id,
1728
+ "fileId": file_id,
1729
+ }
1730
+ if cover_url:
1731
+ render_plan["upload"]["coverUrl"] = cover_url
1732
+ if cover_error and not cover_url:
1733
+ # ่ง†้ข‘ๆˆๅŠŸไฝ†ๅฐ้ขๅคฑ่ดฅ๏ผš้™็บงไธบ้ž่‡ดๅ‘ฝ log๏ผŒไพฟไบŽไธŠๆธธ็œ‹ๅˆฐๅŽŸๅ› 
1734
+ render_plan["logs"].append({
1735
+ "phase": "render",
1736
+ "message": f"Cover render failed (non-fatal): {cover_error}",
1737
+ "timestamp": now_iso(),
1738
+ })
1739
+ render_plan["logs"].append({
1740
+ "phase": "render",
1741
+ "message": f"Remote render completed: {file_url}",
1742
+ "timestamp": now_iso(),
1743
+ })
1744
+ LogPrint(f" โœ… remote render finished: {file_url}", file=sys.stderr)
1745
+ if cover_url:
1746
+ LogPrint(f" ๐Ÿ–ผ๏ธ cover image: {cover_url}", file=sys.stderr)
1747
+ elif cover_composition_id:
1748
+ LogPrint(
1749
+ f" โš ๏ธ ๅฐ้ขๅ›พๆœช่ฟ”ๅ›ž๏ผˆcover_composition_id={cover_composition_id}, "
1750
+ f"error={cover_error or 'unknown'}๏ผ‰",
1751
+ file=sys.stderr,
1752
+ )
1753
+
1754
+ # ๅฆ‚ๆžœๆ˜ฏ vod:// ๅœฐๅ€๏ผŒ่ฝฎ่ฏขๅŽ็ซฏ่Žทๅ–ๅฎž้™…ๆ’ญๆ”พ URL
1755
+ if file_url.startswith("vod://") and file_id and private_token:
1756
+ playback_url = _poll_vod_playback_url(file_id, private_token)
1757
+ if playback_url:
1758
+ render_plan["upload"]["playbackUrl"] = playback_url
1759
+ LogPrint(f" ๐ŸŽฌ VOD playback URL: {playback_url}", file=sys.stderr)
1760
+ else:
1761
+ LogPrint(f" โš ๏ธ VOD playback URL not ready yet (fileId={file_id}); query later via /file/get", file=sys.stderr)
1762
+
1763
+ return True
1764
+
1765
+
1766
+ def _poll_vod_playback_url(
1767
+ file_id: int,
1768
+ private_token: str,
1769
+ *,
1770
+ max_wait: float = 60.0,
1771
+ interval: float = 3.0,
1772
+ ):
1773
+ """Backwards-compat wrapper โ€” implementation lives in ``_vod_polling`` module."""
1774
+ from _vod_polling import poll_vod_playback_url
1775
+ return poll_vod_playback_url(file_id, private_token, max_wait=max_wait, interval=interval)
1776
+
1777
+
1778
+ def auto_bind_template(dsl: dict, template_id: str) -> dict:
1779
+ """Auto-generate TemplateBinding from DSL + template-id by importing
1780
+ match_template.py's binding logic. Eliminates the need for a separate
1781
+ match_template.py invocation step."""
1782
+ # template-bind/scripts is on sys.path via the module-top setup.
1783
+ try:
1784
+ import match_template
1785
+ except ImportError as e:
1786
+ LogPrint(f"โŒ failed to import the match_template module: {e}", file=sys.stderr)
1787
+ LogPrint(f" confirm the shared-lib skill exists at: {_SHARED_LIB_DIR}", file=sys.stderr)
1788
+ sys.exit(1)
1789
+
1790
+ templates = match_template.load_registry()
1791
+ if not templates:
1792
+ LogPrint("โŒ no available templates; check the video_dsl/templates/ directory", file=sys.stderr)
1793
+ sys.exit(1)
1794
+
1795
+ selected = None
1796
+ for tpl in templates:
1797
+ if tpl.get("templateId") == template_id:
1798
+ selected = tpl
1799
+ break
1800
+ if not selected:
1801
+ available = ", ".join(t.get("templateId", "") for t in templates)
1802
+ LogPrint(f"โŒ template not found: {template_id} (available: {available})", file=sys.stderr)
1803
+ sys.exit(1)
1804
+
1805
+ selected = match_template.load_full_template(selected)
1806
+ LogPrint(f"๐Ÿ“‹ auto-bound template: {selected.get('name', '')} ({template_id})", file=sys.stderr)
1807
+ binding = match_template.build_binding(selected, dsl)
1808
+ return binding
1809
+
1810
+
1811
+ def main():
1812
+ parser = argparse.ArgumentParser(
1813
+ description="Video render tool โ€” DSL + TemplateBinding โ†’ Remotion video.",
1814
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1815
+ epilog="""
1816
+ Examples:
1817
+ python render_video.py --dsl video.dsl.json --template-id html-slide
1818
+ python render_video.py --dsl video.dsl.json --template-id html-slide --resolve-only
1819
+ python render_video.py --render-plan video.render-plan.json
1820
+ """,
1821
+ )
1822
+ parser.add_argument("--dsl", help="Input DSL file path")
1823
+ parser.add_argument("--dsl-json", default=None, help="DSL JSON as an inline string (replaces --dsl; no file needed; preferred for multi-user concurrent flows)")
1824
+ parser.add_argument("--template-id", default=None, help="Template id (required unless --render-plan / --job-id is used)")
1825
+ parser.add_argument("--render-plan", help="Existing RenderPlan file path")
1826
+ parser.add_argument("--resolve-only", action="store_true", help="Only resolve assets; do not run the render")
1827
+ parser.add_argument("--skip-asset-resolve", action="store_true", help="Skip asset resolution")
1828
+ parser.add_argument("--save-render-plan", action="store_true", help="Save the RenderPlan to a file")
1829
+ parser.add_argument("--render-plan-output", default=None, help="RenderPlan output path")
1830
+ parser.add_argument("--job-id", type=int, default=None, help="Render job id (replaces --render-plan; loads the RenderPlan from the database)")
1831
+ parser.add_argument(
1832
+ "--save-job",
1833
+ action=argparse.BooleanOptionalAction,
1834
+ default=True,
1835
+ help="Persist RenderPlan / Manifest to the database (on by default โ€” emits a jobId every run); pass --no-save-job to disable. Auto-degrades to off when PRIV_TOKEN is missing.",
1836
+ )
1837
+ parser.add_argument("--asset-cache-dir", default=ASSET_CACHE_DIR, help="Asset cache directory")
1838
+ parser.add_argument("--max-asset-retries", type=int, default=3, help="Max retries per asset")
1839
+ parser.add_argument("--asset-timeout", type=int, default=300, help="Per-asset generation timeout (seconds)")
1840
+ parser.add_argument("--priv-token", default=None, help="PrivToken")
1841
+ parser.add_argument("--upload", action="store_true", default=True, help="Auto-upload to Alibaba Cloud OSS after rendering (on by default)")
1842
+ parser.add_argument("--no-upload", action="store_true", help="Skip the upload")
1843
+ parser.add_argument("--upload-title", default=None, help="Upload title (defaults to the DSL title or the filename)")
1844
+ parser.add_argument(
1845
+ "--renderer",
1846
+ choices=["local", "remote"],
1847
+ default=os.environ.get("REMOTION_RENDER_MODE", "remote"),
1848
+ help="Render mode: remote=call the remote renderer service (default); local=local Remotion CLI (needs Node.js 18+)",
1849
+ )
1850
+ parser.add_argument(
1851
+ "--remote-poll-timeout",
1852
+ type=float,
1853
+ default=float(os.environ.get("REMOTION_REMOTE_POLL_TIMEOUT", "1800")),
1854
+ help="Remote-render polling timeout (seconds, default 1800)",
1855
+ )
1856
+ parser.add_argument(
1857
+ "--remote-poll-interval",
1858
+ type=float,
1859
+ default=float(os.environ.get("REMOTION_REMOTE_POLL_INTERVAL", "5")),
1860
+ help="Remote-render polling interval (seconds, default 5)",
1861
+ )
1862
+ parser.add_argument(
1863
+ "--stub-image-url",
1864
+ default=None,
1865
+ help="Test mode: every source=gen-image asset is short-circuited to this URL, no gen-image API call (env STUB_IMAGE_URL works too, but the explicit CLI flag is preferred to avoid cross-session leakage).",
1866
+ )
1867
+ parser.add_argument(
1868
+ "--stub-video-url",
1869
+ default=None,
1870
+ help="Test mode: every source=gen-video asset is short-circuited to this URL, no gen-video API call (env STUB_VIDEO_URL works too, but the explicit CLI flag is preferred to avoid cross-session leakage).",
1871
+ )
1872
+
1873
+ args = parser.parse_args()
1874
+
1875
+ private_token = args.priv_token or os.environ.get("PRIV_TOKEN", "")
1876
+
1877
+ # --save-job ้ป˜่ฎคๅผ€ๅฏ๏ผŒไฝ†่‹ฅๆฒกๆœ‰ PRIV_TOKEN ๅˆ™่‡ชๅŠจ้™็บงไธบๅ…ณ้—ญ๏ผŒ้ฟๅ…้˜ปๅกžๆ—  token ็š„ๆœฌๅœฐ่ฐƒ่ฏ•ใ€‚
1878
+ if args.save_job and not private_token:
1879
+ LogPrint("โ„น๏ธ PRIV_TOKEN not configured; --save-job auto-disabled (will not write to database)", file=sys.stderr)
1880
+ args.save_job = False
1881
+
1882
+ if args.job_id is not None and args.job_id > 0:
1883
+ # โ”€โ”€ Load RenderPlan from the database โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1884
+ LogPrint(f"๐Ÿ“‹ loading RenderPlan from database (jobId={args.job_id})...", file=sys.stderr)
1885
+ if not private_token:
1886
+ LogPrint("โŒ --job-id mode requires PRIV_TOKEN or --priv-token", file=sys.stderr)
1887
+ sys.exit(1)
1888
+ # render_job_client lives in skills/template-bind/scripts/ (the shared
1889
+ # location for cross-skill helpers); it is already on sys.path via the
1890
+ # module-top setup.
1891
+ from render_job_client import get_plan as rjc_get_plan
1892
+ try:
1893
+ render_plan_str = rjc_get_plan(args.job_id, private_token)
1894
+ except RuntimeError as e:
1895
+ LogPrint(f"โŒ failed to fetch RenderPlan: {e}", file=sys.stderr)
1896
+ sys.exit(1)
1897
+ render_plan = json.loads(render_plan_str)
1898
+ LogPrint(f"โœ… RenderPlan loaded (jobId={args.job_id})", file=sys.stderr)
1899
+ elif args.render_plan:
1900
+ if not os.path.exists(args.render_plan):
1901
+ LogPrint(f"โŒ file does not exist: {args.render_plan}", file=sys.stderr)
1902
+ sys.exit(1)
1903
+ render_plan = load_json(args.render_plan)
1904
+ LogPrint(f"๐Ÿ“‹ loaded existing RenderPlan: {args.render_plan}", file=sys.stderr)
1905
+ else:
1906
+ if not args.dsl and not args.dsl_json:
1907
+ LogPrint("โŒ pass --dsl or --dsl-json (with --template-id), or --render-plan / --job-id", file=sys.stderr)
1908
+ parser.print_help()
1909
+ sys.exit(1)
1910
+
1911
+ if not args.template_id:
1912
+ LogPrint("โŒ pass --template-id (--binding is no longer supported; binding is now computed in-memory from --template-id)", file=sys.stderr)
1913
+ parser.print_help()
1914
+ sys.exit(1)
1915
+
1916
+ # ่งฃๆž DSL๏ผšไผ˜ๅ…ˆ --dsl-json๏ผˆinline๏ผ‰๏ผŒๅ…ถๆฌก --dsl๏ผˆๆ–‡ไปถ่ทฏๅพ„๏ผ‰
1917
+ if args.dsl_json:
1918
+ try:
1919
+ # ่ง„่ŒƒๅŒ– LLM ๅฏ่ƒฝ่พ“ๅ‡บ็š„ๅ…จ่ง’/ไธญๆ–‡ๆ ‡็‚น โ†’ ๆ ‡ๅ‡† ASCII๏ผŒ้ฟๅ… JSON ่งฃๆžๅคฑ่ดฅ
1920
+ _dsl_str = args.dsl_json
1921
+ # ไธญๆ–‡ๅผ•ๅท โ†’ ASCII ๅผ•ๅท
1922
+ _dsl_str = _dsl_str.replace('\u201c', '"').replace('\u201d', '"')
1923
+ _dsl_str = _dsl_str.replace('\u2018', "'").replace('\u2019', "'")
1924
+ # ๅ…จ่ง’ๅ†’ๅท/้€—ๅท/ๆ‹ฌๅท โ†’ ASCII๏ผˆๆœ€ๅธธ่ง็š„ JSON ็ป“ๆž„็ ดๅ่€…๏ผ‰
1925
+ _dsl_str = _dsl_str.replace('\uff1a', ':') # ๏ผš โ†’ :
1926
+ _dsl_str = _dsl_str.replace('\uff0c', ',') # ๏ผŒ โ†’ ,
1927
+ _dsl_str = _dsl_str.replace('\uff08', '(') # ๏ผˆ โ†’ (
1928
+ _dsl_str = _dsl_str.replace('\uff09', ')') # ๏ผ‰ โ†’ )
1929
+ _dsl_str = _dsl_str.replace('\u3010', '[') # ใ€ โ†’ [
1930
+ _dsl_str = _dsl_str.replace('\u3011', ']') # ใ€‘ โ†’ ]
1931
+ _dsl_str = _dsl_str.replace('\uff3b', '[') # ๏ผป โ†’ [
1932
+ _dsl_str = _dsl_str.replace('\uff3d', ']') # ๏ผฝ โ†’ ]
1933
+ _dsl_str = _dsl_str.replace('\uff5b', '{') # ๏ฝ› โ†’ {
1934
+ _dsl_str = _dsl_str.replace('\uff5d', '}') # ๏ฝ โ†’ }
1935
+ dsl = json.loads(_dsl_str)
1936
+ except json.JSONDecodeError as e:
1937
+ LogPrint(f"โŒ --dsl-json parse failed: {e}", file=sys.stderr)
1938
+ sys.exit(1)
1939
+ else:
1940
+ if not os.path.exists(args.dsl):
1941
+ LogPrint(f"โŒ file does not exist: {args.dsl}", file=sys.stderr)
1942
+ sys.exit(1)
1943
+ dsl = load_json(args.dsl)
1944
+
1945
+ # ่‡ชๅŠจ่กฅๅ…จ version ๅญ—ๆฎต๏ผˆLLM ้‡ๆž„ DSL ๆ—ถๅฏ่ƒฝ้—ๆผ๏ผ‰
1946
+ if "version" not in dsl:
1947
+ dsl["version"] = "v1alpha1"
1948
+ LogPrint("โš ๏ธ DSL missing version field; auto-filled with v1alpha1", file=sys.stderr)
1949
+
1950
+ # ้ชจๆžถๆฃ€ๆŸฅ๏ผš้˜ฒๆญข gen_script ๅŽŸๅง‹้ชจๆžถ่ขซ็›ดๆŽฅๆไบค๏ผˆ้Ÿณ้ข‘ๆ—็™ฝไธไธ€่‡ด bug๏ผ‰ใ€‚
1951
+ # agent.ts ๅทฒ็ปๅœจ MCP ๅฑ‚ๅšไบ†ๆ‹ฆๆˆช๏ผŒ่ฟ™้‡Œๆ˜ฏ็ฌฌไบŒ้“ไฟ้™ฉ๏ผŒๅ…ผๅฎน็ป•่ฟ‡ agent ็›ดๆŽฅ่ฐƒ็”จ็š„ๅœบๆ™ฏใ€‚
1952
+ # narration.text ๆ˜ฏๅ”ฏไธ€ๆƒๅจๆฅๆบ๏ผŒๆ‰€ไปฅๅชๆฃ€ๆŸฅ scenes[].audio.narrationใ€‚
1953
+ _skeleton_offenders: list[str] = []
1954
+ _skel_markers = ("[้ชจๆžถๅพ…ๅกซๅ……]", "[skeleton placeholder]", "่ฟ™ๆ˜ฏ้œ€่ฆๆ›ฟๆข็š„ๅ ไฝๆ–‡ๆกˆ")
1955
+ for _scene in dsl.get("scenes", []) or []:
1956
+ _nar = ((_scene.get("audio") or {}).get("narration") or {})
1957
+ _sid = _scene.get("id", "?")
1958
+ if _nar.get("needsFill") is True:
1959
+ _skeleton_offenders.append(f"scene {_sid}: narration.needsFill=true")
1960
+ continue
1961
+ _t = _nar.get("text") or ""
1962
+ if any(m in _t for m in _skel_markers):
1963
+ _skeleton_offenders.append(f"scene {_sid}: narration.text still has the skeleton marker")
1964
+ if _skeleton_offenders:
1965
+ LogPrint("โŒ DSL narration is still the gen_script skeleton and was not replaced with real content; refusing to continue:", file=sys.stderr)
1966
+ for _off in _skeleton_offenders[:5]:
1967
+ LogPrint(f" - {_off}", file=sys.stderr)
1968
+ if len(_skeleton_offenders) > 5:
1969
+ LogPrint(f" ... ({len(_skeleton_offenders)} total)", file=sys.stderr)
1970
+ LogPrint(
1971
+ " Fill audio.narration.text with the real narration for every scene "
1972
+ "and remove the needsFill field, then retry.",
1973
+ file=sys.stderr,
1974
+ )
1975
+ sys.exit(2)
1976
+
1977
+ errors = validate_dsl(dsl)
1978
+ if errors:
1979
+ LogPrint("โŒ DSL validation failed:", file=sys.stderr)
1980
+ for err in errors:
1981
+ LogPrint(f" - {err}", file=sys.stderr)
1982
+ sys.exit(1)
1983
+
1984
+ # binding ็”ฑ --template-id ็Žฐ็ฎ—๏ผŒไธๅ†ๆŽฅๅ—ๅค–้ƒจ binding ๆ–‡ไปถ / JSONใ€‚
1985
+ # ่ฟ™ๆ˜ฏ P1.1 ็š„ๅ…ณ้”ฎ็ฎ€ๅŒ–๏ผšDSL โ†’ RenderPlan ไน‹้—ดไธๅ†ๆœ‰็‹ฌ็ซ‹ๅทฅไปถ๏ผŒ
1986
+ # ็›ดๆŽฅ (DSL + templateId + registry) โ†’ RenderPlanใ€‚
1987
+ # binding ไป…ๅœจๅ†…ๅญ˜ไธญไฝœไธบไธญ้—ดๆ€ไฝฟ็”จ๏ผŒไธ่ฝ็›˜ใ€ไธๅ…ฅๅบ“ใ€‚้œ€่ฆๆŽ’ๆŸฅๆ—ถ
1988
+ # ็›ดๆŽฅ print(binding) ๅณๅฏใ€‚
1989
+ binding = auto_bind_template(dsl, args.template_id)
1990
+
1991
+ LogPrint(f"๐Ÿ“‹ Building RenderPlan...", file=sys.stderr)
1992
+ render_plan = build_render_plan(dsl, binding)
1993
+ render_plan["_dsl_assets"] = dsl.get("assets", [])
1994
+
1995
+ if not args.skip_asset_resolve:
1996
+ # Stub mode: short-circuit gen-image / gen-video before counting pending.
1997
+ # CLI flag takes precedence; env vars act as fallback with a visible warning
1998
+ # so silent cross-session leakage is always observable.
1999
+ stub_image_url = args.stub_image_url
2000
+ stub_video_url = args.stub_video_url
2001
+ if stub_image_url is None:
2002
+ env_v = os.environ.get("STUB_IMAGE_URL", "")
2003
+ if env_v:
2004
+ LogPrint(f"โš ๏ธ detected env var STUB_IMAGE_URL={env_v}; using it as the image stub URL (pass --stub-image-url explicitly or unset the env var)", file=sys.stderr)
2005
+ stub_image_url = env_v
2006
+ if stub_video_url is None:
2007
+ env_v = os.environ.get("STUB_VIDEO_URL", "")
2008
+ if env_v:
2009
+ LogPrint(f"โš ๏ธ detected env var STUB_VIDEO_URL={env_v}; using it as the video stub URL (pass --stub-video-url explicitly or unset the env var)", file=sys.stderr)
2010
+ stub_video_url = env_v
2011
+
2012
+ if stub_image_url or stub_video_url:
2013
+ stubbed = apply_stub_urls(
2014
+ render_plan,
2015
+ stub_image_url=stub_image_url,
2016
+ stub_video_url=stub_video_url,
2017
+ )
2018
+ if stubbed:
2019
+ LogPrint(f"๐Ÿงช Stub mode: {stubbed} asset(s) using a stub URL; generation API skipped", file=sys.stderr)
2020
+ else:
2021
+ LogPrint(f"๐Ÿงช Stub mode is on but no matching assets were found (no pending gen-image / gen-video resources)", file=sys.stderr)
2022
+ pending_count = sum(1 for a in render_plan["resolvedAssets"] if a["status"] == "pending")
2023
+ if pending_count > 0:
2024
+ # โ”€โ”€ Token pre-check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2025
+ # Fail fast with a clear message instead of letting child
2026
+ # processes silently hang waiting for interactive token input.
2027
+ if not private_token:
2028
+ LogPrint("โŒ asset generation needs an auth token, but PRIV_TOKEN env var is not set and --priv-token was not passed", file=sys.stderr)
2029
+ LogPrint(" configure it one of these ways:", file=sys.stderr)
2030
+ LogPrint(" 1. export PRIV_TOKEN=<your-token>", file=sys.stderr)
2031
+ LogPrint(" 2. python3 render_video.py --priv-token <your-token> ...", file=sys.stderr)
2032
+ sys.exit(1)
2033
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2034
+ LogPrint(f"๐Ÿ” Resolving {pending_count} missing asset(s)...", file=sys.stderr)
2035
+ render_plan = resolve_assets(
2036
+ render_plan,
2037
+ private_token=private_token,
2038
+ max_retries=args.max_asset_retries,
2039
+ timeout=args.asset_timeout,
2040
+ )
2041
+ else:
2042
+ LogPrint(f"โœ… all assets ready", file=sys.stderr)
2043
+ render_plan["status"] = "assets-ready"
2044
+
2045
+ render_plan.pop("_dsl_assets", None)
2046
+
2047
+ if not args.skip_asset_resolve:
2048
+ render_plan = adjust_timeline_to_audio(render_plan)
2049
+
2050
+ output_path = os.path.join(OUTPUT_DIR, "video.mp4")
2051
+ rp_output = args.render_plan_output or os.path.join(OUTPUT_DIR, "render-plan.json")
2052
+
2053
+ # --save-job ๆจกๅผไธ‹ RenderPlan ๅชๅญ˜ๆ•ฐๆฎๅบ“๏ผŒไธๅ†™็ฃ็›˜๏ผˆ้ฟๅ…ๅคš็”จๆˆทๅนถๅ‘ๆ—ถไบ’็›ธ่ฆ†็›–ๅŒๅๆ–‡ไปถ๏ผ‰
2054
+ if args.save_render_plan or not args.save_job:
2055
+ validate_and_fix_render_plan(render_plan)
2056
+ save_json(render_plan, rp_output)
2057
+ LogPrint(f"๐Ÿ’พ RenderPlan saved: {rp_output}", file=sys.stderr)
2058
+
2059
+ # โ”€โ”€ ไฟๅญ˜ RenderPlan ๅˆฐๆ•ฐๆฎๅบ“ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2060
+ # ้ป˜่ฎค args.save_job=True๏ผŒๆ‰€ไปฅๆ— ่ฎบ --resolve-only ่ฟ˜ๆ˜ฏ็›ดๆŽฅๆธฒๆŸ“๏ผŒ้ƒฝไผš่ฝๅบ“๏ผ›
2061
+ # ๆฒกๆœ‰ PRIV_TOKEN ๆ—ถๅทฒๅœจ main ๅ…ฅๅฃ้™็บงไธบ Falseใ€‚
2062
+ if args.save_job:
2063
+ # ๅ…ฅๅบ“ๅ‰ๆ ก้ชŒ + ่‡ชๅŠจไฟฎๅค
2064
+ validate_and_fix_render_plan(render_plan)
2065
+
2066
+ from render_job_client import create_job as rjc_create_job, save_plan as rjc_save_plan
2067
+ render_plan_json_str = json.dumps(render_plan, ensure_ascii=False)
2068
+ # ่‹ฅๅทฒๆœ‰ job_id ๅˆ™ๅค็”จ๏ผŒๅฆๅˆ™ๆ–ฐๅปบ
2069
+ _job_id = args.job_id
2070
+ if not _job_id:
2071
+ # ๆž„้€  DB ๅˆ— dsl_meta๏ผˆ่ฝป้‡ๆ‘˜่ฆไพ›ๅˆ—่กจๆŸฅ่ฏขไฝฟ็”จ๏ผ‰ใ€‚
2072
+ # ๅ•ไธ€ๆฅๆบๆ˜ฏ dsl.meta โ€” render_plan ้‡Œๅชๆ”พๆธฒๆŸ“ๅฟ…้œ€็š„ๆœ€ๅฐ้›†๏ผŒไธๅ†็ผ“ๅญ˜ๆ•ดๆฎต metaใ€‚
2073
+ _locals = locals()
2074
+ _dsl_obj = _locals.get("dsl")
2075
+ _binding_obj = _locals.get("binding")
2076
+ _meta_summary: dict = {}
2077
+ # ็›ดๆŽฅๆธฒๆŸ“๏ผˆ--render-plan / --job-id๏ผ‰่ฟ™็ง่ทฏๅพ„ไธ‹ๆฒกๆœ‰ dsl ๅฏน่ฑก๏ผŒ
2078
+ # ้€€่€Œ็”จ render_plan ้‡Œ็š„ๅญ—ๆฎตๆ‹ผไธ€ไปฝๆ‘˜่ฆ๏ผŒไฟ่ฏๅˆ—่กจ้กตๆœ‰ๅฏ่ฏปไฟกๆฏใ€‚
2079
+ if _dsl_obj:
2080
+ _src_meta = (_dsl_obj.get("meta") or {})
2081
+ for _k in ("title", "topic", "platform", "templateId", "targetDuration"):
2082
+ _v = _src_meta.get(_k)
2083
+ if _v:
2084
+ _meta_summary[_k] = _v
2085
+ _ratio = (_dsl_obj.get("global") or {}).get("aspectRatio")
2086
+ if _ratio:
2087
+ _meta_summary["aspectRatio"] = _ratio
2088
+ else:
2089
+ for _k in ("title", "templateId", "targetDuration"):
2090
+ _v = render_plan.get(_k)
2091
+ if _v:
2092
+ _meta_summary[_k] = _v
2093
+ _rc = render_plan.get("renderConfig") or {}
2094
+ if _rc.get("width") and _rc.get("height"):
2095
+ _meta_summary["resolution"] = f"{_rc['width']}x{_rc['height']}"
2096
+ if _binding_obj and isinstance(_binding_obj, dict):
2097
+ _tid = _binding_obj.get("templateId")
2098
+ if _tid and "templateId" not in _meta_summary:
2099
+ _meta_summary["templateId"] = _tid
2100
+ _dsl_str = json.dumps(_dsl_obj, ensure_ascii=False) if _dsl_obj else ""
2101
+ try:
2102
+ _job_id = rjc_create_job(
2103
+ private_token,
2104
+ dsl=_dsl_str,
2105
+ dsl_meta=_meta_summary or None,
2106
+ )
2107
+ LogPrint(f"โœ… render job created: jobId={_job_id}", file=sys.stderr)
2108
+ except RuntimeError as e:
2109
+ LogPrint(f"โš ๏ธ failed to create render job (--save-job auto-disabled, rendering continues): {e}", file=sys.stderr)
2110
+ args.save_job = False
2111
+ _job_id = None
2112
+ if args.save_job and _job_id:
2113
+ try:
2114
+ rjc_save_plan(_job_id, render_plan_json_str, private_token)
2115
+ LogPrint(f"โœ… RenderPlan saved to database: jobId={_job_id}", file=sys.stderr)
2116
+ # ๆŠŠๅ›žๅกซ็š„ job_id ๆšด้œฒ็ป™ๅŽ็ปญ saveManifest ไฝฟ็”จ
2117
+ args.job_id = _job_id
2118
+ print(f"\n๐Ÿ“ฆ render job jobId: {_job_id}")
2119
+ except RuntimeError as e:
2120
+ LogPrint(f"โš ๏ธ failed to save RenderPlan to database (--save-job auto-disabled, rendering continues): {e}", file=sys.stderr)
2121
+ args.save_job = False
2122
+
2123
+ if args.resolve_only:
2124
+ generated = sum(1 for a in render_plan["resolvedAssets"] if a["status"] == "generated")
2125
+ failed = sum(1 for a in render_plan["resolvedAssets"] if a["status"] == "failed")
2126
+ total = len(render_plan["resolvedAssets"])
2127
+ LogPrint(f"\n๐Ÿ“Š Asset resolution finished (resolve-only mode): total {total}, generated {generated}, failed {failed}", file=sys.stderr)
2128
+
2129
+ fps = render_plan.get("renderConfig", {}).get("fps", 30)
2130
+ target_dur = render_plan.get("targetDuration")
2131
+ actual_dur = render_plan.get("renderConfig", {}).get("totalDuration", 0)
2132
+
2133
+ # โ”€โ”€ ๆ‘˜่ฆ่พ“ๅ‡บๅˆฐ stdout๏ผŒไพ› Agent ่ฏปๅ–่ต„ไบง URL๏ผˆไธ่ฆๆ”นไธบ LogPrint/stderr๏ผ‰โ”€โ”€
2134
+ print(f"\n๐Ÿ“Š Asset resolution finished: total {total}, generated {generated}, failed {failed}")
2135
+ if target_dur:
2136
+ diff = actual_dur - target_dur
2137
+ sign = "+" if diff >= 0 else ""
2138
+ print(f"โฑ Actual duration: {actual_dur}s (target {target_dur}s, {sign}{diff:.1f}s)")
2139
+ else:
2140
+ print(f"โฑ Actual duration: {actual_dur}s")
2141
+
2142
+ print(f"\n๐ŸŽฌ Scene timeline:")
2143
+ for entry in render_plan.get("timeline", []):
2144
+ scene_dur = round(entry.get("durationFrames", 0) / fps, 1)
2145
+ scene_id = entry.get("sceneId", "?")
2146
+ print(f" {scene_id:<25} {scene_dur:>6.1f}s")
2147
+
2148
+ audio_assets = [a for a in render_plan.get("resolvedAssets", [])
2149
+ if a.get("type") == "audio" and a.get("status") == "generated"]
2150
+ if audio_assets:
2151
+ print(f"\n๐Ÿ”Š TTS audio:")
2152
+ for asset in audio_assets:
2153
+ dur_s = round((asset.get("duration") or 0) / 1000, 1)
2154
+ url = asset.get("url", "")
2155
+ print(f" {asset['assetId']:<25} {dur_s:>6.1f}s {url}")
2156
+
2157
+ image_assets = [a for a in render_plan.get("resolvedAssets", [])
2158
+ if a.get("type") == "image" and a.get("status") == "generated"]
2159
+ if image_assets:
2160
+ print(f"\n๐Ÿ–ผ Image assets:")
2161
+ for asset in image_assets:
2162
+ url = asset.get("url", "")
2163
+ print(f" {asset['assetId']:<25} {url}")
2164
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2165
+
2166
+ if failed > 0:
2167
+ sys.exit(1)
2168
+ return
2169
+
2170
+ if render_plan["status"] == "failed":
2171
+ LogPrint("โŒ asset resolution had failures; cannot continue rendering", file=sys.stderr)
2172
+ if not args.save_job:
2173
+ save_json(render_plan, rp_output)
2174
+ sys.exit(1)
2175
+
2176
+ render_mode = args.renderer
2177
+ upload_title_hint = (
2178
+ args.upload_title
2179
+ or render_plan.get("title")
2180
+ or os.path.splitext(os.path.basename(output_path))[0]
2181
+ )
2182
+ # ้˜ฟ้‡Œไบ‘ VOD ไธŽ ab-api DTO ้ƒฝๆŒ‰"ๅญ—็ฌฆๆ•ฐ"้™ๅˆถ title ้•ฟๅบฆ๏ผˆmax=128๏ผ‰ใ€‚
2183
+ # DSL ็š„ meta.title ็ปๅธธๆ˜ฏไธ€ๆฎต้•ฟๆ่ฟฐ๏ผˆgen-script ๆŠŠ topic ็›ดๆŽฅๅฝ“ title๏ผ‰๏ผŒ
2184
+ # ่ฟ™้‡Œๆˆชๆ–ญๅˆฐ 128๏ผŒ้ฟๅ…่ฟœ็จ‹ๆธฒๆŸ“่ท‘ๅฎŒไน‹ๅŽๆ ฝๅœจ CreateUploadVideoToken ๆ ก้ชŒไธŠใ€‚
2185
+ if upload_title_hint and len(upload_title_hint) > 128:
2186
+ upload_title_hint = upload_title_hint[:128]
2187
+ conversation_id = os.environ.get("CONVERSATION_ID") or None
2188
+ if render_mode == "remote":
2189
+ LogPrint(f"๐ŸŒ Using remote render mode (MM_API_BASE_URL)", file=sys.stderr)
2190
+ if not private_token:
2191
+ LogPrint("โŒ remote render requires PRIV_TOKEN or --priv-token", file=sys.stderr)
2192
+ sys.exit(1)
2193
+ success = render_with_remote_api(
2194
+ render_plan,
2195
+ output_path,
2196
+ private_token=private_token,
2197
+ poll_timeout=args.remote_poll_timeout,
2198
+ poll_interval=args.remote_poll_interval,
2199
+ upload_title=upload_title_hint,
2200
+ conversation_id=conversation_id,
2201
+ )
2202
+ else:
2203
+ LogPrint(f"๐Ÿ’ป Using local render mode", file=sys.stderr)
2204
+ render_plan["renderMode"] = "local"
2205
+ success = render_with_local_cli(render_plan, output_path)
2206
+
2207
+ # save-job ๆจกๅผไธ‹ RenderPlan ๅชๅ…ฅๅบ“๏ผ›้ž save-job ๆ‰ๅ†™ๅ…ฑไบซ็ฃ็›˜
2208
+ if not args.save_job:
2209
+ save_json(render_plan, rp_output)
2210
+
2211
+ manifest = {
2212
+ "outputPath": output_path,
2213
+ "renderPlanPath": "" if args.save_job else rp_output,
2214
+ "renderConfig": render_plan["renderConfig"],
2215
+ "status": render_plan["status"],
2216
+ "renderMode": render_mode,
2217
+ "createdAt": render_plan["createdAt"],
2218
+ "completedAt": now_iso(),
2219
+ "assetCount": len(render_plan["resolvedAssets"]),
2220
+ "sceneCount": len(render_plan["timeline"]),
2221
+ "errorCount": len(render_plan["errors"]),
2222
+ }
2223
+ # --save-job ๆจกๅผไธ‹ Manifest ๅชๅญ˜ๆ•ฐๆฎๅบ“๏ผŒไธๅ†™็ฃ็›˜๏ผˆ้ฟๅ…ๅคš็”จๆˆทๅนถๅ‘ๆ—ถไบ’็›ธ่ฆ†็›–ๅŒๅๆ–‡ไปถ๏ผ‰
2224
+ manifest_path = None if args.save_job else os.path.join(OUTPUT_DIR, "render-manifest.json")
2225
+ if manifest_path:
2226
+ save_json(manifest, manifest_path)
2227
+
2228
+ if success:
2229
+ LogPrint(f"\n๐ŸŽ‰ Video render finished!", file=sys.stderr)
2230
+ if render_mode == "remote":
2231
+ upload_info = render_plan.get("upload") or {}
2232
+ remote_url = upload_info.get("fileUrl", "")
2233
+ playback_url = upload_info.get("playbackUrl", "")
2234
+ LogPrint(f" Remote video: {remote_url}", file=sys.stderr)
2235
+ if playback_url:
2236
+ LogPrint(f" Playback URL: {playback_url}", file=sys.stderr)
2237
+ # Print the playable video URL to stdout; prefer the transcoded playbackUrl.
2238
+ effective_playback = playback_url or remote_url
2239
+ if effective_playback and not effective_playback.startswith("vod://"):
2240
+ print(f"\n๐ŸŽฌ Video playback URL: {effective_playback}")
2241
+ else:
2242
+ LogPrint(f" Video: {output_path}", file=sys.stderr)
2243
+ if args.save_job:
2244
+ LogPrint(f" RenderPlan: persisted to database (jobId={args.job_id})", file=sys.stderr)
2245
+ else:
2246
+ LogPrint(f" RenderPlan: {rp_output}", file=sys.stderr)
2247
+ if manifest_path:
2248
+ LogPrint(f" Manifest: {manifest_path}", file=sys.stderr)
2249
+
2250
+ if render_mode == "remote":
2251
+ # In remote mode the fileUrl came from the server; reuse it into the manifest.
2252
+ manifest["upload"] = render_plan.get("upload", {})
2253
+ if manifest_path:
2254
+ save_json(manifest, manifest_path)
2255
+ LogPrint(f" Manifest updated (remote fileUrl): {manifest_path}", file=sys.stderr)
2256
+ elif args.upload and not args.no_upload:
2257
+ try:
2258
+ from upload_video import upload_media_file
2259
+
2260
+ upload_title = upload_title_hint
2261
+ LogPrint(f"\n๐Ÿ“ค Uploading video to OSS...", file=sys.stderr)
2262
+ upload_result = upload_media_file(file_path=output_path, title=upload_title)
2263
+ manifest["upload"] = {
2264
+ "fileUrl": upload_result["fileUrl"],
2265
+ "title": upload_title,
2266
+ "uploadedAt": now_iso(),
2267
+ }
2268
+ if manifest_path:
2269
+ save_json(manifest, manifest_path)
2270
+ LogPrint(f" Manifest updated: {manifest_path}", file=sys.stderr)
2271
+ except Exception as e:
2272
+ LogPrint(f"\nโš ๏ธ upload failed (the video stays local): {e}", file=sys.stderr)
2273
+
2274
+ # โ”€โ”€ Save Manifest to the database โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2275
+ if args.save_job and args.job_id:
2276
+ from render_job_client import save_manifest as rjc_save_manifest
2277
+ try:
2278
+ rjc_save_manifest(args.job_id, manifest, private_token)
2279
+ LogPrint(f"โœ… Manifest saved to database: jobId={args.job_id}", file=sys.stderr)
2280
+ except RuntimeError as e:
2281
+ LogPrint(f"โš ๏ธ failed to save Manifest to database (local result unaffected): {e}", file=sys.stderr)
2282
+ else:
2283
+ LogPrint(f"\nโŒ render failed, see RenderPlan for details: {rp_output}", file=sys.stderr)
2284
+ sys.exit(1)
2285
+
2286
+
2287
+ if __name__ == "__main__":
2288
+ main()