@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.
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/dist/argv.d.ts +15 -0
- package/dist/argv.js +51 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +83 -0
- package/dist/handlers/gen-image.d.ts +13 -0
- package/dist/handlers/gen-image.js +161 -0
- package/dist/handlers/gen-voice.d.ts +16 -0
- package/dist/handlers/gen-voice.js +110 -0
- package/dist/handlers/index.d.ts +13 -0
- package/dist/handlers/index.js +14 -0
- package/dist/handlers/template-bind.d.ts +15 -0
- package/dist/handlers/template-bind.js +149 -0
- package/dist/http.d.ts +52 -0
- package/dist/http.js +110 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/manifest.json +260 -0
- package/dist/progress.d.ts +23 -0
- package/dist/progress.js +15 -0
- package/dist/registry.d.ts +33 -0
- package/dist/registry.js +72 -0
- package/dist/runner.d.ts +17 -0
- package/dist/runner.js +63 -0
- package/package.json +54 -0
- package/skills/export-jianying/SKILL.md +251 -0
- package/skills/export-jianying/scripts/gen_jianying_draft.py +1099 -0
- package/skills/export-jianying/skill.json +24 -0
- package/skills/export-jianying/version.json +7 -0
- package/skills/gen-digital-human/SKILL.md +211 -0
- package/skills/gen-digital-human/scripts/gen_digital_human_video.py +574 -0
- package/skills/gen-digital-human/skill.json +23 -0
- package/skills/gen-digital-human/version.json +7 -0
- package/skills/gen-image/SKILL.md +130 -0
- package/skills/gen-image/skill.json +22 -0
- package/skills/gen-image/version.json +7 -0
- package/skills/gen-script/SKILL.md +283 -0
- package/skills/gen-script/scripts/gen_script.py +1208 -0
- package/skills/gen-script/skill.json +41 -0
- package/skills/gen-script/version.json +7 -0
- package/skills/gen-video/SKILL.md +170 -0
- package/skills/gen-video/scripts/gen_video.py +591 -0
- package/skills/gen-video/skill.json +24 -0
- package/skills/gen-video/version.json +7 -0
- package/skills/gen-voice/SKILL.md +104 -0
- package/skills/gen-voice/skill.json +21 -0
- package/skills/gen-voice/version.json +7 -0
- package/skills/prepare-video-assets/SKILL.md +192 -0
- package/skills/prepare-video-assets/scripts/prepare_video_assets.py +57 -0
- package/skills/prepare-video-assets/skill.json +23 -0
- package/skills/prepare-video-assets/version.json +7 -0
- package/skills/render-video/SKILL.md +187 -0
- package/skills/render-video/scripts/_chrome_vendor.py +305 -0
- package/skills/render-video/scripts/_video_probe.py +174 -0
- package/skills/render-video/scripts/_vod_polling.py +86 -0
- package/skills/render-video/scripts/remote_renderer_client.py +236 -0
- package/skills/render-video/scripts/render_video.py +2288 -0
- package/skills/render-video/scripts/upload_video.py +193 -0
- package/skills/render-video/skill.json +21 -0
- package/skills/render-video/version.json +7 -0
- package/skills/template-bind/README.md +63 -0
- package/skills/template-bind/SKILL.md +234 -0
- package/skills/template-bind/scripts/check_contracts.py +196 -0
- package/skills/template-bind/scripts/match_template.py +215 -0
- package/skills/template-bind/scripts/registry_loader.py +379 -0
- package/skills/template-bind/scripts/render_job_client.py +162 -0
- package/skills/template-bind/scripts/sync_registry.py +44 -0
- package/skills/template-bind/scripts/template_paths.py +72 -0
- package/skills/template-bind/skill.json +20 -0
- package/skills/template-bind/version.json +7 -0
- package/skills/template-bind/video_dsl/README.md +97 -0
- package/skills/template-bind/video_dsl/runtime/__init__.py +11 -0
- package/skills/template-bind/video_dsl/runtime/dsl_validator.py +495 -0
- package/skills/template-bind/video_dsl/runtime/prompt_enhancer.py +274 -0
- package/skills/template-bind/video_dsl/runtime/template_binder.py +87 -0
- package/skills/template-bind/video_dsl/runtime/timeline_compiler.py +312 -0
- package/skills/template-bind/video_dsl/schema/render-plan-v1alpha1.json +193 -0
- package/skills/template-bind/video_dsl/schema/template-binding-v1alpha1.json +122 -0
- package/skills/template-bind/video_dsl/schema/template-definition-v1alpha1.json +247 -0
- package/skills/template-bind/video_dsl/schema/video-dsl-v1alpha1.json +378 -0
- package/skills/video-parser/SKILL.md +238 -0
- package/skills/video-parser/scripts/analyze_video.py +1038 -0
- package/skills/video-parser/scripts/deconstruct_video.py +602 -0
- package/skills/video-parser/scripts/parse_via_render.py +148 -0
- package/skills/video-parser/skill.json +21 -0
- package/skills/video-parser/version.json +7 -0
- package/skills/web-capture/SKILL.md +183 -0
- package/skills/web-capture/scripts/web_capture.py +461 -0
- package/skills/web-capture/skill.json +29 -0
- 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()
|