@remixmate/cli 0.9.72 → 0.9.74
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.
- package/LICENSE +45 -21
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/argv.js +1 -51
- package/dist/auth/auth-lock.js +1 -100
- package/dist/auth/auto-login.js +7 -75
- package/dist/auth/commands.js +40 -290
- package/dist/auth/credential-store.js +1 -143
- package/dist/auth/device-flow-runner.js +13 -110
- package/dist/auth/device-flow.js +1 -132
- package/dist/auth/ensure.js +1 -62
- package/dist/auth/environment.js +1 -69
- package/dist/auth/pending-store.js +1 -45
- package/dist/auth/resolve.js +1 -44
- package/dist/billing.js +3 -76
- package/dist/capabilities.js +1 -75
- package/dist/cli.js +39 -211
- package/dist/diagnostics.js +2 -70
- package/dist/doctor.js +9 -188
- package/dist/errors.js +1 -33
- package/dist/exec.js +4 -47
- package/dist/handlers/gen-digital-human.js +33 -264
- package/dist/handlers/gen-image.js +9 -113
- package/dist/handlers/gen-post.js +15 -192
- package/dist/handlers/gen-video.js +7 -185
- package/dist/handlers/gen-voice.js +19 -133
- package/dist/handlers/index.js +1 -20
- package/dist/handlers/shared.js +1 -66
- package/dist/handlers/template-init.js +1 -21
- package/dist/http.js +3 -155
- package/dist/index.js +1 -8
- package/dist/install.js +40 -448
- package/dist/manifest.json +44 -3
- package/dist/progress.js +2 -15
- package/dist/project/commands.js +34 -156
- package/dist/project/host.js +1 -151
- package/dist/project/resolve.js +1 -65
- package/dist/project/store.js +2 -97
- package/dist/project/take.js +1 -106
- package/dist/project/trace.js +5 -278
- package/dist/registry.js +3 -70
- package/dist/runner.js +7 -225
- package/dist/skill-schema.js +1 -194
- package/dist/template/args.js +1 -69
- package/dist/template/check.js +2 -30
- package/dist/template/commands.js +34 -163
- package/dist/template/constants.js +1 -46
- package/dist/template/init.js +5 -133
- package/dist/template/list.js +1 -46
- package/dist/template/preview.js +7 -318
- package/dist/template/proc.js +1 -43
- package/dist/template/project.js +6 -161
- package/dist/template/publish.js +5 -185
- package/dist/template/render-client.js +2 -79
- package/dist/template/upgrade.js +4 -123
- package/dist/text.js +1 -15
- package/package.json +2 -35
- package/skills/doc-parse/scripts/extract_via_render.py +32 -32
- package/skills/export-jianying/scripts/gen_jianying_draft.py +188 -188
- package/skills/gen-script/SKILL.md +23 -1
- package/skills/gen-script/scripts/gen_script.py +644 -551
- package/skills/gen-script/skill.json +12 -0
- package/skills/gen-script/version.json +1 -1
- package/skills/prepare-video-assets/scripts/prepare_video_assets.py +21 -21
- package/skills/render-video/scripts/_chrome_vendor.py +17 -17
- package/skills/render-video/scripts/_digital_human_segments.py +34 -34
- package/skills/render-video/scripts/_video_probe.py +33 -33
- package/skills/render-video/scripts/_vod_polling.py +13 -13
- package/skills/render-video/scripts/remote_renderer_client.py +42 -42
- package/skills/render-video/scripts/render_video.py +639 -639
- package/skills/render-video/scripts/required_props.py +37 -37
- package/skills/render-video/scripts/upload_video.py +35 -35
- package/skills/render-video/version.json +1 -1
- package/skills/template-registry/SKILL.md +16 -0
- package/skills/template-registry/scripts/countdown_spec.py +92 -92
- package/skills/template-registry/scripts/doc_extract.py +40 -40
- package/skills/template-registry/scripts/list_templates.py +303 -191
- package/skills/template-registry/scripts/log_diagnostics.py +1 -1
- package/skills/template-registry/scripts/match_template.py +87 -87
- package/skills/template-registry/scripts/payload_hydrator.py +89 -89
- package/skills/template-registry/scripts/registry_loader.py +159 -159
- package/skills/template-registry/scripts/render_job_client.py +75 -75
- package/skills/template-registry/scripts/showcase_loader.py +44 -44
- package/skills/template-registry/skill.json +4 -1
- package/skills/template-registry/version.json +1 -1
- package/skills/template-registry/video_dsl/runtime/__init__.py +8 -8
- package/skills/template-registry/video_dsl/runtime/dsl_validator.py +329 -329
- package/skills/template-registry/video_dsl/runtime/narration_budget.py +36 -36
- package/skills/template-registry/video_dsl/runtime/payload_contract.py +7 -7
- package/skills/template-registry/video_dsl/runtime/prompt_enhancer.py +98 -98
- package/skills/template-registry/video_dsl/runtime/timeline_compiler.py +127 -127
- package/skills/template-registry/video_dsl/schema/video-dsl-v1alpha1.json +14 -1
- package/skills/video-parser/scripts/analyze_video.py +173 -173
- package/skills/video-parser/scripts/deconstruct_video.py +91 -91
- package/skills/video-parser/scripts/parse_via_render.py +21 -21
- package/skills/web-screenshot/scripts/_media_screenshot/__init__.py +4 -4
- package/skills/web-screenshot/scripts/_media_screenshot/bootstrap.py +7 -7
- package/skills/web-screenshot/scripts/_media_screenshot/browser.py +4 -4
- package/skills/web-screenshot/scripts/_media_screenshot/cli_args.py +4 -4
- package/skills/web-screenshot/scripts/_media_screenshot/js_loader.py +2 -2
- package/skills/web-screenshot/scripts/_media_screenshot/overlay.py +1 -1
- package/skills/web-screenshot/scripts/_media_screenshot/reader.py +40 -40
- package/skills/web-screenshot/scripts/_media_screenshot/recording.py +11 -11
- package/skills/web-screenshot/scripts/_media_screenshot/scenes/__init__.py +8 -8
- package/skills/web-screenshot/scripts/_media_screenshot/scenes/arrow.py +2 -2
- package/skills/web-screenshot/scripts/_media_screenshot/scenes/caption.py +1 -1
- package/skills/web-screenshot/scripts/_media_screenshot/scenes/click.py +1 -1
- package/skills/web-screenshot/scripts/_media_screenshot/scenes/code_lines.py +5 -5
- package/skills/web-screenshot/scripts/_media_screenshot/scenes/focus.py +1 -1
- package/skills/web-screenshot/scripts/_media_screenshot/scenes/highlight.py +3 -3
- package/skills/web-screenshot/scripts/_media_screenshot/scenes/hover.py +1 -1
- package/skills/web-screenshot/scripts/_media_screenshot/scenes/redact.py +1 -1
- package/skills/web-screenshot/scripts/_media_screenshot/scenes/scroll.py +1 -1
- package/skills/web-screenshot/scripts/_media_screenshot/scenes/sequence.py +1 -1
- package/skills/web-screenshot/scripts/_media_screenshot/scenes/title_card.py +1 -1
- package/skills/web-screenshot/scripts/_media_screenshot/scenes/type.py +1 -1
- package/skills/web-screenshot/scripts/_media_screenshot/scenes/wait.py +1 -1
- package/skills/web-screenshot/scripts/_media_screenshot/scenes/zoom.py +2 -2
- package/skills/web-screenshot/scripts/_media_screenshot/screenshot.py +15 -15
- package/skills/web-screenshot/scripts/_media_screenshot/storyboard.py +2 -2
- package/skills/web-screenshot/scripts/_media_screenshot/template.py +11 -11
- package/skills/web-screenshot/scripts/_media_screenshot/trim.py +10 -10
- package/skills/web-screenshot/scripts/_media_screenshot/urlguard.py +22 -22
- package/skills/web-screenshot/scripts/_media_upload.py +33 -33
- package/skills/web-screenshot/scripts/read_page.py +15 -15
- package/skills/web-screenshot/scripts/record.py +26 -26
- package/skills/web-screenshot/scripts/screenshot.py +5 -5
- package/dist/argv.d.ts +0 -15
- package/dist/auth/auth-lock.d.ts +0 -26
- package/dist/auth/auto-login.d.ts +0 -20
- package/dist/auth/commands.d.ts +0 -8
- package/dist/auth/credential-store.d.ts +0 -60
- package/dist/auth/device-flow-runner.d.ts +0 -58
- package/dist/auth/device-flow.d.ts +0 -52
- package/dist/auth/ensure.d.ts +0 -55
- package/dist/auth/environment.d.ts +0 -38
- package/dist/auth/pending-store.d.ts +0 -33
- package/dist/auth/resolve.d.ts +0 -30
- package/dist/billing.d.ts +0 -44
- package/dist/capabilities.d.ts +0 -63
- package/dist/cli.d.ts +0 -13
- package/dist/diagnostics.d.ts +0 -24
- package/dist/doctor.d.ts +0 -22
- package/dist/errors.d.ts +0 -29
- package/dist/exec.d.ts +0 -18
- package/dist/handlers/gen-digital-human.d.ts +0 -17
- package/dist/handlers/gen-image.d.ts +0 -20
- package/dist/handlers/gen-post.d.ts +0 -22
- package/dist/handlers/gen-video.d.ts +0 -19
- package/dist/handlers/gen-voice.d.ts +0 -26
- package/dist/handlers/index.d.ts +0 -20
- package/dist/handlers/shared.d.ts +0 -26
- package/dist/handlers/template-init.d.ts +0 -10
- package/dist/http.d.ts +0 -72
- package/dist/index.d.ts +0 -9
- package/dist/install.d.ts +0 -71
- package/dist/progress.d.ts +0 -23
- package/dist/project/commands.d.ts +0 -19
- package/dist/project/host.d.ts +0 -91
- package/dist/project/resolve.d.ts +0 -43
- package/dist/project/store.d.ts +0 -31
- package/dist/project/take.d.ts +0 -50
- package/dist/project/trace.d.ts +0 -98
- package/dist/registry.d.ts +0 -30
- package/dist/runner.d.ts +0 -39
- package/dist/skill-schema.d.ts +0 -138
- package/dist/template/args.d.ts +0 -25
- package/dist/template/check.d.ts +0 -23
- package/dist/template/commands.d.ts +0 -25
- package/dist/template/constants.d.ts +0 -39
- package/dist/template/init.d.ts +0 -28
- package/dist/template/list.d.ts +0 -23
- package/dist/template/preview.d.ts +0 -60
- package/dist/template/proc.d.ts +0 -26
- package/dist/template/project.d.ts +0 -47
- package/dist/template/publish.d.ts +0 -43
- package/dist/template/render-client.d.ts +0 -39
- package/dist/template/upgrade.d.ts +0 -44
- package/dist/text.d.ts +0 -8
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
视频渲染工具 - 接收 DSL + TemplateBinding,补齐素材,调用 Remotion 渲染
|
|
4
2
|
|
|
5
|
-
默认行为:
|
|
6
|
-
读取 DSL 与 TemplateBinding,解析缺失素材,调用原子 Skills 生成,
|
|
7
|
-
编译时间线,最终调用 Remotion 渲染引擎输出视频。
|
|
8
3
|
|
|
9
|
-
用法:
|
|
10
|
-
python render_video.py --dsl video.dsl.json --template-id <templateId>
|
|
11
|
-
python render_video.py --dsl video.dsl.json --template-id <templateId> --resolve-only
|
|
12
|
-
python render_video.py --render-plan video.render-plan.json
|
|
13
4
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
25
|
|
|
26
26
|
import argparse
|
|
27
27
|
import atexit
|
|
@@ -38,25 +38,25 @@ import urllib.request
|
|
|
38
38
|
from datetime import datetime, timezone
|
|
39
39
|
from typing import Optional
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
52
|
_SHARED_LIB_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "template-registry")
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
60
|
sys.path.insert(0, _SHARED_LIB_DIR)
|
|
61
61
|
sys.path.insert(0, os.path.join(_SHARED_LIB_DIR, "scripts"))
|
|
62
62
|
from video_dsl.runtime.timeline_compiler import (
|
|
@@ -66,52 +66,52 @@ from video_dsl.runtime.timeline_compiler import (
|
|
|
66
66
|
assign_sentence_indices,
|
|
67
67
|
)
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
|
|
70
70
|
_TRAILING_PUNCT_RE = re.compile(r"[。!?;,,、::..…]+$")
|
|
71
71
|
|
|
72
72
|
def _strip_subtitle_trailing_punct(text: str) -> str:
|
|
73
|
-
|
|
73
|
+
|
|
74
74
|
return _TRAILING_PUNCT_RE.sub("", text)
|
|
75
75
|
|
|
76
76
|
|
|
77
|
-
# ── 字幕段二次平衡(切长 + 合短) ─────────────────────────────────────
|
|
78
77
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
|
|
79
|
+
_SUB_MAX_CHARS = 18
|
|
80
|
+
_SUB_MIN_CHARS = 7
|
|
81
|
+
_SUB_SPLIT_RE = re.compile(r"(?<=[,,、;;])")
|
|
82
82
|
|
|
83
83
|
|
|
84
84
|
def _rebalance_subtitle_segments(segs: list[dict]) -> list[dict]:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
89
|
if not segs:
|
|
90
90
|
return segs
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
|
|
93
93
|
expanded: list[dict] = []
|
|
94
94
|
for seg in segs:
|
|
95
95
|
text = seg["text"]
|
|
96
96
|
if len(text) <= _SUB_MAX_CHARS:
|
|
97
97
|
expanded.append(seg)
|
|
98
98
|
continue
|
|
99
|
-
|
|
99
|
+
|
|
100
100
|
parts = _SUB_SPLIT_RE.split(text)
|
|
101
101
|
parts = [p for p in parts if p.strip()]
|
|
102
102
|
if len(parts) <= 1:
|
|
103
|
-
|
|
103
|
+
|
|
104
104
|
expanded.append(seg)
|
|
105
105
|
continue
|
|
106
|
-
|
|
106
|
+
|
|
107
107
|
total_chars = max(sum(len(p) for p in parts), 1)
|
|
108
108
|
total_frames = seg["endFrame"] - seg["startFrame"]
|
|
109
109
|
cur_frame = seg["startFrame"]
|
|
110
110
|
for p in parts:
|
|
111
111
|
ratio = len(p) / total_chars
|
|
112
112
|
frames = max(int(total_frames * ratio), 1)
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
|
|
114
|
+
|
|
115
115
|
expanded.append({
|
|
116
116
|
**seg,
|
|
117
117
|
"text": _strip_subtitle_trailing_punct(p.strip()),
|
|
@@ -119,22 +119,22 @@ def _rebalance_subtitle_segments(segs: list[dict]) -> list[dict]:
|
|
|
119
119
|
"endFrame": cur_frame + frames,
|
|
120
120
|
})
|
|
121
121
|
cur_frame += frames
|
|
122
|
-
|
|
122
|
+
|
|
123
123
|
if expanded:
|
|
124
124
|
expanded[-1]["endFrame"] = seg["endFrame"]
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
|
|
127
127
|
if len(expanded) <= 1:
|
|
128
128
|
return expanded
|
|
129
129
|
merged: list[dict] = [expanded[0]]
|
|
130
130
|
for seg in expanded[1:]:
|
|
131
131
|
if len(seg["text"]) < _SUB_MIN_CHARS and merged:
|
|
132
|
-
|
|
132
|
+
|
|
133
133
|
merged[-1]["text"] = merged[-1]["text"] + seg["text"]
|
|
134
134
|
merged[-1]["endFrame"] = seg["endFrame"]
|
|
135
135
|
else:
|
|
136
136
|
merged.append(seg)
|
|
137
|
-
|
|
137
|
+
|
|
138
138
|
if len(merged) > 1 and len(merged[-1]["text"]) < _SUB_MIN_CHARS:
|
|
139
139
|
merged[-2]["text"] = merged[-2]["text"] + merged[-1]["text"]
|
|
140
140
|
merged[-2]["endFrame"] = merged[-1]["endFrame"]
|
|
@@ -142,15 +142,15 @@ def _rebalance_subtitle_segments(segs: list[dict]) -> list[dict]:
|
|
|
142
142
|
|
|
143
143
|
return merged
|
|
144
144
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
|
|
148
148
|
_DEFAULT_RENDERER_DIR = os.path.abspath(
|
|
149
149
|
os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "apps", "ab-render")
|
|
150
150
|
)
|
|
151
151
|
REMOTION_RENDERER_DIR = os.environ.get("REMOTION_RENDERER_DIR", _DEFAULT_RENDERER_DIR)
|
|
152
|
-
|
|
153
|
-
|
|
152
|
+
|
|
153
|
+
|
|
154
154
|
OUTPUT_DIR = os.environ.get("REMOTION_OUTPUT_DIR") or tempfile.mkdtemp(prefix="ab-render-")
|
|
155
155
|
ASSET_CACHE_DIR = os.environ.get("ASSET_CACHE_DIR", "./.asset-cache")
|
|
156
156
|
|
|
@@ -164,13 +164,13 @@ RESOLUTION_MAP = {
|
|
|
164
164
|
|
|
165
165
|
|
|
166
166
|
def _narration_speed(narration: dict) -> Optional[float]:
|
|
167
|
-
"""Read a narration block's speech-rate multiplier, or None when unset.
|
|
168
167
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
|
|
174
174
|
if not isinstance(narration, dict):
|
|
175
175
|
return None
|
|
176
176
|
raw = narration.get("speed")
|
|
@@ -180,14 +180,14 @@ def _narration_speed(narration: dict) -> Optional[float]:
|
|
|
180
180
|
|
|
181
181
|
|
|
182
182
|
def _narration_language_boost(narration: dict) -> Optional[str]:
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
|
|
191
191
|
if not isinstance(narration, dict):
|
|
192
192
|
return None
|
|
193
193
|
raw = narration.get("languageBoost")
|
|
@@ -197,14 +197,14 @@ def _narration_language_boost(narration: dict) -> Optional[str]:
|
|
|
197
197
|
|
|
198
198
|
|
|
199
199
|
def _narration_voice(narration: dict) -> Optional[str]:
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
|
|
208
208
|
if not isinstance(narration, dict):
|
|
209
209
|
return None
|
|
210
210
|
raw = narration.get("voiceId")
|
|
@@ -214,12 +214,12 @@ def _narration_voice(narration: dict) -> Optional[str]:
|
|
|
214
214
|
|
|
215
215
|
|
|
216
216
|
def extract_narration_lines(narration: dict) -> Optional[tuple[list[str], int, list[Optional[float]]]]:
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
|
|
223
223
|
if not narration:
|
|
224
224
|
return None
|
|
225
225
|
items = narration.get("items")
|
|
@@ -263,16 +263,16 @@ _SKILLS_BASE_DIR = os.environ.get(
|
|
|
263
263
|
|
|
264
264
|
|
|
265
265
|
def _resolve_ab_skill_cli_path() -> str | None:
|
|
266
|
-
"""Locate the compiled ab-skill CLI (dist/cli.js).
|
|
267
266
|
|
|
268
|
-
Search order:
|
|
269
|
-
1. AB_SKILL_CLI_PATH env (ab-agent always sets this)
|
|
270
|
-
2. <_SKILLS_BASE_DIR>/../dist/cli.js (ab-skill source layout)
|
|
271
|
-
3. <_SKILLS_BASE_DIR>/../skills-cli/cli.js (ab-agent bundled layout)
|
|
272
267
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
|
|
276
276
|
explicit = os.environ.get("AB_SKILL_CLI_PATH", "").strip()
|
|
277
277
|
if explicit and os.path.exists(explicit):
|
|
278
278
|
return explicit
|
|
@@ -287,21 +287,21 @@ def _resolve_ab_skill_cli_path() -> str | None:
|
|
|
287
287
|
|
|
288
288
|
|
|
289
289
|
def build_skill_command(skill_name: str) -> list[str]:
|
|
290
|
-
"""Return the argv prefix to invoke sibling skill `skill_name`.
|
|
291
290
|
|
|
292
|
-
Reads <_SKILLS_BASE_DIR>/<skill_name>/skill.json once and dispatches by
|
|
293
|
-
entry.type:
|
|
294
|
-
- python → [sys.executable, "<skillDir>/<scriptPath>"]
|
|
295
|
-
- http / builtin → [node, "<ab-skill-cli>", skill_name]
|
|
296
291
|
|
|
297
|
-
Centralizing the dispatch here means the 4 resolve_asset_* functions
|
|
298
|
-
only need to append their per-call --flag value pairs to the prefix —
|
|
299
|
-
they don't have to know whether the sibling is Python or TS.
|
|
300
292
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
|
|
305
305
|
skill_dir = os.path.join(_SKILLS_BASE_DIR, skill_name)
|
|
306
306
|
skill_json_path = os.path.join(skill_dir, "skill.json")
|
|
307
307
|
if not os.path.exists(skill_json_path):
|
|
@@ -338,31 +338,31 @@ def build_skill_command(skill_name: str) -> list[str]:
|
|
|
338
338
|
|
|
339
339
|
|
|
340
340
|
def resolve_remotion_entry(template_id: str, aspect_ratio: str) -> str:
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
|
|
363
363
|
if template_id:
|
|
364
364
|
try:
|
|
365
|
-
from registry_loader import get_template
|
|
365
|
+
from registry_loader import get_template
|
|
366
366
|
tpl = get_template(template_id, include_all_statuses=True)
|
|
367
367
|
if tpl:
|
|
368
368
|
entry_map = tpl.get("remotionEntry") or {}
|
|
@@ -375,24 +375,24 @@ def resolve_remotion_entry(template_id: str, aspect_ratio: str) -> str:
|
|
|
375
375
|
|
|
376
376
|
|
|
377
377
|
def resolve_cover_composition_id(template_id: str) -> Optional[str]:
|
|
378
|
-
"""Look up the cover compositionId for a template via the registry.
|
|
379
378
|
|
|
380
|
-
Each template's `compositions[]` array may contain at most one entry with
|
|
381
|
-
`slot == "cover"`. We return its compositionId, or None if the template
|
|
382
|
-
doesn't ship a cover.
|
|
383
379
|
|
|
384
|
-
Reading from the registry rather than the template.json file directly so
|
|
385
|
-
behaviour matches what ab-render's manifest exposes (the registry is the
|
|
386
|
-
aggregated truth used by both ab-render and ab-skill).
|
|
387
380
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
|
|
391
391
|
if not template_id:
|
|
392
392
|
return None
|
|
393
393
|
try:
|
|
394
|
-
|
|
395
|
-
from registry_loader import get_template
|
|
394
|
+
|
|
395
|
+
from registry_loader import get_template
|
|
396
396
|
|
|
397
397
|
tpl = get_template(template_id, include_all_statuses=True)
|
|
398
398
|
if not tpl:
|
|
@@ -412,7 +412,7 @@ def now_iso():
|
|
|
412
412
|
|
|
413
413
|
|
|
414
414
|
def LogPrint(*args, sep=" ", end="\n", file=None, flush=False):
|
|
415
|
-
|
|
415
|
+
|
|
416
416
|
now = datetime.now()
|
|
417
417
|
stamp = now.strftime("%Y%m%d %H%M%S") + f":{now.microsecond // 1000:03d}"
|
|
418
418
|
if file is None:
|
|
@@ -421,17 +421,17 @@ def LogPrint(*args, sep=" ", end="\n", file=None, flush=False):
|
|
|
421
421
|
builtins.print(f"[{stamp}] {message}", end=end, file=file, flush=flush)
|
|
422
422
|
|
|
423
423
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
|
|
430
430
|
_BILLING: dict = {"credits": 0, "balance": None}
|
|
431
431
|
|
|
432
432
|
|
|
433
433
|
def absorb_child_billing(stdout: str) -> None:
|
|
434
|
-
|
|
434
|
+
|
|
435
435
|
for line in (stdout or "").splitlines():
|
|
436
436
|
line = line.strip()
|
|
437
437
|
if not line.startswith("{") or "__progress__" not in line:
|
|
@@ -452,24 +452,24 @@ def absorb_child_billing(stdout: str) -> None:
|
|
|
452
452
|
|
|
453
453
|
|
|
454
454
|
def print_billing_footer() -> None:
|
|
455
|
-
"""收尾时报一次总账。通过 atexit 注册,所以 sys.exit / 中途失败也会打印
|
|
456
|
-
——已经发生的扣费不该因为后面某一步失败就不告知用户。
|
|
457
455
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
|
|
461
461
|
credits = _BILLING["credits"]
|
|
462
462
|
balance = _BILLING["balance"]
|
|
463
463
|
try:
|
|
464
|
-
import render_job_client
|
|
464
|
+
import render_job_client
|
|
465
465
|
|
|
466
466
|
job_billing = render_job_client.billing_summary()
|
|
467
467
|
if job_billing.get("credits"):
|
|
468
468
|
credits += int(job_billing["credits"])
|
|
469
|
-
|
|
469
|
+
|
|
470
470
|
if job_billing.get("balance") is not None:
|
|
471
471
|
balance = int(job_billing["balance"])
|
|
472
|
-
except Exception:
|
|
472
|
+
except Exception:
|
|
473
473
|
pass
|
|
474
474
|
if credits <= 0:
|
|
475
475
|
return
|
|
@@ -478,12 +478,12 @@ def print_billing_footer() -> None:
|
|
|
478
478
|
|
|
479
479
|
|
|
480
480
|
def sync_chrome_headless_vendor(renderer_dir: str, render_plan: dict) -> None:
|
|
481
|
-
"""Chrome Headless vendor 同步——实现已抽到独立模块 ``_chrome_vendor``。
|
|
482
481
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
|
|
487
487
|
from _chrome_vendor import sync_chrome_headless_vendor as _sync
|
|
488
488
|
_sync(renderer_dir, render_plan)
|
|
489
489
|
|
|
@@ -500,22 +500,22 @@ def save_json(data: dict, path: str):
|
|
|
500
500
|
|
|
501
501
|
|
|
502
502
|
def validate_dsl(dsl: dict) -> list:
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
from video_dsl.runtime.dsl_validator import (
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
from video_dsl.runtime.dsl_validator import (
|
|
519
519
|
validate_integrity,
|
|
520
520
|
errors_as_strings,
|
|
521
521
|
)
|
|
@@ -531,26 +531,26 @@ def validate_dsl(dsl: dict) -> list:
|
|
|
531
531
|
def _hydrate_dsl_payload(
|
|
532
532
|
dsl: dict, template_id: str | None, source_document_url: str | None
|
|
533
533
|
) -> None:
|
|
534
|
-
"""按模板声明把源文件的每一页填进 ``customPayload``(就地改 dsl)。
|
|
535
534
|
|
|
536
|
-
只对声明了 ``capabilities.payloadHydration`` 的模板生效;
|
|
537
|
-
其余模板这里是零成本 no-op —— 声明驱动,不写 ``if template_id == X``(AGENTS.md)。
|
|
538
535
|
|
|
539
|
-
失败一律 exit 2 并把「下一步做什么」打全:这条路上所有失败形态的共同点是
|
|
540
|
-
*数据源没通*,而历史报错说的是"字段没填",两者要采取的行动完全不同。
|
|
541
536
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
|
|
545
545
|
try:
|
|
546
|
-
from payload_hydrator import HydrationError, hydrate_dsl
|
|
546
|
+
from payload_hydrator import HydrationError, hydrate_dsl
|
|
547
547
|
except ImportError:
|
|
548
548
|
return
|
|
549
549
|
|
|
550
550
|
try:
|
|
551
|
-
from registry_loader import get_template
|
|
551
|
+
from registry_loader import get_template
|
|
552
552
|
template = get_template(template_id) if template_id else None
|
|
553
|
-
except Exception:
|
|
553
|
+
except Exception:
|
|
554
554
|
template = None
|
|
555
555
|
if not template:
|
|
556
556
|
return
|
|
@@ -571,18 +571,18 @@ def _hydrate_dsl_payload(
|
|
|
571
571
|
|
|
572
572
|
|
|
573
573
|
def _compose_image_prompts(dsl: dict, template_id: str | None) -> None:
|
|
574
|
-
"""按模板 imageStyleGuide 把画风套到待生成图片的 prompt 上(就地改 dsl)。
|
|
575
574
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
|
|
581
581
|
try:
|
|
582
|
-
from registry_loader import get_template
|
|
583
|
-
from video_dsl.runtime.prompt_enhancer import compose_dsl_image_prompts
|
|
582
|
+
from registry_loader import get_template
|
|
583
|
+
from video_dsl.runtime.prompt_enhancer import compose_dsl_image_prompts
|
|
584
584
|
template = get_template(template_id) if template_id else None
|
|
585
|
-
except Exception:
|
|
585
|
+
except Exception:
|
|
586
586
|
return
|
|
587
587
|
if not template:
|
|
588
588
|
return
|
|
@@ -591,20 +591,20 @@ def _compose_image_prompts(dsl: dict, template_id: str | None) -> None:
|
|
|
591
591
|
|
|
592
592
|
|
|
593
593
|
def _warn_step_sentence_mismatch(entry: dict, sentence_indices: list[int], step_field: Optional[str]) -> None:
|
|
594
|
-
"""动作条数与旁白句数不等时出声。
|
|
595
594
|
|
|
596
|
-
动作数组放在哪个字段由模板的 ``capabilities.stepAlignment.payloadField`` 声明
|
|
597
|
-
(``step_field``);没声明的模板不检查。
|
|
598
595
|
|
|
599
|
-
为什么值得单独打一条:这一类缺陷**渲得出来**。它不报错、不退非零、renderJob
|
|
600
|
-
completed、漏斗记 rendered —— 2026-09-23 线上那条鸡兔同笼成片就是这样交付的,
|
|
601
|
-
10 步板面配 10 句旁白,听起来该一一对应,实际板面在音频中点就走完了。
|
|
602
|
-
既然成片本身不会告状,那就必须有人在这里说一句。
|
|
603
596
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
|
|
608
608
|
if not step_field:
|
|
609
609
|
return
|
|
610
610
|
steps = (entry.get("props") or {}).get(step_field)
|
|
@@ -636,21 +636,21 @@ def resolve_dimensions(dsl: dict) -> tuple:
|
|
|
636
636
|
|
|
637
637
|
|
|
638
638
|
def validate_and_fix_render_plan(render_plan: dict) -> list:
|
|
639
|
-
"""Validate RenderPlan integrity and auto-fix recoverable issues.
|
|
640
639
|
|
|
641
|
-
Checks performed:
|
|
642
|
-
1. subtitleSegments frame numbers within scene [startFrame, endFrame]
|
|
643
|
-
2. Video assets have non-null duration (required for screen-walkthrough)
|
|
644
|
-
3. Timeline frame continuity (prev.endFrame == next.startFrame)
|
|
645
|
-
4. All generated assets have a URL
|
|
646
640
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
|
|
650
650
|
warnings: list[str] = []
|
|
651
651
|
fps = render_plan.get("renderConfig", {}).get("fps", 30)
|
|
652
652
|
|
|
653
|
-
|
|
653
|
+
|
|
654
654
|
for entry in render_plan.get("timeline", []):
|
|
655
655
|
scene_id = entry.get("sceneId", "?")
|
|
656
656
|
scene_start = entry.get("startFrame", 0)
|
|
@@ -671,21 +671,21 @@ def validate_and_fix_render_plan(render_plan: dict) -> list:
|
|
|
671
671
|
seg["endFrame"] = scene_end
|
|
672
672
|
fixed = True
|
|
673
673
|
if seg["startFrame"] >= seg["endFrame"]:
|
|
674
|
-
|
|
674
|
+
|
|
675
675
|
seg["endFrame"] = min(seg["startFrame"] + max(1, int(fps * 0.5)), scene_end)
|
|
676
676
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
|
|
683
683
|
template_id = render_plan.get("templateId", "")
|
|
684
684
|
needs_video_duration = False
|
|
685
685
|
if template_id:
|
|
686
686
|
try:
|
|
687
|
-
from registry_loader import get_template
|
|
688
|
-
|
|
687
|
+
from registry_loader import get_template
|
|
688
|
+
|
|
689
689
|
_tpl = get_template(template_id, include_all_statuses=True)
|
|
690
690
|
needs_video_duration = bool(
|
|
691
691
|
((_tpl or {}).get("capabilities") or {}).get("needsVideoDuration")
|
|
@@ -697,8 +697,8 @@ def validate_and_fix_render_plan(render_plan: dict) -> list:
|
|
|
697
697
|
if asset.get("type") != "video":
|
|
698
698
|
continue
|
|
699
699
|
if asset.get("duration") is None and needs_video_duration:
|
|
700
|
-
|
|
701
|
-
|
|
700
|
+
|
|
701
|
+
|
|
702
702
|
fallback_dur = None
|
|
703
703
|
for entry in render_plan.get("timeline", []):
|
|
704
704
|
props = entry.get("props") or {}
|
|
@@ -716,7 +716,7 @@ def validate_and_fix_render_plan(render_plan: dict) -> list:
|
|
|
716
716
|
f"[fix] asset {asset.get('assetId')}: duration was null, set to scene duration {fallback_dur:.1f}s"
|
|
717
717
|
)
|
|
718
718
|
|
|
719
|
-
|
|
719
|
+
|
|
720
720
|
timeline = render_plan.get("timeline", [])
|
|
721
721
|
for i in range(1, len(timeline)):
|
|
722
722
|
prev_end = timeline[i - 1].get("endFrame", 0)
|
|
@@ -726,7 +726,7 @@ def validate_and_fix_render_plan(render_plan: dict) -> list:
|
|
|
726
726
|
f"[fix] timeline gap: {timeline[i-1].get('sceneId')}.endFrame={prev_end} != "
|
|
727
727
|
f"{timeline[i].get('sceneId')}.startFrame={curr_start}, correcting"
|
|
728
728
|
)
|
|
729
|
-
|
|
729
|
+
|
|
730
730
|
offset = prev_end - curr_start
|
|
731
731
|
for j in range(i, len(timeline)):
|
|
732
732
|
timeline[j]["startFrame"] += offset
|
|
@@ -736,20 +736,20 @@ def validate_and_fix_render_plan(render_plan: dict) -> list:
|
|
|
736
736
|
for seg in timeline[j].get("subtitleSegments", []):
|
|
737
737
|
seg["startFrame"] += offset
|
|
738
738
|
seg["endFrame"] += offset
|
|
739
|
-
|
|
739
|
+
|
|
740
740
|
last = timeline[-1]
|
|
741
741
|
render_plan["renderConfig"]["totalFrames"] = last["endFrame"]
|
|
742
742
|
render_plan["renderConfig"]["totalDuration"] = round(last["endFrame"] / fps, 2)
|
|
743
|
-
break
|
|
743
|
+
break
|
|
744
|
+
|
|
744
745
|
|
|
745
|
-
# ── Check 4: generated assets have URL ────────────────────────────────
|
|
746
746
|
for asset in render_plan.get("resolvedAssets", []):
|
|
747
747
|
if asset.get("status") == "generated" and not asset.get("url"):
|
|
748
748
|
warnings.append(
|
|
749
749
|
f"[warn] asset {asset.get('assetId')}: status=generated but url is empty"
|
|
750
750
|
)
|
|
751
751
|
|
|
752
|
-
|
|
752
|
+
|
|
753
753
|
if warnings:
|
|
754
754
|
for w in warnings:
|
|
755
755
|
render_plan.setdefault("logs", []).append({
|
|
@@ -765,7 +765,7 @@ def validate_and_fix_render_plan(render_plan: dict) -> list:
|
|
|
765
765
|
|
|
766
766
|
|
|
767
767
|
def _build_bgm_props(dsl: dict) -> dict:
|
|
768
|
-
|
|
768
|
+
|
|
769
769
|
bgm = dsl.get("global", {}).get("bgm", {})
|
|
770
770
|
if bgm.get("enabled") and bgm.get("url"):
|
|
771
771
|
return {"bgm": {"url": bgm["url"], "volume": bgm.get("volume", 0.15)}}
|
|
@@ -773,13 +773,13 @@ def _build_bgm_props(dsl: dict) -> dict:
|
|
|
773
773
|
|
|
774
774
|
|
|
775
775
|
def _probe_video_duration(url: str):
|
|
776
|
-
|
|
776
|
+
|
|
777
777
|
from _video_probe import probe_video_duration
|
|
778
778
|
return probe_video_duration(url)
|
|
779
779
|
|
|
780
780
|
|
|
781
781
|
def build_render_plan(dsl: dict, binding: dict) -> dict:
|
|
782
|
-
|
|
782
|
+
|
|
783
783
|
fps = dsl.get("global", {}).get("fps", 30)
|
|
784
784
|
width, height = resolve_dimensions(dsl)
|
|
785
785
|
|
|
@@ -787,15 +787,15 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
|
|
|
787
787
|
for b in binding.get("bindings", []):
|
|
788
788
|
binding_map[b["sceneId"]] = b
|
|
789
789
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
|
|
799
799
|
dsl_assets_by_id = {a["assetId"]: a for a in dsl.get("assets", [])}
|
|
800
800
|
global_narration = (dsl.get("global") or {}).get("narration") or {}
|
|
801
801
|
global_speed = _narration_speed(global_narration)
|
|
@@ -808,25 +808,25 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
|
|
|
808
808
|
if not asset:
|
|
809
809
|
continue
|
|
810
810
|
payload = asset.setdefault("payload", {})
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
|
|
816
816
|
voice = _narration_voice(narration) or global_voice
|
|
817
817
|
if voice:
|
|
818
818
|
payload["voiceId"] = voice
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
|
|
823
823
|
speed = _narration_speed(narration)
|
|
824
824
|
if speed is None:
|
|
825
825
|
speed = global_speed
|
|
826
826
|
if speed is not None:
|
|
827
827
|
payload["speed"] = speed
|
|
828
|
-
|
|
829
|
-
|
|
828
|
+
|
|
829
|
+
|
|
830
830
|
language_boost = _narration_language_boost(narration)
|
|
831
831
|
if language_boost is None:
|
|
832
832
|
language_boost = global_language_boost
|
|
@@ -845,7 +845,7 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
|
|
|
845
845
|
|
|
846
846
|
assets_by_id = {}
|
|
847
847
|
for asset in dsl.get("assets", []):
|
|
848
|
-
|
|
848
|
+
|
|
849
849
|
asset_url = asset.get("url", "") or (asset.get("payload") or {}).get("url", "")
|
|
850
850
|
assets_by_id[asset["assetId"]] = {
|
|
851
851
|
"assetId": asset["assetId"],
|
|
@@ -863,10 +863,10 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
|
|
|
863
863
|
"maxRetries": 3,
|
|
864
864
|
}
|
|
865
865
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
|
|
870
870
|
for aid, asset in assets_by_id.items():
|
|
871
871
|
if asset["type"] == "video" and asset["source"] == "existing" and not asset.get("duration") and asset.get("url"):
|
|
872
872
|
probed = _probe_video_duration(asset["url"])
|
|
@@ -883,9 +883,9 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
|
|
|
883
883
|
|
|
884
884
|
scene_binding = binding_map.get(scene_id, {})
|
|
885
885
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
|
|
889
889
|
|
|
890
890
|
narration = (scene.get("audio") or {}).get("narration") or {}
|
|
891
891
|
|
|
@@ -893,18 +893,18 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
|
|
|
893
893
|
extracted = extract_narration_lines(narration)
|
|
894
894
|
subtitle_at_sec: list[Optional[float]] = []
|
|
895
895
|
if extracted:
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
|
|
900
900
|
lines, _intro_lines, subtitle_at_sec = extracted
|
|
901
901
|
local_subs = split_subtitle_from_lines(lines, duration_frames, fps)
|
|
902
902
|
else:
|
|
903
903
|
local_subs = split_subtitle(narration_text, duration_frames, fps)
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
|
|
908
908
|
subtitle_segments = [
|
|
909
909
|
{
|
|
910
910
|
**s,
|
|
@@ -918,13 +918,13 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
|
|
|
918
918
|
trans_type = transition_config.get("default", "fade")
|
|
919
919
|
trans_dur = int(transition_config.get("duration", 0.5) * fps)
|
|
920
920
|
|
|
921
|
-
|
|
921
|
+
|
|
922
922
|
scene_trans_type = "cut" if idx == 0 else trans_type
|
|
923
923
|
scene_trans_dur = 0 if idx == 0 else trans_dur
|
|
924
924
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
|
|
928
928
|
custom_payload = scene.get("customPayload") or {}
|
|
929
929
|
min_dur_sec = custom_payload.get("minDurationSec")
|
|
930
930
|
min_dur_frames = (
|
|
@@ -932,9 +932,9 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
|
|
|
932
932
|
if isinstance(min_dur_sec, (int, float)) and min_dur_sec > 0
|
|
933
933
|
else 0
|
|
934
934
|
)
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
|
|
938
938
|
tail_pad_sec = custom_payload.get("tailPadSec")
|
|
939
939
|
tail_pad_frames = (
|
|
940
940
|
int(round(float(tail_pad_sec) * fps))
|
|
@@ -967,8 +967,8 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
|
|
|
967
967
|
"createdAt": now_iso(),
|
|
968
968
|
"status": "planning",
|
|
969
969
|
"templateId": binding.get("templateId", ""),
|
|
970
|
-
|
|
971
|
-
|
|
970
|
+
|
|
971
|
+
|
|
972
972
|
"title": dsl.get("meta", {}).get("title", ""),
|
|
973
973
|
"targetDuration": dsl.get("meta", {}).get("targetDuration"),
|
|
974
974
|
"resolvedAssets": list(assets_by_id.values()),
|
|
@@ -1005,7 +1005,7 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
|
|
|
1005
1005
|
|
|
1006
1006
|
|
|
1007
1007
|
def resolve_asset_image(asset: dict, private_token: str, timeout: int) -> dict:
|
|
1008
|
-
|
|
1008
|
+
|
|
1009
1009
|
payload = asset.get("payload", {}) if "payload" not in asset else asset["payload"]
|
|
1010
1010
|
|
|
1011
1011
|
try:
|
|
@@ -1018,7 +1018,7 @@ def resolve_asset_image(asset: dict, private_token: str, timeout: int) -> dict:
|
|
|
1018
1018
|
cmd.extend(["--size", payload["ratio"]])
|
|
1019
1019
|
if payload.get("model"):
|
|
1020
1020
|
cmd.extend(["--model", payload["model"]])
|
|
1021
|
-
|
|
1021
|
+
|
|
1022
1022
|
if payload.get("negativePrompt"):
|
|
1023
1023
|
cmd.extend(["--negative-prompt", str(payload["negativePrompt"])])
|
|
1024
1024
|
if payload.get("guidanceScale") is not None:
|
|
@@ -1044,16 +1044,16 @@ def resolve_asset_image(asset: dict, private_token: str, timeout: int) -> dict:
|
|
|
1044
1044
|
|
|
1045
1045
|
|
|
1046
1046
|
def resolve_asset_audio(asset: dict, private_token: str, timeout: int) -> dict:
|
|
1047
|
-
"""Resolve a single audio asset by calling gen-voice skill.
|
|
1048
1047
|
|
|
1049
|
-
Pre-processes text with segment_narration() and newlines so Minimax
|
|
1050
|
-
returns per-segment timestamps. Uses --json-output to capture metadata.
|
|
1051
1048
|
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
1057
|
payload = asset.get("payload", {})
|
|
1058
1058
|
|
|
1059
1059
|
try:
|
|
@@ -1077,12 +1077,12 @@ def resolve_asset_audio(asset: dict, private_token: str, timeout: int) -> dict:
|
|
|
1077
1077
|
cmd.extend(["--text", tts_text, "--json-output"])
|
|
1078
1078
|
if payload.get("voiceId"):
|
|
1079
1079
|
cmd.extend(["--voice-id", payload["voiceId"]])
|
|
1080
|
-
|
|
1081
|
-
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
1082
|
speed = _narration_speed(payload)
|
|
1083
1083
|
if speed is not None:
|
|
1084
1084
|
cmd.extend(["--speed", str(speed)])
|
|
1085
|
-
|
|
1085
|
+
|
|
1086
1086
|
language_boost = _narration_language_boost(payload)
|
|
1087
1087
|
if language_boost is not None:
|
|
1088
1088
|
cmd.extend(["--language-boost", language_boost])
|
|
@@ -1117,7 +1117,7 @@ def resolve_asset_audio(asset: dict, private_token: str, timeout: int) -> dict:
|
|
|
1117
1117
|
|
|
1118
1118
|
|
|
1119
1119
|
def resolve_asset_video(asset: dict, private_token: str, timeout: int) -> dict:
|
|
1120
|
-
|
|
1120
|
+
|
|
1121
1121
|
payload = asset.get("payload", {})
|
|
1122
1122
|
|
|
1123
1123
|
try:
|
|
@@ -1151,7 +1151,7 @@ def resolve_asset_video(asset: dict, private_token: str, timeout: int) -> dict:
|
|
|
1151
1151
|
|
|
1152
1152
|
|
|
1153
1153
|
def resolve_asset_digital_human(asset: dict, private_token: str, timeout: int) -> dict:
|
|
1154
|
-
|
|
1154
|
+
|
|
1155
1155
|
payload = asset.get("payload", {})
|
|
1156
1156
|
|
|
1157
1157
|
try:
|
|
@@ -1183,8 +1183,8 @@ def resolve_asset_digital_human(asset: dict, private_token: str, timeout: int) -
|
|
|
1183
1183
|
line = line.strip()
|
|
1184
1184
|
if line.startswith("http"):
|
|
1185
1185
|
out = {"status": "generated", "url": line}
|
|
1186
|
-
|
|
1187
|
-
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
1188
|
from _video_probe import probe_video_meta
|
|
1189
1189
|
meta = probe_video_meta(line)
|
|
1190
1190
|
if meta.get("width") and meta.get("height"):
|
|
@@ -1215,11 +1215,11 @@ def _resolve_single_asset(
|
|
|
1215
1215
|
timeout: int,
|
|
1216
1216
|
payload_override: Optional[dict] = None,
|
|
1217
1217
|
) -> bool:
|
|
1218
|
-
"""Resolve one asset. Returns True if generated, False if failed/skipped.
|
|
1219
1218
|
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
1223
|
source = asset.get("source", "")
|
|
1224
1224
|
resolver = ASSET_RESOLVERS.get(source)
|
|
1225
1225
|
if not resolver:
|
|
@@ -1243,12 +1243,12 @@ def _resolve_single_asset(
|
|
|
1243
1243
|
elif dsl_asset and "payload" in dsl_asset:
|
|
1244
1244
|
asset_with_payload["payload"] = dsl_asset["payload"]
|
|
1245
1245
|
|
|
1246
|
-
|
|
1247
|
-
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
1248
|
if source == "gen-digital-human" and payload_override is None:
|
|
1249
1249
|
payload = asset_with_payload.get("payload", {})
|
|
1250
1250
|
if not payload.get("audioUrl"):
|
|
1251
|
-
|
|
1251
|
+
|
|
1252
1252
|
for ra in render_plan["resolvedAssets"]:
|
|
1253
1253
|
if ra.get("type") == "audio" and ra.get("source") == "gen-voice" and ra.get("status") == "generated" and ra.get("url"):
|
|
1254
1254
|
payload["audioUrl"] = ra["url"]
|
|
@@ -1272,15 +1272,15 @@ def _resolve_single_asset(
|
|
|
1272
1272
|
asset["generatedBy"]["completedAt"] = now_iso()
|
|
1273
1273
|
if result.get("audio_length_ms") is not None:
|
|
1274
1274
|
asset["duration"] = result["audio_length_ms"]
|
|
1275
|
-
|
|
1275
|
+
|
|
1276
1276
|
if result.get("duration_ms") is not None:
|
|
1277
1277
|
asset["duration"] = result["duration_ms"]
|
|
1278
1278
|
if result.get("width") and result.get("height"):
|
|
1279
1279
|
asset["width"], asset["height"] = result["width"], result["height"]
|
|
1280
1280
|
if result.get("subtitles"):
|
|
1281
1281
|
asset["ttsSubtitles"] = result["subtitles"]
|
|
1282
|
-
|
|
1283
|
-
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
1284
|
src_payload = asset_with_payload.get("payload", {}) or {}
|
|
1285
1285
|
if isinstance(src_payload.get("narrationItems"), list):
|
|
1286
1286
|
asset["narrationLineCount"] = len(src_payload["narrationItems"])
|
|
@@ -1306,12 +1306,12 @@ def _resolve_single_asset(
|
|
|
1306
1306
|
|
|
1307
1307
|
|
|
1308
1308
|
def apply_stub_urls(render_plan: dict, stub_image_url: str = "", stub_video_url: str = "") -> int:
|
|
1309
|
-
"""Short-circuit pending image/video assets with a stub URL (test mode, no API cost).
|
|
1310
1309
|
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
1315
|
if not stub_image_url and not stub_video_url:
|
|
1316
1316
|
return 0
|
|
1317
1317
|
|
|
@@ -1354,15 +1354,15 @@ def resolve_assets(
|
|
|
1354
1354
|
timeout: int,
|
|
1355
1355
|
defer_digital_human: bool = False,
|
|
1356
1356
|
) -> dict:
|
|
1357
|
-
"""Resolve all pending assets in the render plan.
|
|
1358
1357
|
|
|
1359
|
-
Two-phase resolution:
|
|
1360
|
-
Phase 1: resolve non-avatar assets (TTS, images, video) — **in parallel**
|
|
1361
|
-
Phase 2: resolve avatar/digital-human assets which may depend on generated audio URLs — serial
|
|
1362
1358
|
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
1366
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
1367
1367
|
|
|
1368
1368
|
render_plan["status"] = "resolving-assets"
|
|
@@ -1373,9 +1373,9 @@ def resolve_assets(
|
|
|
1373
1373
|
})
|
|
1374
1374
|
|
|
1375
1375
|
pending = [a for a in render_plan["resolvedAssets"] if a["status"] == "pending"]
|
|
1376
|
-
|
|
1376
|
+
|
|
1377
1377
|
phase1 = [a for a in pending if a.get("source") != "gen-digital-human"]
|
|
1378
|
-
|
|
1378
|
+
|
|
1379
1379
|
phase2 = [a for a in pending if a.get("source") == "gen-digital-human"]
|
|
1380
1380
|
if defer_digital_human:
|
|
1381
1381
|
from _digital_human_segments import collect_segments
|
|
@@ -1385,7 +1385,7 @@ def resolve_assets(
|
|
|
1385
1385
|
generated = 0
|
|
1386
1386
|
failed = 0
|
|
1387
1387
|
|
|
1388
|
-
|
|
1388
|
+
|
|
1389
1389
|
if phase1:
|
|
1390
1390
|
default_parallelism = 8
|
|
1391
1391
|
max_workers = min(
|
|
@@ -1413,7 +1413,7 @@ def resolve_assets(
|
|
|
1413
1413
|
failed += 1
|
|
1414
1414
|
LogPrint(f" ❌ {asset['assetId']} exception: {exc}", file=sys.stderr)
|
|
1415
1415
|
|
|
1416
|
-
|
|
1416
|
+
|
|
1417
1417
|
for asset in phase2:
|
|
1418
1418
|
ok = _resolve_single_asset(asset, render_plan, private_token, max_retries, timeout)
|
|
1419
1419
|
if ok:
|
|
@@ -1438,17 +1438,17 @@ def resolve_digital_human_segments(
|
|
|
1438
1438
|
timeout: int,
|
|
1439
1439
|
work_dir: Optional[str] = None,
|
|
1440
1440
|
) -> dict:
|
|
1441
|
-
"""排轴之后按连续段生成数字人,并给段内场景注入起播帧 / 段角色。
|
|
1442
1441
|
|
|
1443
|
-
必须在 adjust_timeline_to_audio 之后调:驱动音频 = 段内各场景旁白、每段补静音
|
|
1444
|
-
到该场景**最终**时长,视频第 Σ前序场景帧 帧才正好是本场景第 0 帧。
|
|
1445
1442
|
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
1452
|
import _digital_human_segments as dhs
|
|
1453
1453
|
|
|
1454
1454
|
segments = dhs.collect_segments(render_plan)
|
|
@@ -1474,15 +1474,15 @@ def resolve_digital_human_segments(
|
|
|
1474
1474
|
})
|
|
1475
1475
|
LogPrint(f" 🧑💼 {message}", file=sys.stderr)
|
|
1476
1476
|
|
|
1477
|
-
|
|
1478
|
-
jobs: list[tuple[str, dict]] = []
|
|
1477
|
+
|
|
1478
|
+
jobs: list[tuple[str, dict]] = []
|
|
1479
1479
|
for asset_id, indices in list(segments.items()):
|
|
1480
1480
|
asset = assets.get(asset_id)
|
|
1481
1481
|
if not asset or asset.get("status") != "pending":
|
|
1482
1482
|
continue
|
|
1483
1483
|
base_payload = dsl_payloads.get(asset_id, {})
|
|
1484
1484
|
if base_payload.get("audioUrl"):
|
|
1485
|
-
|
|
1485
|
+
|
|
1486
1486
|
jobs.append((asset_id, base_payload))
|
|
1487
1487
|
continue
|
|
1488
1488
|
if len(indices) > 1 and not ffmpeg:
|
|
@@ -1495,7 +1495,7 @@ def resolve_digital_human_segments(
|
|
|
1495
1495
|
timeline = render_plan.get("timeline", [])
|
|
1496
1496
|
for idx, new_id in zip(indices, new_ids):
|
|
1497
1497
|
if not dhs.plan_drive_pieces(render_plan, [idx])[0].get("url"):
|
|
1498
|
-
|
|
1498
|
+
|
|
1499
1499
|
timeline[idx].get("props", {}).pop("avatarAssetId", None)
|
|
1500
1500
|
render_plan["resolvedAssets"] = [
|
|
1501
1501
|
a for a in render_plan["resolvedAssets"] if a.get("assetId") != new_id
|
|
@@ -1524,7 +1524,7 @@ def resolve_digital_human_segments(
|
|
|
1524
1524
|
if not any(p.get("url") for p in pieces):
|
|
1525
1525
|
raise RuntimeError("no generated narration audio in this segment to drive the presenter")
|
|
1526
1526
|
if not ffmpeg:
|
|
1527
|
-
|
|
1527
|
+
|
|
1528
1528
|
url = next(p["url"] for p in pieces if p.get("url"))
|
|
1529
1529
|
log(f"{asset_id}: narration shorter than {dhs.MIN_DRIVE_SEC:.0f}s and ffmpeg not found; submitting unpadded", level="warn")
|
|
1530
1530
|
else:
|
|
@@ -1539,7 +1539,7 @@ def resolve_digital_human_segments(
|
|
|
1539
1539
|
total = sum(p["durationSec"] for p in pieces)
|
|
1540
1540
|
log(f"{asset_id}: drive audio for {len(pieces)} scene(s), {total:.1f}s")
|
|
1541
1541
|
payload["audioUrl"] = url
|
|
1542
|
-
except Exception as exc:
|
|
1542
|
+
except Exception as exc:
|
|
1543
1543
|
asset["status"] = "failed"
|
|
1544
1544
|
failed += 1
|
|
1545
1545
|
render_plan.setdefault("errors", []).append({
|
|
@@ -1550,44 +1550,44 @@ def resolve_digital_human_segments(
|
|
|
1550
1550
|
})
|
|
1551
1551
|
log(f"{asset_id}: drive audio failed: {exc}", level="error")
|
|
1552
1552
|
continue
|
|
1553
|
-
|
|
1553
|
+
|
|
1554
1554
|
payload.pop("text", None)
|
|
1555
1555
|
payload.pop("voiceId", None)
|
|
1556
1556
|
if not _resolve_single_asset(asset, render_plan, private_token, max_retries, timeout, payload_override=payload):
|
|
1557
1557
|
failed += 1
|
|
1558
1558
|
|
|
1559
|
-
for warning in dhs.inject_segment_props(render_plan, dhs.collect_segments(render_plan)):
|
|
1560
|
-
log(warning, level="warn")
|
|
1561
|
-
if failed:
|
|
1562
|
-
render_plan["status"] = "failed"
|
|
1563
|
-
return render_plan
|
|
1559
|
+
for warning in dhs.inject_segment_props(render_plan, dhs.collect_segments(render_plan)):
|
|
1560
|
+
log(warning, level="warn")
|
|
1561
|
+
if failed:
|
|
1562
|
+
render_plan["status"] = "failed"
|
|
1563
|
+
return render_plan
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
def _auto_highlight_map(
|
|
1567
|
+
entry: dict,
|
|
1568
|
+
narration_asset: Optional[dict],
|
|
1569
|
+
render_plan: dict,
|
|
1570
|
+
line_sync: dict[str, str],
|
|
1571
|
+
) -> None:
|
|
1572
|
+
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
|
|
1564
1589
|
|
|
1565
1590
|
|
|
1566
|
-
def _auto_highlight_map(
|
|
1567
|
-
entry: dict,
|
|
1568
|
-
narration_asset: Optional[dict],
|
|
1569
|
-
render_plan: dict,
|
|
1570
|
-
line_sync: dict[str, str],
|
|
1571
|
-
) -> None:
|
|
1572
|
-
"""Auto-fill ``templateData.highlightMap`` for any template that uses
|
|
1573
|
-
structured ``{intro, items, outro}`` narration and ships per-line cards.
|
|
1574
|
-
|
|
1575
|
-
Originally written for html-slide's multi-item slides — that's still the
|
|
1576
|
-
primary consumer — but the trigger is capability-driven, not templateId-
|
|
1577
|
-
keyed: any template whose binding pushes a ``narrationLineCount`` onto
|
|
1578
|
-
the narration asset and whose subtitle segments line up 1-per-line gets
|
|
1579
|
-
this auto-fill. Adding a new template that follows the same pattern needs
|
|
1580
|
-
NO change to this function.
|
|
1581
|
-
|
|
1582
|
-
``line_sync`` is the template's ``lineSyncSlides`` declaration (slideId →
|
|
1583
|
-
card-array key). It caps the map at the card count so trailing outro lines
|
|
1584
|
-
don't point past the last card; slides it doesn't list get no cap.
|
|
1585
|
-
|
|
1586
|
-
Skipped silently when:
|
|
1587
|
-
- narration asset has no narrationLineCount (structured form wasn't used)
|
|
1588
|
-
- author already supplied a highlightMap (explicit wins)
|
|
1589
|
-
- subtitleSegments count ≠ narrationLineCount (emits a warn log)
|
|
1590
|
-
"""
|
|
1591
1591
|
if not narration_asset:
|
|
1592
1592
|
return
|
|
1593
1593
|
line_count = narration_asset.get("narrationLineCount")
|
|
@@ -1599,7 +1599,7 @@ def _auto_highlight_map(
|
|
|
1599
1599
|
if not isinstance(tdata, dict):
|
|
1600
1600
|
return
|
|
1601
1601
|
if tdata.get("highlightMap"):
|
|
1602
|
-
return
|
|
1602
|
+
return
|
|
1603
1603
|
|
|
1604
1604
|
segs = entry.get("subtitleSegments") or []
|
|
1605
1605
|
if len(segs) != line_count:
|
|
@@ -1616,10 +1616,10 @@ def _auto_highlight_map(
|
|
|
1616
1616
|
|
|
1617
1617
|
intro_lines = int(narration_asset.get("narrationIntroLines") or 0)
|
|
1618
1618
|
item_count = line_count - intro_lines
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1619
|
+
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
|
|
1623
1623
|
slide_id = props.get("slideId") or tdata.get("slideId")
|
|
1624
1624
|
items_key = line_sync.get(slide_id) if isinstance(slide_id, str) else None
|
|
1625
1625
|
if items_key and not isinstance(tdata.get(items_key), list):
|
|
@@ -1643,23 +1643,23 @@ def _auto_highlight_map(
|
|
|
1643
1643
|
|
|
1644
1644
|
|
|
1645
1645
|
def _timing_template(template_id: str) -> dict:
|
|
1646
|
-
|
|
1646
|
+
|
|
1647
1647
|
if not template_id:
|
|
1648
1648
|
return {}
|
|
1649
1649
|
try:
|
|
1650
|
-
from registry_loader import get_template
|
|
1650
|
+
from registry_loader import get_template
|
|
1651
1651
|
return get_template(template_id, include_all_statuses=True) or {}
|
|
1652
|
-
except Exception:
|
|
1652
|
+
except Exception:
|
|
1653
1653
|
return {}
|
|
1654
1654
|
|
|
1655
1655
|
|
|
1656
1656
|
def _template_default_tail_pad_frames(tpl: dict, fps: int) -> int | None:
|
|
1657
|
-
"""模板声明的旁白尾巴默认值(``capabilities.defaultTailPadSec``),换算成帧。
|
|
1658
1657
|
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
1663
|
value = (tpl.get("capabilities") or {}).get("defaultTailPadSec")
|
|
1664
1664
|
if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0:
|
|
1665
1665
|
return None
|
|
@@ -1667,8 +1667,8 @@ def _template_default_tail_pad_frames(tpl: dict, fps: int) -> int | None:
|
|
|
1667
1667
|
|
|
1668
1668
|
|
|
1669
1669
|
def adjust_timeline_to_audio(render_plan: dict) -> dict:
|
|
1670
|
-
|
|
1671
|
-
|
|
1670
|
+
|
|
1671
|
+
|
|
1672
1672
|
import math
|
|
1673
1673
|
|
|
1674
1674
|
fps = render_plan["renderConfig"]["fps"]
|
|
@@ -1684,8 +1684,8 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
|
|
|
1684
1684
|
default_tail_pad = int(fps * 1.5)
|
|
1685
1685
|
|
|
1686
1686
|
for entry in render_plan["timeline"]:
|
|
1687
|
-
|
|
1688
|
-
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
1689
|
narration_asset = None
|
|
1690
1690
|
nar_id = (entry.get("props") or {}).get("narrationAssetId")
|
|
1691
1691
|
if nar_id:
|
|
@@ -1696,7 +1696,7 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
|
|
|
1696
1696
|
if narration_asset and narration_asset.get("duration"):
|
|
1697
1697
|
audio_ms = narration_asset["duration"]
|
|
1698
1698
|
if audio_ms <= 0:
|
|
1699
|
-
|
|
1699
|
+
|
|
1700
1700
|
render_plan.setdefault("logs", []).append({
|
|
1701
1701
|
"phase": "audio-adjust",
|
|
1702
1702
|
"level": "warn",
|
|
@@ -1705,18 +1705,18 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
|
|
|
1705
1705
|
})
|
|
1706
1706
|
else:
|
|
1707
1707
|
audio_frames = math.ceil(audio_ms / 1000.0 * fps)
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
1711
|
tail_pad_override = entry.get("tailPadFrames")
|
|
1712
1712
|
tail_pad = (
|
|
1713
1713
|
int(tail_pad_override)
|
|
1714
1714
|
if isinstance(tail_pad_override, int) and tail_pad_override >= 0
|
|
1715
1715
|
else default_tail_pad
|
|
1716
1716
|
)
|
|
1717
|
-
min_dur = int(fps * 2)
|
|
1718
|
-
|
|
1719
|
-
|
|
1717
|
+
min_dur = int(fps * 2)
|
|
1718
|
+
|
|
1719
|
+
|
|
1720
1720
|
scene_min_dur = int(entry.get("minDurationFrames") or 0)
|
|
1721
1721
|
new_dur = max(min_dur, scene_min_dur, audio_frames + tail_pad)
|
|
1722
1722
|
|
|
@@ -1726,11 +1726,11 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
|
|
|
1726
1726
|
|
|
1727
1727
|
tts_subs = narration_asset.get("ttsSubtitles", [])
|
|
1728
1728
|
if tts_subs:
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1729
|
+
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
|
|
1733
|
+
|
|
1734
1734
|
sentence_indices = assign_sentence_indices(
|
|
1735
1735
|
[s.get("text") or "" for s in tts_subs]
|
|
1736
1736
|
)
|
|
@@ -1745,20 +1745,20 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
|
|
|
1745
1745
|
]
|
|
1746
1746
|
_warn_step_sentence_mismatch(entry, sentence_indices, step_field)
|
|
1747
1747
|
else:
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1748
|
+
|
|
1749
|
+
|
|
1750
|
+
|
|
1751
|
+
|
|
1752
|
+
|
|
1753
|
+
|
|
1754
|
+
|
|
1755
|
+
|
|
1756
1756
|
old_subs = entry.get("subtitleSegments", [])
|
|
1757
1757
|
at_sec_list = entry.get("subtitleAtSec") or []
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
|
|
1761
|
+
|
|
1762
1762
|
has_at_sec = any(
|
|
1763
1763
|
isinstance(a, (int, float)) and a is not None
|
|
1764
1764
|
for a in at_sec_list
|
|
@@ -1766,8 +1766,8 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
|
|
|
1766
1766
|
|
|
1767
1767
|
if has_at_sec and old_subs:
|
|
1768
1768
|
scene_dur = entry["durationFrames"]
|
|
1769
|
-
|
|
1770
|
-
|
|
1769
|
+
|
|
1770
|
+
|
|
1771
1771
|
starts: list[int] = []
|
|
1772
1772
|
for i, s in enumerate(old_subs):
|
|
1773
1773
|
at = at_sec_list[i] if i < len(at_sec_list) else None
|
|
@@ -1775,7 +1775,7 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
|
|
|
1775
1775
|
f = max(0, int(round(at * fps)))
|
|
1776
1776
|
else:
|
|
1777
1777
|
f = starts[i - 1] + (fps // 2) if i > 0 else 0
|
|
1778
|
-
|
|
1778
|
+
|
|
1779
1779
|
if i > 0 and f <= starts[i - 1]:
|
|
1780
1780
|
f = starts[i - 1] + (fps // 2)
|
|
1781
1781
|
starts.append(min(f, scene_dur - 1))
|
|
@@ -1783,8 +1783,8 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
|
|
|
1783
1783
|
for i, s in enumerate(old_subs):
|
|
1784
1784
|
text = s.get("text", "")
|
|
1785
1785
|
start_f = starts[i]
|
|
1786
|
-
|
|
1787
|
-
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
1788
|
end_f = scene_dur if i == len(old_subs) - 1 else starts[i + 1]
|
|
1789
1789
|
new_subs.append({
|
|
1790
1790
|
**s,
|
|
@@ -1794,9 +1794,9 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
|
|
|
1794
1794
|
})
|
|
1795
1795
|
entry["subtitleSegments"] = new_subs
|
|
1796
1796
|
elif old_subs:
|
|
1797
|
-
sub_span = audio_frames
|
|
1798
|
-
|
|
1799
|
-
|
|
1797
|
+
sub_span = audio_frames
|
|
1798
|
+
|
|
1799
|
+
|
|
1800
1800
|
def _plain(t: str) -> int:
|
|
1801
1801
|
return len(re.sub(r"\*+", "", t or ""))
|
|
1802
1802
|
total_chars = max(sum(_plain(s.get("text", "")) for s in old_subs), 1)
|
|
@@ -1817,11 +1817,11 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
|
|
|
1817
1817
|
|
|
1818
1818
|
_auto_highlight_map(entry, narration_asset, render_plan, line_sync)
|
|
1819
1819
|
|
|
1820
|
-
|
|
1821
|
-
|
|
1820
|
+
|
|
1821
|
+
|
|
1822
1822
|
old_scene_start = entry.get("startFrame", 0)
|
|
1823
1823
|
if not (narration_asset and narration_asset.get("duration") and narration_asset["duration"] > 0):
|
|
1824
|
-
|
|
1824
|
+
|
|
1825
1825
|
if old_scene_start != current_frame:
|
|
1826
1826
|
old_subs = entry.get("subtitleSegments", [])
|
|
1827
1827
|
if old_subs:
|
|
@@ -1841,14 +1841,14 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
|
|
|
1841
1841
|
entry["startTime"] = round(current_frame / fps, 2)
|
|
1842
1842
|
entry["endTime"] = round(entry["endFrame"] / fps, 2)
|
|
1843
1843
|
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1844
|
+
|
|
1845
|
+
|
|
1846
|
+
|
|
1847
|
+
|
|
1848
|
+
|
|
1849
1849
|
entry.pop("layers", None)
|
|
1850
1850
|
|
|
1851
|
-
|
|
1851
|
+
|
|
1852
1852
|
slides = entry.get("props", {}).get("slides")
|
|
1853
1853
|
if slides and isinstance(slides, list) and len(slides) > 0:
|
|
1854
1854
|
scene_dur = entry["durationFrames"]
|
|
@@ -1878,7 +1878,7 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
|
|
|
1878
1878
|
|
|
1879
1879
|
|
|
1880
1880
|
def render_with_local_cli(render_plan: dict, output_path: str) -> bool:
|
|
1881
|
-
|
|
1881
|
+
|
|
1882
1882
|
render_plan["status"] = "rendering"
|
|
1883
1883
|
render_plan["logs"].append({
|
|
1884
1884
|
"phase": "render",
|
|
@@ -1886,12 +1886,12 @@ def render_with_local_cli(render_plan: dict, output_path: str) -> bool:
|
|
|
1886
1886
|
"timestamp": now_iso(),
|
|
1887
1887
|
})
|
|
1888
1888
|
|
|
1889
|
-
|
|
1889
|
+
|
|
1890
1890
|
output_path = os.path.abspath(output_path)
|
|
1891
1891
|
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
|
1892
1892
|
|
|
1893
|
-
|
|
1894
|
-
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
1895
|
https_assets = []
|
|
1896
1896
|
for asset in render_plan.get("resolvedAssets", []):
|
|
1897
1897
|
asset = asset.copy()
|
|
@@ -1958,7 +1958,7 @@ def render_with_local_cli(render_plan: dict, output_path: str) -> bool:
|
|
|
1958
1958
|
composition_id,
|
|
1959
1959
|
output_path,
|
|
1960
1960
|
"--props", props_path,
|
|
1961
|
-
|
|
1961
|
+
|
|
1962
1962
|
"--width", str(config.get("width", 1920)),
|
|
1963
1963
|
"--height", str(config.get("height", 1080)),
|
|
1964
1964
|
"--fps", str(config.get("fps", 30)),
|
|
@@ -1977,7 +1977,7 @@ def render_with_local_cli(render_plan: dict, output_path: str) -> bool:
|
|
|
1977
1977
|
sys.stderr.flush()
|
|
1978
1978
|
|
|
1979
1979
|
try:
|
|
1980
|
-
|
|
1980
|
+
|
|
1981
1981
|
proc = subprocess.Popen(
|
|
1982
1982
|
cmd,
|
|
1983
1983
|
stdout=subprocess.PIPE,
|
|
@@ -1998,19 +1998,19 @@ def render_with_local_cli(render_plan: dict, output_path: str) -> bool:
|
|
|
1998
1998
|
if len(stderr_tail) > 50:
|
|
1999
1999
|
stderr_tail.pop(0)
|
|
2000
2000
|
|
|
2001
|
-
|
|
2001
|
+
|
|
2002
2002
|
stripped = line.strip()
|
|
2003
2003
|
if stripped.startswith("Rendered "):
|
|
2004
2004
|
try:
|
|
2005
2005
|
parts = stripped.split()
|
|
2006
|
-
frac = parts[1].rstrip(",")
|
|
2006
|
+
frac = parts[1].rstrip(",")
|
|
2007
2007
|
current = int(frac.split("/")[0])
|
|
2008
|
-
|
|
2008
|
+
|
|
2009
2009
|
if total_frames and (current - last_rendered >= max(total_frames // 10, 1)):
|
|
2010
2010
|
pct = int(current / total_frames * 100)
|
|
2011
2011
|
LogPrint(f" 🎞️ Render progress: {current}/{total_frames} ({pct}%)", file=sys.stderr)
|
|
2012
2012
|
sys.stderr.flush()
|
|
2013
|
-
|
|
2013
|
+
|
|
2014
2014
|
print(
|
|
2015
2015
|
json.dumps({
|
|
2016
2016
|
"__progress__": True,
|
|
@@ -2067,25 +2067,25 @@ def render_with_local_cli(render_plan: dict, output_path: str) -> bool:
|
|
|
2067
2067
|
|
|
2068
2068
|
|
|
2069
2069
|
def _persist_plan_checkpoint(render_plan: dict, job_id: Optional[int], private_token: str) -> None:
|
|
2070
|
-
"""把当前 RenderPlan 存个档(尽力而为,失败只记日志)。
|
|
2071
2070
|
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2071
|
+
|
|
2072
|
+
|
|
2073
|
+
|
|
2074
|
+
|
|
2075
|
+
|
|
2076
2076
|
if not job_id:
|
|
2077
2077
|
return
|
|
2078
2078
|
try:
|
|
2079
|
-
import render_job_client
|
|
2079
|
+
import render_job_client
|
|
2080
2080
|
render_job_client.save_plan(job_id, json.dumps(render_plan, ensure_ascii=False), private_token)
|
|
2081
2081
|
LogPrint(f" 💾 RenderPlan checkpoint saved (jobId={job_id})", file=sys.stderr)
|
|
2082
|
-
except Exception as exc:
|
|
2082
|
+
except Exception as exc:
|
|
2083
2083
|
LogPrint(f" ⚠️ RenderPlan checkpoint failed (jobId={job_id}): {exc}", file=sys.stderr)
|
|
2084
2084
|
|
|
2085
2085
|
|
|
2086
2086
|
|
|
2087
2087
|
def _builtin_dynamic_render_enabled() -> bool:
|
|
2088
|
-
|
|
2088
|
+
|
|
2089
2089
|
return os.environ.get("REMIXMATE_BUILTIN_DYNAMIC_RENDER", "").strip().lower() in {"1", "true", "yes", "on"}
|
|
2090
2090
|
|
|
2091
2091
|
def render_with_remote_api(
|
|
@@ -2099,13 +2099,13 @@ def render_with_remote_api(
|
|
|
2099
2099
|
conversation_id: Optional[str] = None,
|
|
2100
2100
|
job_id: Optional[int] = None,
|
|
2101
2101
|
) -> bool:
|
|
2102
|
-
"""Submit render to ab-api /tool/renderVideo and poll until completion.
|
|
2103
2102
|
|
|
2104
|
-
On success, writes upload.fileUrl into render_plan and skips local MP4 download.
|
|
2105
2103
|
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2104
|
+
|
|
2105
|
+
|
|
2106
|
+
|
|
2107
|
+
|
|
2108
|
+
|
|
2109
2109
|
import remote_renderer_client
|
|
2110
2110
|
|
|
2111
2111
|
render_plan["status"] = "rendering"
|
|
@@ -2133,11 +2133,11 @@ def render_with_remote_api(
|
|
|
2133
2133
|
}
|
|
2134
2134
|
effective_title = upload_title or os.path.splitext(os.path.basename(output_path))[0]
|
|
2135
2135
|
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2136
|
+
|
|
2137
|
+
|
|
2138
|
+
|
|
2139
|
+
|
|
2140
|
+
|
|
2141
2141
|
cover_composition_id = (
|
|
2142
2142
|
render_plan.get("remotionProps", {}).get("coverCompositionId")
|
|
2143
2143
|
)
|
|
@@ -2145,60 +2145,60 @@ def render_with_remote_api(
|
|
|
2145
2145
|
template_id = render_plan.get("templateId", "")
|
|
2146
2146
|
cover_composition_id = resolve_cover_composition_id(template_id)
|
|
2147
2147
|
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2148
|
+
|
|
2149
|
+
|
|
2150
|
+
|
|
2151
|
+
|
|
2152
|
+
|
|
2153
|
+
|
|
2154
2154
|
pending_resume = bool(str(render_plan.get("remoteTaskId") or "").strip())
|
|
2155
2155
|
render_path = "/render"
|
|
2156
2156
|
status_path = "/renderStatus"
|
|
2157
2157
|
|
|
2158
2158
|
source_oss_key = None
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2159
|
+
|
|
2160
|
+
|
|
2161
|
+
|
|
2162
|
+
|
|
2163
|
+
|
|
2164
2164
|
builtin_dynamic = _builtin_dynamic_render_enabled()
|
|
2165
2165
|
is_builtin_dynamic = False
|
|
2166
2166
|
template_id = render_plan.get("templateId", "")
|
|
2167
2167
|
if template_id:
|
|
2168
2168
|
try:
|
|
2169
|
-
from registry_loader import get_template
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2169
|
+
from registry_loader import get_template
|
|
2170
|
+
|
|
2171
|
+
|
|
2172
|
+
|
|
2173
2173
|
_meta = get_template(template_id, include_all_statuses=True)
|
|
2174
2174
|
if _meta and not _meta.get("isBuiltin"):
|
|
2175
2175
|
source_oss_key = _meta.get("hasSource")
|
|
2176
2176
|
elif _meta and builtin_dynamic:
|
|
2177
|
-
|
|
2178
|
-
|
|
2177
|
+
|
|
2178
|
+
|
|
2179
2179
|
source_oss_key = True
|
|
2180
2180
|
is_builtin_dynamic = True
|
|
2181
2181
|
except Exception:
|
|
2182
2182
|
source_oss_key = None
|
|
2183
2183
|
|
|
2184
2184
|
if source_oss_key:
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2185
|
+
|
|
2186
|
+
|
|
2187
|
+
|
|
2188
2188
|
payload = {
|
|
2189
2189
|
"templateKey": template_id,
|
|
2190
2190
|
"inputProps": input_props,
|
|
2191
2191
|
"upload": True,
|
|
2192
2192
|
"uploadTitle": effective_title,
|
|
2193
2193
|
}
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2194
|
+
|
|
2195
|
+
|
|
2196
|
+
|
|
2197
2197
|
if cover_composition_id:
|
|
2198
2198
|
payload["coverCompositionId"] = cover_composition_id
|
|
2199
2199
|
if is_builtin_dynamic:
|
|
2200
|
-
|
|
2201
|
-
|
|
2200
|
+
|
|
2201
|
+
|
|
2202
2202
|
payload["spreadEntryProps"] = False
|
|
2203
2203
|
render_path = "/renderDraft"
|
|
2204
2204
|
status_path = "/renderDraftStatus"
|
|
@@ -2216,7 +2216,7 @@ def render_with_remote_api(
|
|
|
2216
2216
|
|
|
2217
2217
|
total_frames = config.get("totalFrames", 0)
|
|
2218
2218
|
if pending_resume:
|
|
2219
|
-
|
|
2219
|
+
|
|
2220
2220
|
LogPrint(f"🎬 Remote render task already exists, will resume polling", file=sys.stderr)
|
|
2221
2221
|
elif is_builtin_dynamic:
|
|
2222
2222
|
LogPrint(f" Builtin template (dynamic bundle): {template_id}", file=sys.stderr)
|
|
@@ -2230,17 +2230,17 @@ def render_with_remote_api(
|
|
|
2230
2230
|
LogPrint(f" total frames: {total_frames}", file=sys.stderr)
|
|
2231
2231
|
sys.stderr.flush()
|
|
2232
2232
|
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2233
|
+
|
|
2234
|
+
|
|
2235
|
+
|
|
2236
|
+
|
|
2237
|
+
|
|
2238
|
+
|
|
2239
|
+
|
|
2240
2240
|
task_id = str(render_plan.get("remoteTaskId") or "").strip()
|
|
2241
2241
|
if task_id:
|
|
2242
|
-
|
|
2243
|
-
|
|
2242
|
+
|
|
2243
|
+
|
|
2244
2244
|
status_path = render_plan.get("remoteStatusPath") or status_path
|
|
2245
2245
|
LogPrint(f"🔄 resuming existing remote task: taskId={task_id}", file=sys.stderr)
|
|
2246
2246
|
render_plan["logs"].append({
|
|
@@ -2270,19 +2270,19 @@ def render_with_remote_api(
|
|
|
2270
2270
|
"message": f"Remote task submitted: {task_id}",
|
|
2271
2271
|
"timestamp": now_iso(),
|
|
2272
2272
|
})
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2273
|
+
|
|
2274
|
+
|
|
2275
|
+
|
|
2276
2276
|
_persist_plan_checkpoint(render_plan, job_id, private_token)
|
|
2277
2277
|
|
|
2278
2278
|
last_progress = -1.0
|
|
2279
|
-
|
|
2279
|
+
|
|
2280
2280
|
render_start_time = float(render_plan.get("remoteStartedAt") or time.time())
|
|
2281
2281
|
|
|
2282
2282
|
def _on_progress(status_data: dict):
|
|
2283
2283
|
nonlocal last_progress
|
|
2284
2284
|
progress = float(status_data.get("progress") or 0.0)
|
|
2285
|
-
|
|
2285
|
+
|
|
2286
2286
|
if progress - last_progress >= 0.02 or progress >= 1.0:
|
|
2287
2287
|
pct = int(progress * 100)
|
|
2288
2288
|
elapsed = time.time() - render_start_time
|
|
@@ -2296,7 +2296,7 @@ def render_with_remote_api(
|
|
|
2296
2296
|
else:
|
|
2297
2297
|
LogPrint(f" 🎞️ Remote render progress: {pct}%", file=sys.stderr)
|
|
2298
2298
|
sys.stderr.flush()
|
|
2299
|
-
|
|
2299
|
+
|
|
2300
2300
|
print(
|
|
2301
2301
|
json.dumps({
|
|
2302
2302
|
"__progress__": True,
|
|
@@ -2321,8 +2321,8 @@ def render_with_remote_api(
|
|
|
2321
2321
|
status_path=status_path,
|
|
2322
2322
|
)
|
|
2323
2323
|
except remote_renderer_client.RemoteRenderTimeout as exc:
|
|
2324
|
-
|
|
2325
|
-
|
|
2324
|
+
|
|
2325
|
+
|
|
2326
2326
|
LogPrint(f"⏳ {exc}", file=sys.stderr)
|
|
2327
2327
|
render_plan["status"] = "rendering"
|
|
2328
2328
|
_persist_plan_checkpoint(render_plan, job_id, private_token)
|
|
@@ -2340,8 +2340,8 @@ def render_with_remote_api(
|
|
|
2340
2340
|
except Exception as exc:
|
|
2341
2341
|
LogPrint(f"❌ remote render polling failed: {exc}", file=sys.stderr)
|
|
2342
2342
|
render_plan["status"] = "failed"
|
|
2343
|
-
|
|
2344
|
-
|
|
2343
|
+
|
|
2344
|
+
|
|
2345
2345
|
render_plan.pop("remoteTaskId", None)
|
|
2346
2346
|
render_plan.pop("remoteStatusPath", None)
|
|
2347
2347
|
render_plan.pop("remoteStartedAt", None)
|
|
@@ -2364,13 +2364,13 @@ def render_with_remote_api(
|
|
|
2364
2364
|
})
|
|
2365
2365
|
return False
|
|
2366
2366
|
|
|
2367
|
-
|
|
2367
|
+
|
|
2368
2368
|
video_id = (result or {}).get("videoId", "")
|
|
2369
2369
|
file_id = (result or {}).get("fileId")
|
|
2370
2370
|
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2371
|
+
|
|
2372
|
+
|
|
2373
|
+
|
|
2374
2374
|
cover_url = (result or {}).get("coverUrl")
|
|
2375
2375
|
cover_error = (result or {}).get("coverError")
|
|
2376
2376
|
|
|
@@ -2386,7 +2386,7 @@ def render_with_remote_api(
|
|
|
2386
2386
|
if cover_url:
|
|
2387
2387
|
render_plan["upload"]["coverUrl"] = cover_url
|
|
2388
2388
|
if cover_error and not cover_url:
|
|
2389
|
-
|
|
2389
|
+
|
|
2390
2390
|
render_plan["logs"].append({
|
|
2391
2391
|
"phase": "render",
|
|
2392
2392
|
"message": f"Cover render failed (non-fatal): {cover_error}",
|
|
@@ -2407,7 +2407,7 @@ def render_with_remote_api(
|
|
|
2407
2407
|
file=sys.stderr,
|
|
2408
2408
|
)
|
|
2409
2409
|
|
|
2410
|
-
|
|
2410
|
+
|
|
2411
2411
|
if file_url.startswith("vod://") and file_id and private_token:
|
|
2412
2412
|
playback_url = _poll_vod_playback_url(file_id, private_token)
|
|
2413
2413
|
if playback_url:
|
|
@@ -2426,16 +2426,16 @@ def _poll_vod_playback_url(
|
|
|
2426
2426
|
max_wait: float = 60.0,
|
|
2427
2427
|
interval: float = 3.0,
|
|
2428
2428
|
):
|
|
2429
|
-
|
|
2429
|
+
|
|
2430
2430
|
from _vod_polling import poll_vod_playback_url
|
|
2431
2431
|
return poll_vod_playback_url(file_id, private_token, max_wait=max_wait, interval=interval)
|
|
2432
2432
|
|
|
2433
2433
|
|
|
2434
2434
|
def auto_bind_template(dsl: dict, template_id: str) -> dict:
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2435
|
+
|
|
2436
|
+
|
|
2437
|
+
|
|
2438
|
+
|
|
2439
2439
|
try:
|
|
2440
2440
|
import match_template
|
|
2441
2441
|
except ImportError as e:
|
|
@@ -2458,14 +2458,14 @@ def auto_bind_template(dsl: dict, template_id: str) -> dict:
|
|
|
2458
2458
|
LogPrint(f"❌ template not found: {template_id} (available: {available})", file=sys.stderr)
|
|
2459
2459
|
sys.exit(1)
|
|
2460
2460
|
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2461
|
+
|
|
2462
|
+
|
|
2463
|
+
|
|
2464
|
+
|
|
2465
|
+
|
|
2466
|
+
|
|
2467
2467
|
try:
|
|
2468
|
-
from registry_loader import gated_status
|
|
2468
|
+
from registry_loader import gated_status
|
|
2469
2469
|
_gated = gated_status(template_id)
|
|
2470
2470
|
except Exception:
|
|
2471
2471
|
_gated = None
|
|
@@ -2491,15 +2491,15 @@ def auto_bind_template(dsl: dict, template_id: str) -> dict:
|
|
|
2491
2491
|
|
|
2492
2492
|
|
|
2493
2493
|
def _log_render_plan_summary(render_plan: dict) -> None:
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2494
|
+
|
|
2495
|
+
|
|
2496
|
+
|
|
2497
|
+
|
|
2498
|
+
|
|
2499
|
+
|
|
2500
|
+
|
|
2501
|
+
|
|
2502
|
+
|
|
2503
2503
|
try:
|
|
2504
2504
|
template_id = render_plan.get("templateId", "?")
|
|
2505
2505
|
title = render_plan.get("title", "")
|
|
@@ -2515,12 +2515,12 @@ def _log_render_plan_summary(render_plan: dict) -> None:
|
|
|
2515
2515
|
file=sys.stderr,
|
|
2516
2516
|
)
|
|
2517
2517
|
except Exception:
|
|
2518
|
-
|
|
2518
|
+
|
|
2519
2519
|
pass
|
|
2520
2520
|
|
|
2521
2521
|
|
|
2522
2522
|
def main():
|
|
2523
|
-
|
|
2523
|
+
|
|
2524
2524
|
atexit.register(print_billing_footer)
|
|
2525
2525
|
parser = argparse.ArgumentParser(
|
|
2526
2526
|
description="Video render tool — DSL + TemplateBinding → Remotion video.",
|
|
@@ -2564,8 +2564,8 @@ Examples:
|
|
|
2564
2564
|
parser.add_argument(
|
|
2565
2565
|
"--renderer",
|
|
2566
2566
|
choices=["local", "remote"],
|
|
2567
|
-
|
|
2568
|
-
|
|
2567
|
+
|
|
2568
|
+
|
|
2569
2569
|
default=(os.environ.get("REMOTION_RENDER_MODE") or "remote").strip().lower(),
|
|
2570
2570
|
help="Render mode: remote=call the remote renderer service (default); local=local Remotion CLI (needs Node.js 18+)",
|
|
2571
2571
|
)
|
|
@@ -2573,9 +2573,9 @@ Examples:
|
|
|
2573
2573
|
"--remote-poll-timeout",
|
|
2574
2574
|
type=float,
|
|
2575
2575
|
default=float(os.environ.get("REMOTION_REMOTE_POLL_TIMEOUT", "2700")),
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2576
|
+
|
|
2577
|
+
|
|
2578
|
+
|
|
2579
2579
|
help="Remote-render polling timeout (seconds, default 2700)",
|
|
2580
2580
|
)
|
|
2581
2581
|
parser.add_argument(
|
|
@@ -2598,25 +2598,25 @@ Examples:
|
|
|
2598
2598
|
args = parser.parse_args()
|
|
2599
2599
|
|
|
2600
2600
|
private_token = args.priv_token or os.environ.get("PRIV_TOKEN", "")
|
|
2601
|
-
|
|
2602
|
-
|
|
2601
|
+
|
|
2602
|
+
|
|
2603
2603
|
if args.priv_token:
|
|
2604
2604
|
os.environ["PRIV_TOKEN"] = private_token
|
|
2605
2605
|
|
|
2606
|
-
|
|
2606
|
+
|
|
2607
2607
|
if args.save_job and not private_token:
|
|
2608
2608
|
LogPrint("ℹ️ PRIV_TOKEN not configured; --save-job auto-disabled (will not write to database)", file=sys.stderr)
|
|
2609
2609
|
args.save_job = False
|
|
2610
2610
|
|
|
2611
2611
|
if args.job_id is not None and args.job_id > 0:
|
|
2612
|
-
|
|
2612
|
+
|
|
2613
2613
|
LogPrint(f"📋 loading RenderPlan from database (jobId={args.job_id})...", file=sys.stderr)
|
|
2614
2614
|
if not private_token:
|
|
2615
2615
|
LogPrint("❌ --job-id mode requires PRIV_TOKEN or --priv-token", file=sys.stderr)
|
|
2616
2616
|
sys.exit(1)
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2617
|
+
|
|
2618
|
+
|
|
2619
|
+
|
|
2620
2620
|
from render_job_client import get_plan as rjc_get_plan
|
|
2621
2621
|
try:
|
|
2622
2622
|
render_plan_str = rjc_get_plan(args.job_id, private_token)
|
|
@@ -2633,10 +2633,10 @@ Examples:
|
|
|
2633
2633
|
render_plan = load_json(args.render_plan)
|
|
2634
2634
|
LogPrint(f"📋 loaded existing RenderPlan: {args.render_plan}", file=sys.stderr)
|
|
2635
2635
|
_log_render_plan_summary(render_plan)
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2636
|
+
|
|
2637
|
+
|
|
2638
|
+
|
|
2639
|
+
|
|
2640
2640
|
if args.dsl and os.path.exists(args.dsl):
|
|
2641
2641
|
try:
|
|
2642
2642
|
if os.path.getmtime(args.render_plan) < os.path.getmtime(args.dsl):
|
|
@@ -2669,25 +2669,25 @@ Examples:
|
|
|
2669
2669
|
)
|
|
2670
2670
|
sys.exit(1)
|
|
2671
2671
|
|
|
2672
|
-
|
|
2672
|
+
|
|
2673
2673
|
if args.dsl_json:
|
|
2674
2674
|
try:
|
|
2675
|
-
|
|
2675
|
+
|
|
2676
2676
|
_dsl_str = args.dsl_json
|
|
2677
|
-
|
|
2677
|
+
|
|
2678
2678
|
_dsl_str = _dsl_str.replace('\u201c', '"').replace('\u201d', '"')
|
|
2679
2679
|
_dsl_str = _dsl_str.replace('\u2018', "'").replace('\u2019', "'")
|
|
2680
|
-
|
|
2681
|
-
_dsl_str = _dsl_str.replace('\uff1a', ':')
|
|
2682
|
-
_dsl_str = _dsl_str.replace('\uff0c', ',')
|
|
2683
|
-
_dsl_str = _dsl_str.replace('\uff08', '(')
|
|
2684
|
-
_dsl_str = _dsl_str.replace('\uff09', ')')
|
|
2685
|
-
_dsl_str = _dsl_str.replace('\u3010', '[')
|
|
2686
|
-
_dsl_str = _dsl_str.replace('\u3011', ']')
|
|
2687
|
-
_dsl_str = _dsl_str.replace('\uff3b', '[')
|
|
2688
|
-
_dsl_str = _dsl_str.replace('\uff3d', ']')
|
|
2689
|
-
_dsl_str = _dsl_str.replace('\uff5b', '{')
|
|
2690
|
-
_dsl_str = _dsl_str.replace('\uff5d', '}')
|
|
2680
|
+
|
|
2681
|
+
_dsl_str = _dsl_str.replace('\uff1a', ':')
|
|
2682
|
+
_dsl_str = _dsl_str.replace('\uff0c', ',')
|
|
2683
|
+
_dsl_str = _dsl_str.replace('\uff08', '(')
|
|
2684
|
+
_dsl_str = _dsl_str.replace('\uff09', ')')
|
|
2685
|
+
_dsl_str = _dsl_str.replace('\u3010', '[')
|
|
2686
|
+
_dsl_str = _dsl_str.replace('\u3011', ']')
|
|
2687
|
+
_dsl_str = _dsl_str.replace('\uff3b', '[')
|
|
2688
|
+
_dsl_str = _dsl_str.replace('\uff3d', ']')
|
|
2689
|
+
_dsl_str = _dsl_str.replace('\uff5b', '{')
|
|
2690
|
+
_dsl_str = _dsl_str.replace('\uff5d', '}')
|
|
2691
2691
|
dsl = json.loads(_dsl_str)
|
|
2692
2692
|
except json.JSONDecodeError as e:
|
|
2693
2693
|
LogPrint(f"❌ --dsl-json parse failed: {e}", file=sys.stderr)
|
|
@@ -2698,14 +2698,14 @@ Examples:
|
|
|
2698
2698
|
sys.exit(1)
|
|
2699
2699
|
dsl = load_json(args.dsl)
|
|
2700
2700
|
|
|
2701
|
-
|
|
2701
|
+
|
|
2702
2702
|
if "version" not in dsl:
|
|
2703
2703
|
dsl["version"] = "v1alpha1"
|
|
2704
2704
|
LogPrint("⚠️ DSL missing version field; auto-filled with v1alpha1", file=sys.stderr)
|
|
2705
2705
|
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2706
|
+
|
|
2707
|
+
|
|
2708
|
+
|
|
2709
2709
|
_skeleton_offenders: list[str] = []
|
|
2710
2710
|
_skel_markers = ("[骨架待填充]", "[skeleton placeholder]", "这是需要替换的占位文案")
|
|
2711
2711
|
for _scene in dsl.get("scenes", []) or []:
|
|
@@ -2730,20 +2730,20 @@ Examples:
|
|
|
2730
2730
|
)
|
|
2731
2731
|
sys.exit(2)
|
|
2732
2732
|
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2733
|
+
|
|
2734
|
+
|
|
2735
|
+
|
|
2736
|
+
|
|
2737
|
+
|
|
2738
|
+
|
|
2739
|
+
|
|
2740
|
+
|
|
2741
|
+
|
|
2742
|
+
|
|
2743
2743
|
_hydrate_dsl_payload(dsl, args.template_id, args.source_document_url)
|
|
2744
2744
|
|
|
2745
|
-
|
|
2746
|
-
|
|
2745
|
+
|
|
2746
|
+
|
|
2747
2747
|
_compose_image_prompts(dsl, args.template_id)
|
|
2748
2748
|
|
|
2749
2749
|
errors = validate_dsl(dsl)
|
|
@@ -2753,19 +2753,19 @@ Examples:
|
|
|
2753
2753
|
LogPrint(f" - {err}", file=sys.stderr)
|
|
2754
2754
|
sys.exit(1)
|
|
2755
2755
|
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2756
|
+
|
|
2757
|
+
|
|
2758
|
+
|
|
2759
|
+
|
|
2760
|
+
|
|
2761
2761
|
binding = auto_bind_template(dsl, args.template_id)
|
|
2762
2762
|
|
|
2763
2763
|
LogPrint(f"📋 Building RenderPlan...", file=sys.stderr)
|
|
2764
2764
|
render_plan = build_render_plan(dsl, binding)
|
|
2765
2765
|
render_plan["_dsl_assets"] = dsl.get("assets", [])
|
|
2766
2766
|
|
|
2767
|
-
|
|
2768
|
-
|
|
2767
|
+
|
|
2768
|
+
|
|
2769
2769
|
if args.job_id or args.render_plan:
|
|
2770
2770
|
from registry_loader import get_template
|
|
2771
2771
|
from required_props import validate_required_props
|
|
@@ -2787,9 +2787,9 @@ Examples:
|
|
|
2787
2787
|
sys.exit(2)
|
|
2788
2788
|
|
|
2789
2789
|
if not args.skip_asset_resolve:
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2790
|
+
|
|
2791
|
+
|
|
2792
|
+
|
|
2793
2793
|
stub_image_url = args.stub_image_url
|
|
2794
2794
|
stub_video_url = args.stub_video_url
|
|
2795
2795
|
if stub_image_url is None:
|
|
@@ -2815,16 +2815,16 @@ Examples:
|
|
|
2815
2815
|
LogPrint(f"🧪 Stub mode is on but no matching assets were found (no pending gen-image / gen-video resources)", file=sys.stderr)
|
|
2816
2816
|
pending_count = sum(1 for a in render_plan["resolvedAssets"] if a["status"] == "pending")
|
|
2817
2817
|
if pending_count > 0:
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2818
|
+
|
|
2819
|
+
|
|
2820
|
+
|
|
2821
2821
|
if not private_token:
|
|
2822
2822
|
LogPrint("❌ asset generation needs an auth token, but PRIV_TOKEN env var is not set and --priv-token was not passed", file=sys.stderr)
|
|
2823
2823
|
LogPrint(" configure it one of these ways:", file=sys.stderr)
|
|
2824
2824
|
LogPrint(" 1. export PRIV_TOKEN=<your-token>", file=sys.stderr)
|
|
2825
2825
|
LogPrint(" 2. python3 render_video.py --priv-token <your-token> ...", file=sys.stderr)
|
|
2826
2826
|
sys.exit(1)
|
|
2827
|
-
|
|
2827
|
+
|
|
2828
2828
|
LogPrint(f"🔍 Resolving {pending_count} missing asset(s)...", file=sys.stderr)
|
|
2829
2829
|
render_plan = resolve_assets(
|
|
2830
2830
|
render_plan,
|
|
@@ -2839,8 +2839,8 @@ Examples:
|
|
|
2839
2839
|
|
|
2840
2840
|
if not args.skip_asset_resolve:
|
|
2841
2841
|
render_plan = adjust_timeline_to_audio(render_plan)
|
|
2842
|
-
|
|
2843
|
-
|
|
2842
|
+
|
|
2843
|
+
|
|
2844
2844
|
render_plan = resolve_digital_human_segments(
|
|
2845
2845
|
render_plan,
|
|
2846
2846
|
private_token=private_token,
|
|
@@ -2853,32 +2853,32 @@ Examples:
|
|
|
2853
2853
|
output_path = os.path.join(OUTPUT_DIR, "video.mp4")
|
|
2854
2854
|
rp_output = args.render_plan_output or os.path.join(OUTPUT_DIR, "render-plan.json")
|
|
2855
2855
|
|
|
2856
|
-
|
|
2856
|
+
|
|
2857
2857
|
if args.save_render_plan or not args.save_job:
|
|
2858
2858
|
validate_and_fix_render_plan(render_plan)
|
|
2859
2859
|
save_json(render_plan, rp_output)
|
|
2860
2860
|
LogPrint(f"💾 RenderPlan saved: {rp_output}", file=sys.stderr)
|
|
2861
2861
|
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2862
|
+
|
|
2863
|
+
|
|
2864
|
+
|
|
2865
2865
|
if args.save_job:
|
|
2866
|
-
|
|
2866
|
+
|
|
2867
2867
|
validate_and_fix_render_plan(render_plan)
|
|
2868
2868
|
|
|
2869
2869
|
from render_job_client import create_job as rjc_create_job, save_plan as rjc_save_plan
|
|
2870
2870
|
render_plan_json_str = json.dumps(render_plan, ensure_ascii=False)
|
|
2871
|
-
|
|
2871
|
+
|
|
2872
2872
|
_job_id = args.job_id
|
|
2873
2873
|
if not _job_id:
|
|
2874
|
-
|
|
2875
|
-
|
|
2874
|
+
|
|
2875
|
+
|
|
2876
2876
|
_locals = locals()
|
|
2877
2877
|
_dsl_obj = _locals.get("dsl")
|
|
2878
2878
|
_binding_obj = _locals.get("binding")
|
|
2879
2879
|
_meta_summary: dict = {}
|
|
2880
|
-
|
|
2881
|
-
|
|
2880
|
+
|
|
2881
|
+
|
|
2882
2882
|
if _dsl_obj:
|
|
2883
2883
|
_src_meta = (_dsl_obj.get("meta") or {})
|
|
2884
2884
|
for _k in ("title", "topic", "platform", "templateId", "targetDuration"):
|
|
@@ -2916,7 +2916,7 @@ Examples:
|
|
|
2916
2916
|
try:
|
|
2917
2917
|
rjc_save_plan(_job_id, render_plan_json_str, private_token)
|
|
2918
2918
|
LogPrint(f"✅ RenderPlan saved to database: jobId={_job_id}", file=sys.stderr)
|
|
2919
|
-
|
|
2919
|
+
|
|
2920
2920
|
args.job_id = _job_id
|
|
2921
2921
|
print(f"\n📦 render job jobId: {_job_id}")
|
|
2922
2922
|
except RuntimeError as e:
|
|
@@ -2933,7 +2933,7 @@ Examples:
|
|
|
2933
2933
|
target_dur = render_plan.get("targetDuration")
|
|
2934
2934
|
actual_dur = render_plan.get("renderConfig", {}).get("totalDuration", 0)
|
|
2935
2935
|
|
|
2936
|
-
|
|
2936
|
+
|
|
2937
2937
|
print(f"\n📊 Asset resolution finished: total {total}, generated {generated}, failed {failed}")
|
|
2938
2938
|
if target_dur:
|
|
2939
2939
|
diff = actual_dur - target_dur
|
|
@@ -2965,9 +2965,9 @@ Examples:
|
|
|
2965
2965
|
url = asset.get("url", "")
|
|
2966
2966
|
print(f" {asset['assetId']:<25} {url}")
|
|
2967
2967
|
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2968
|
+
|
|
2969
|
+
|
|
2970
|
+
|
|
2971
2971
|
avatar_assets = [a for a in render_plan.get("resolvedAssets", []) if a.get("type") == "avatar"]
|
|
2972
2972
|
avatar_scenes: dict = {}
|
|
2973
2973
|
for entry in render_plan.get("timeline", []):
|
|
@@ -2981,7 +2981,7 @@ Examples:
|
|
|
2981
2981
|
print(f" {asset['assetId']:<25} {asset.get('status', '?'):<10} scenes: {scenes_on} {asset.get('url', '')}")
|
|
2982
2982
|
else:
|
|
2983
2983
|
print(f"\n🧑 Digital-human segments: none (this render has no presenter)")
|
|
2984
|
-
|
|
2984
|
+
|
|
2985
2985
|
|
|
2986
2986
|
if failed > 0:
|
|
2987
2987
|
sys.exit(1)
|
|
@@ -2999,9 +2999,9 @@ Examples:
|
|
|
2999
2999
|
or render_plan.get("title")
|
|
3000
3000
|
or os.path.splitext(os.path.basename(output_path))[0]
|
|
3001
3001
|
)
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3002
|
+
|
|
3003
|
+
|
|
3004
|
+
|
|
3005
3005
|
if upload_title_hint and len(upload_title_hint) > 128:
|
|
3006
3006
|
upload_title_hint = upload_title_hint[:128]
|
|
3007
3007
|
conversation_id = os.environ.get("CONVERSATION_ID") or None
|
|
@@ -3025,7 +3025,7 @@ Examples:
|
|
|
3025
3025
|
render_plan["renderMode"] = "local"
|
|
3026
3026
|
success = render_with_local_cli(render_plan, output_path)
|
|
3027
3027
|
|
|
3028
|
-
|
|
3028
|
+
|
|
3029
3029
|
if not args.save_job:
|
|
3030
3030
|
save_json(render_plan, rp_output)
|
|
3031
3031
|
|
|
@@ -3041,7 +3041,7 @@ Examples:
|
|
|
3041
3041
|
"sceneCount": len(render_plan["timeline"]),
|
|
3042
3042
|
"errorCount": len(render_plan["errors"]),
|
|
3043
3043
|
}
|
|
3044
|
-
|
|
3044
|
+
|
|
3045
3045
|
manifest_path = None if args.save_job else os.path.join(OUTPUT_DIR, "render-manifest.json")
|
|
3046
3046
|
if manifest_path:
|
|
3047
3047
|
save_json(manifest, manifest_path)
|
|
@@ -3055,26 +3055,26 @@ Examples:
|
|
|
3055
3055
|
LogPrint(f" Remote video: {remote_url}", file=sys.stderr)
|
|
3056
3056
|
if playback_url:
|
|
3057
3057
|
LogPrint(f" Playback URL: {playback_url}", file=sys.stderr)
|
|
3058
|
-
|
|
3058
|
+
|
|
3059
3059
|
effective_playback = playback_url or remote_url
|
|
3060
3060
|
if effective_playback and not effective_playback.startswith("vod://"):
|
|
3061
3061
|
print(f"\n🎬 Video playback URL: {effective_playback}")
|
|
3062
|
-
|
|
3063
|
-
|
|
3062
|
+
|
|
3063
|
+
|
|
3064
3064
|
if upload_info.get("coverUrl"):
|
|
3065
3065
|
print(f"🖼️ Cover image URL: {upload_info['coverUrl']}")
|
|
3066
3066
|
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3067
|
+
|
|
3068
|
+
|
|
3069
|
+
|
|
3070
|
+
|
|
3071
|
+
|
|
3072
|
+
|
|
3073
|
+
|
|
3074
|
+
|
|
3075
|
+
|
|
3076
|
+
|
|
3077
|
+
|
|
3078
3078
|
asset = {"url": effective_playback}
|
|
3079
3079
|
cover = upload_info.get("coverUrl")
|
|
3080
3080
|
if cover:
|
|
@@ -3093,7 +3093,7 @@ Examples:
|
|
|
3093
3093
|
LogPrint(f" Manifest: {manifest_path}", file=sys.stderr)
|
|
3094
3094
|
|
|
3095
3095
|
if render_mode == "remote":
|
|
3096
|
-
|
|
3096
|
+
|
|
3097
3097
|
manifest["upload"] = render_plan.get("upload", {})
|
|
3098
3098
|
if manifest_path:
|
|
3099
3099
|
save_json(manifest, manifest_path)
|
|
@@ -3114,10 +3114,10 @@ Examples:
|
|
|
3114
3114
|
"fileUrl": upload_result["fileUrl"],
|
|
3115
3115
|
"title": upload_title,
|
|
3116
3116
|
"uploadedAt": now_iso(),
|
|
3117
|
-
|
|
3117
|
+
|
|
3118
3118
|
"fileId": upload_result.get("fileId"),
|
|
3119
3119
|
}
|
|
3120
|
-
|
|
3120
|
+
|
|
3121
3121
|
if upload_result.get("fileUrl"):
|
|
3122
3122
|
print(f"\n🎬 Video playback URL: {upload_result['fileUrl']}")
|
|
3123
3123
|
if manifest_path:
|
|
@@ -3126,7 +3126,7 @@ Examples:
|
|
|
3126
3126
|
except Exception as e:
|
|
3127
3127
|
LogPrint(f"\n⚠️ upload failed (the video stays local): {e}", file=sys.stderr)
|
|
3128
3128
|
|
|
3129
|
-
|
|
3129
|
+
|
|
3130
3130
|
if args.save_job and args.job_id:
|
|
3131
3131
|
from render_job_client import save_manifest as rjc_save_manifest
|
|
3132
3132
|
try:
|