@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,1208 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Video script generator — turns a topic into Video DSL v1alpha1 JSON.
|
|
4
|
+
|
|
5
|
+
Default behavior:
|
|
6
|
+
Produces a structured Video DSL JSON to stdout based on the topic, platform,
|
|
7
|
+
duration, and other constraints.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python gen_script.py --topic "3 AI hacks that double study efficiency" --duration 30 --ratio "9:16"
|
|
11
|
+
python gen_script.py --validate --input my-video.dsl.json
|
|
12
|
+
|
|
13
|
+
Environment variables:
|
|
14
|
+
VIDEO_DSL_SCHEMA_PATH - DSL schema file path
|
|
15
|
+
(default: skills/template-bind/video_dsl/schema/video-dsl-v1alpha1.json)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import math
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import uuid
|
|
24
|
+
|
|
25
|
+
SCHEMA_PATH = os.environ.get(
|
|
26
|
+
"VIDEO_DSL_SCHEMA_PATH",
|
|
27
|
+
"skills/template-bind/video_dsl/schema/video-dsl-v1alpha1.json",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# prompt_enhancer 路径动态添加
|
|
31
|
+
_TEMPLATE_DSL_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "template-bind", "video_dsl")
|
|
32
|
+
if os.path.isdir(_TEMPLATE_DSL_DIR):
|
|
33
|
+
sys.path.insert(0, os.path.abspath(os.path.join(_TEMPLATE_DSL_DIR, "..")))
|
|
34
|
+
|
|
35
|
+
# Shared Python lib lives under skills/template-bind/scripts/ — the de-facto
|
|
36
|
+
# cross-skill module location (registry_loader, template_paths, etc.). Named
|
|
37
|
+
# generically here so its host skill could be renamed without touching every
|
|
38
|
+
# caller. See AGENTS.md for the convention.
|
|
39
|
+
_SHARED_SCRIPTS_DIR = os.path.join(
|
|
40
|
+
os.path.dirname(__file__), "..", "..", "template-bind", "scripts"
|
|
41
|
+
)
|
|
42
|
+
if os.path.isdir(_SHARED_SCRIPTS_DIR):
|
|
43
|
+
sys.path.insert(0, os.path.abspath(_SHARED_SCRIPTS_DIR))
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
from video_dsl.runtime.prompt_enhancer import build_enhanced_prompt
|
|
47
|
+
_HAS_ENHANCER = True
|
|
48
|
+
except ImportError:
|
|
49
|
+
_HAS_ENHANCER = False
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
from registry_loader import (
|
|
53
|
+
load_registry_data as _load_registry_data,
|
|
54
|
+
get_template as _get_template,
|
|
55
|
+
)
|
|
56
|
+
_HAS_REGISTRY_LOADER = True
|
|
57
|
+
except ImportError:
|
|
58
|
+
_HAS_REGISTRY_LOADER = False
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
# Centralized monorepo-path resolver living in template-bind/scripts/;
|
|
62
|
+
# the import works because the same sys.path.insert above pointed at it.
|
|
63
|
+
from template_paths import monorepo_template_src_dir as _monorepo_template_src_dir
|
|
64
|
+
except ImportError:
|
|
65
|
+
_monorepo_template_src_dir = None
|
|
66
|
+
|
|
67
|
+
VALID_PLATFORMS = ["douyin", "xiaohongshu", "bilibili", "wechat", "youtube", "generic"]
|
|
68
|
+
VALID_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]
|
|
69
|
+
VALID_PURPOSES = ["opening", "point", "example", "explanation", "highlight", "cta", "ending"]
|
|
70
|
+
|
|
71
|
+
CHARS_PER_SECOND = 5.0 # 与 timeline_compiler.py 保持一致(中文约 5 字/秒)
|
|
72
|
+
|
|
73
|
+
# ── Language contract (mirrors apps/ab-skill/src/handlers/template-meta.ts) ──
|
|
74
|
+
# template.json may declare:
|
|
75
|
+
# - outputLanguage: "en" | "zh" default "zh" when missing/invalid
|
|
76
|
+
# - defaultVoiceId: string default None when missing/empty
|
|
77
|
+
# Read tolerantly: any failure returns the legacy zh defaults plus a stderr warning.
|
|
78
|
+
|
|
79
|
+
DEFAULT_LANGUAGE = "zh"
|
|
80
|
+
LANGUAGE_TO_META = {"en": "en-US", "zh": "zh-CN"}
|
|
81
|
+
|
|
82
|
+
# Fallback voices keyed by output language. Mirrors voice-resolver.ts FALLBACK_VOICE.
|
|
83
|
+
# zh keeps the legacy default that's been used everywhere; en is a placeholder
|
|
84
|
+
# until the gen-voice service confirms the right English voice id.
|
|
85
|
+
FALLBACK_VOICE = {
|
|
86
|
+
"zh": "Chinese (Mandarin)_Male_Announcer",
|
|
87
|
+
# TODO: confirm an English voice id against gen-voice service before shipping.
|
|
88
|
+
"en": "english-male-friendly-01",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _resolve_template_language_meta(template_config):
|
|
93
|
+
"""Tolerant reader for outputLanguage / defaultVoiceId on a template dict.
|
|
94
|
+
|
|
95
|
+
Returns (language, default_voice_id). Never raises. Logs a single stderr
|
|
96
|
+
warning when fields are present but malformed.
|
|
97
|
+
"""
|
|
98
|
+
if not isinstance(template_config, dict):
|
|
99
|
+
return DEFAULT_LANGUAGE, None
|
|
100
|
+
raw_lang = template_config.get("outputLanguage")
|
|
101
|
+
if raw_lang in ("en", "zh"):
|
|
102
|
+
language = raw_lang
|
|
103
|
+
else:
|
|
104
|
+
if raw_lang is not None:
|
|
105
|
+
print(
|
|
106
|
+
f"⚠️ unrecognized outputLanguage={raw_lang!r} on template; defaulting to {DEFAULT_LANGUAGE}",
|
|
107
|
+
file=sys.stderr,
|
|
108
|
+
)
|
|
109
|
+
language = DEFAULT_LANGUAGE
|
|
110
|
+
raw_voice = template_config.get("defaultVoiceId")
|
|
111
|
+
if isinstance(raw_voice, str) and raw_voice.strip():
|
|
112
|
+
default_voice_id = raw_voice.strip()
|
|
113
|
+
else:
|
|
114
|
+
default_voice_id = None
|
|
115
|
+
return language, default_voice_id
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _resolve_voice_id(cli_voice_id, template_default_voice_id, language):
|
|
119
|
+
"""Mirror of voice-resolver.ts resolveVoiceId. Total function.
|
|
120
|
+
|
|
121
|
+
Order: explicit CLI override → template defaultVoiceId → language fallback.
|
|
122
|
+
"""
|
|
123
|
+
if isinstance(cli_voice_id, str) and cli_voice_id.strip():
|
|
124
|
+
return cli_voice_id.strip()
|
|
125
|
+
if isinstance(template_default_voice_id, str) and template_default_voice_id.strip():
|
|
126
|
+
return template_default_voice_id.strip()
|
|
127
|
+
return FALLBACK_VOICE.get(language, FALLBACK_VOICE[DEFAULT_LANGUAGE])
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _check_narration_language(narrations, expected):
|
|
131
|
+
"""Non-blocking sanity check: warn when narration looks like the wrong language.
|
|
132
|
+
|
|
133
|
+
Skips strings that start with NARRATION_SKELETON_MARKER or its English variant —
|
|
134
|
+
those are gen_script placeholders the agent will replace with real narration
|
|
135
|
+
before sending to prepare_video_assets, so a language mismatch on them is expected.
|
|
136
|
+
"""
|
|
137
|
+
cjk_re = __import__("re").compile(r"[\u4e00-\u9fff]")
|
|
138
|
+
latin_re = __import__("re").compile(r"[A-Za-z]")
|
|
139
|
+
skeleton_prefixes = (NARRATION_SKELETON_MARKER, "[skeleton placeholder]")
|
|
140
|
+
for text in narrations:
|
|
141
|
+
if not isinstance(text, str) or not text.strip():
|
|
142
|
+
continue
|
|
143
|
+
if text.lstrip().startswith(skeleton_prefixes):
|
|
144
|
+
continue
|
|
145
|
+
has_cjk = bool(cjk_re.search(text))
|
|
146
|
+
has_latin = bool(latin_re.search(text))
|
|
147
|
+
if expected == "en" and has_cjk:
|
|
148
|
+
print(
|
|
149
|
+
"⚠️ narration contains CJK characters but template outputLanguage=en",
|
|
150
|
+
file=sys.stderr,
|
|
151
|
+
)
|
|
152
|
+
break
|
|
153
|
+
if expected == "zh" and has_latin and not has_cjk:
|
|
154
|
+
print(
|
|
155
|
+
"⚠️ narration looks non-Chinese but template outputLanguage=zh",
|
|
156
|
+
file=sys.stderr,
|
|
157
|
+
)
|
|
158
|
+
break
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _localize_label(language, key, **fmt):
|
|
162
|
+
"""Tiny localization helper for the on-screen / narration default strings.
|
|
163
|
+
|
|
164
|
+
Keeps the template-driven outputLanguage as the single source of truth: zh
|
|
165
|
+
templates keep their legacy Chinese phrasing, en templates emit English. We
|
|
166
|
+
deliberately keep the table small — anything more elaborate belongs in the
|
|
167
|
+
LLM prompt, not in this fallback skeleton.
|
|
168
|
+
"""
|
|
169
|
+
table = {
|
|
170
|
+
"label_opening": {"zh": "开场 Hook", "en": "Opening hook"},
|
|
171
|
+
"label_point": {"zh": "内容要点 {n}", "en": "Point {n}"},
|
|
172
|
+
"label_cta": {"zh": "结尾 CTA", "en": "Closing CTA"},
|
|
173
|
+
"narration_opening": {
|
|
174
|
+
"zh": "[骨架待填充] 关于{topic}的开场 Hook({duration}s)",
|
|
175
|
+
"en": "[skeleton placeholder] Opening hook about {topic} ({duration}s)",
|
|
176
|
+
},
|
|
177
|
+
"narration_point": {
|
|
178
|
+
"zh": "[骨架待填充] {label}:关于{topic}的一个要点({duration}s)",
|
|
179
|
+
"en": "[skeleton placeholder] {label}: a point about {topic} ({duration}s)",
|
|
180
|
+
},
|
|
181
|
+
"narration_cta": {
|
|
182
|
+
"zh": "[骨架待填充] 关于{topic}的 CTA 结尾({duration}s)",
|
|
183
|
+
"en": "[skeleton placeholder] Closing CTA about {topic} ({duration}s)",
|
|
184
|
+
},
|
|
185
|
+
"narration_default": {
|
|
186
|
+
"zh": "[骨架待填充] 关于{topic}的内容({duration}s)",
|
|
187
|
+
"en": "[skeleton placeholder] Content about {topic} ({duration}s)",
|
|
188
|
+
},
|
|
189
|
+
"point_description": {
|
|
190
|
+
"zh": "关于{topic}的要点内容",
|
|
191
|
+
"en": "A key point about {topic}",
|
|
192
|
+
},
|
|
193
|
+
"follow_us": {"zh": "关注我们", "en": "Follow us"},
|
|
194
|
+
"more_about": {"zh": "更多关于{topic}的内容", "en": "More about {topic}"},
|
|
195
|
+
"video_description": {
|
|
196
|
+
"zh": "关于「{topic}」的{duration}秒短视频",
|
|
197
|
+
"en": "A {duration}s short video about \"{topic}\"",
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
entry = table.get(key, {})
|
|
201
|
+
template = entry.get(language) or entry.get(DEFAULT_LANGUAGE) or ""
|
|
202
|
+
return template.format(**fmt)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def load_schema():
|
|
206
|
+
if not os.path.exists(SCHEMA_PATH):
|
|
207
|
+
return None
|
|
208
|
+
with open(SCHEMA_PATH, "r", encoding="utf-8") as f:
|
|
209
|
+
return json.load(f)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def validate_dsl(dsl: dict) -> list:
|
|
213
|
+
"""Structural DSL validation — delegates to the unified validator.
|
|
214
|
+
|
|
215
|
+
Historical rule set (preserved verbatim by ``validate_structural``):
|
|
216
|
+
version + meta(title) + global presence + scene count 1–20 + each
|
|
217
|
+
scene's ``id`` and ``purpose``. Asset integrity, enum membership and
|
|
218
|
+
picture-book-en checks are intentionally NOT enforced here — those
|
|
219
|
+
live in ``validate_dsl`` / ``validate_integrity`` and only run at
|
|
220
|
+
their respective historical call sites.
|
|
221
|
+
"""
|
|
222
|
+
from video_dsl.runtime.dsl_validator import ( # noqa: E402
|
|
223
|
+
validate_structural,
|
|
224
|
+
errors_as_strings,
|
|
225
|
+
)
|
|
226
|
+
return errors_as_strings(validate_structural(dsl))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def plan_scenes(topic: str, duration: int, scene_count: int | None, allow_digital_human: bool, language: str = DEFAULT_LANGUAGE) -> list:
|
|
230
|
+
"""Plan scene structure based on topic and constraints."""
|
|
231
|
+
if scene_count is None:
|
|
232
|
+
if duration <= 15:
|
|
233
|
+
scene_count = 3
|
|
234
|
+
elif duration <= 30:
|
|
235
|
+
scene_count = 5
|
|
236
|
+
elif duration <= 60:
|
|
237
|
+
scene_count = 7
|
|
238
|
+
else:
|
|
239
|
+
scene_count = min(10, duration // 8)
|
|
240
|
+
|
|
241
|
+
scene_count = max(3, min(scene_count, 20))
|
|
242
|
+
|
|
243
|
+
scenes = []
|
|
244
|
+
content_scenes = scene_count - 2
|
|
245
|
+
|
|
246
|
+
opening_duration = max(3, round(duration * 0.12))
|
|
247
|
+
ending_duration = max(3, round(duration * 0.12))
|
|
248
|
+
content_total = duration - opening_duration - ending_duration
|
|
249
|
+
per_content = max(3, round(content_total / content_scenes))
|
|
250
|
+
|
|
251
|
+
scenes.append({
|
|
252
|
+
"purpose": "opening",
|
|
253
|
+
"duration": opening_duration,
|
|
254
|
+
"label": _localize_label(language, "label_opening"),
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
for i in range(content_scenes):
|
|
258
|
+
scenes.append({
|
|
259
|
+
"purpose": "point",
|
|
260
|
+
"duration": per_content,
|
|
261
|
+
"label": _localize_label(language, "label_point", n=i + 1),
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
scenes.append({
|
|
265
|
+
"purpose": "cta",
|
|
266
|
+
"duration": ending_duration,
|
|
267
|
+
"label": _localize_label(language, "label_cta"),
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
return scenes
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def estimate_narration_text(label: str, purpose: str, duration: int, topic: str, language: str = DEFAULT_LANGUAGE) -> str:
|
|
274
|
+
"""Generate a short skeleton narration hint for a scene.
|
|
275
|
+
|
|
276
|
+
This returns a *skeleton only* — the agent is expected to replace this
|
|
277
|
+
text with real narration before submitting DSL to prepare_video_assets. We
|
|
278
|
+
deliberately return a short, clearly-placeholder-looking string rather
|
|
279
|
+
than padding to duration × CHARS_PER_SECOND, because padding with a
|
|
280
|
+
repeating filler sentence historically caused the skeleton to be
|
|
281
|
+
submitted verbatim to TTS (audio narration mismatch bug).
|
|
282
|
+
"""
|
|
283
|
+
fmt = {"label": label, "topic": topic, "duration": duration}
|
|
284
|
+
if purpose == "opening":
|
|
285
|
+
return _localize_label(language, "narration_opening", **fmt)
|
|
286
|
+
if purpose == "point":
|
|
287
|
+
return _localize_label(language, "narration_point", **fmt)
|
|
288
|
+
if purpose == "cta":
|
|
289
|
+
return _localize_label(language, "narration_cta", **fmt)
|
|
290
|
+
return _localize_label(language, "narration_default", **fmt)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# 骨架标记:出现此前缀说明 narration 仍是 gen_script 原始骨架,未被 agent 填充真实内容
|
|
294
|
+
NARRATION_SKELETON_MARKER = "[骨架待填充]"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def generate_image_prompt(
|
|
298
|
+
purpose: str, topic: str, style: str, narration_text: str = "", style_guide: dict | None = None
|
|
299
|
+
) -> dict:
|
|
300
|
+
"""Generate image prompt for a scene.
|
|
301
|
+
|
|
302
|
+
Returns dict with keys: prompt, and optionally negativePrompt, guidanceScale.
|
|
303
|
+
"""
|
|
304
|
+
if _HAS_ENHANCER:
|
|
305
|
+
return build_enhanced_prompt(
|
|
306
|
+
purpose=purpose,
|
|
307
|
+
topic=topic,
|
|
308
|
+
style=style,
|
|
309
|
+
narration_text=narration_text,
|
|
310
|
+
style_guide=style_guide,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# 降级:无 enhancer 时使用原逻辑
|
|
314
|
+
style_suffix = f",{style}风格" if style else ""
|
|
315
|
+
prompts = {
|
|
316
|
+
"opening": f"{topic}主题概念图,吸引眼球的视觉效果{style_suffix},高清大图",
|
|
317
|
+
"point": f"与{topic}相关的场景插画{style_suffix},信息图表风格",
|
|
318
|
+
"cta": f"关注点赞互动图标{style_suffix},简洁现代设计",
|
|
319
|
+
}
|
|
320
|
+
return {"prompt": prompts.get(purpose, f"{topic}相关配图{style_suffix}")}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _load_template_config(template_id: str) -> dict | None:
|
|
324
|
+
"""Load template definition for the given template ID.
|
|
325
|
+
|
|
326
|
+
解析顺序(与 match_template / template_binder 保持一致):
|
|
327
|
+
1. 通过 registry_loader 从 monorepo template-library/packages/metadata/registry.json
|
|
328
|
+
(或 VIDEO_TEMPLATE_REGISTRY[_URL] 环境变量)加载,并按 templateId 匹配。
|
|
329
|
+
2. 兜底:旧路径 apps/ab-skill/skills/template-bind/video_dsl/templates/<id>/template.json
|
|
330
|
+
(仅在仓库还残留旧目录时使用)。
|
|
331
|
+
返回完整 template dict(含 supportedAspectRatios / assetRequirements / slotMapping 等),
|
|
332
|
+
找不到时返回 None。
|
|
333
|
+
"""
|
|
334
|
+
# 优先使用 registry_loader(这是模板元数据的唯一权威来源)
|
|
335
|
+
if _HAS_REGISTRY_LOADER:
|
|
336
|
+
try:
|
|
337
|
+
return _get_template(template_id)
|
|
338
|
+
except Exception as exc:
|
|
339
|
+
print(
|
|
340
|
+
f"⚠️ registry_loader failed, falling back to local template.json: {exc}",
|
|
341
|
+
file=sys.stderr,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# 兜底:旧的本地目录布局
|
|
345
|
+
legacy_dir = os.path.join(
|
|
346
|
+
os.path.dirname(__file__), "..", "..", "template-bind", "video_dsl", "templates"
|
|
347
|
+
)
|
|
348
|
+
legacy_file = os.path.join(legacy_dir, template_id, "template.json")
|
|
349
|
+
if os.path.isfile(legacy_file):
|
|
350
|
+
with open(legacy_file, "r", encoding="utf-8") as f:
|
|
351
|
+
return json.load(f)
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _template_supported_ratio(template_config: dict | None) -> str | None:
|
|
356
|
+
"""Return template 的首选宽高比。
|
|
357
|
+
|
|
358
|
+
如果模板声明了 supportedAspectRatios,返回数组首项作为推荐值;
|
|
359
|
+
否则返回 None,由调用方使用全局默认(16:9)。
|
|
360
|
+
"""
|
|
361
|
+
if not template_config:
|
|
362
|
+
return None
|
|
363
|
+
ratios = template_config.get("supportedAspectRatios") or []
|
|
364
|
+
for r in ratios:
|
|
365
|
+
if isinstance(r, str) and r in VALID_RATIOS:
|
|
366
|
+
return r
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _template_primary_visual_type(template_config: dict | None) -> str:
|
|
371
|
+
"""从模板的 assetRequirements 推导每个 scene 的主要视觉素材类型。
|
|
372
|
+
|
|
373
|
+
返回值之一:
|
|
374
|
+
- "image" : 模板要求图片素材(默认通用 DSL 也是图片)
|
|
375
|
+
- "video" : 模板要求视频素材(assetRequirements 含 video 但不含 image)
|
|
376
|
+
- "none" : 模板不需要任何视觉素材(如 html-slide 仅 audio)
|
|
377
|
+
"""
|
|
378
|
+
if not template_config:
|
|
379
|
+
return "image" # 默认:通用 DSL 走图片
|
|
380
|
+
requirements = template_config.get("assetRequirements") or []
|
|
381
|
+
if "image" in requirements:
|
|
382
|
+
return "image"
|
|
383
|
+
if "video" in requirements:
|
|
384
|
+
return "video"
|
|
385
|
+
return "none"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _template_needs_image(template_config: dict | None) -> bool:
|
|
389
|
+
"""Check if a template requires image assets.
|
|
390
|
+
|
|
391
|
+
If assetRequirements is defined and does NOT include 'image', images are not needed.
|
|
392
|
+
"""
|
|
393
|
+
if template_config is None:
|
|
394
|
+
return True # default: generate images for generic DSL
|
|
395
|
+
requirements = template_config.get("assetRequirements", [])
|
|
396
|
+
return "image" in requirements
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
400
|
+
# Template-as-Contract: input-schema driven DSL assembly
|
|
401
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
402
|
+
#
|
|
403
|
+
# 当模板目录下存在 input-schema.json 时,gen_script 用该 schema 动态组装 DSL,
|
|
404
|
+
# 无需为每个模板写 if-else 分支。模板作者只需维护 input-schema.json + dsl-example.json。
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _locate_template_dir(template_id: str) -> str | None:
|
|
408
|
+
"""定位模板源码目录(template-library/packages/templates/src/<id>/)。
|
|
409
|
+
|
|
410
|
+
路径解析已集中到 ``template_paths.monorepo_template_src_dir``;
|
|
411
|
+
当 helper 不可用(template-bind/scripts/ 不在 sys.path)时回退到 None,
|
|
412
|
+
与原始函数"找不到就返回 None"的语义一致。
|
|
413
|
+
"""
|
|
414
|
+
if _monorepo_template_src_dir is None:
|
|
415
|
+
return None
|
|
416
|
+
return _monorepo_template_src_dir(template_id)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _plain_len(text: str) -> int:
|
|
420
|
+
"""剥离 **强调** 标记后的纯字符数(与渲染端 emphasis.plainLength 等价)。"""
|
|
421
|
+
return len((text or "").replace("**", ""))
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# fit-caption 时长估算常量 —— 与 template-library spotlight-card/utils/timing.ts
|
|
425
|
+
# + estimateDuration.ts 保持同一套数值,避免上游估时与组件实际耗时漂移。
|
|
426
|
+
_MAX_CPS_CAP = 60
|
|
427
|
+
_DEFAULT_TAIL_HOLD_SEC = 1.5
|
|
428
|
+
_TAIL_PADDING_SEC = 1.0
|
|
429
|
+
_HEADLINE_INTRO_SEC = 1.0
|
|
430
|
+
_CAROUSEL_MIN_PER_ITEM_SEC = 2.0
|
|
431
|
+
_CAROUSEL_MIN_PER_VIDEO_SEC = 3.5
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _estimate_caption_natural_sec(caption: dict) -> float:
|
|
435
|
+
"""打字机自然节奏耗时(startDelay + 打字 + 行间停顿 + 收尾),秒。"""
|
|
436
|
+
lines = (caption.get("lines") or [])[:10]
|
|
437
|
+
if not lines:
|
|
438
|
+
return 0.0
|
|
439
|
+
natural_cps = max(4, min(80, caption.get("charsPerSec", 28)))
|
|
440
|
+
line_gap_sec = caption.get("lineGapMs", 350) / 1000.0
|
|
441
|
+
start_delay_sec = caption.get("startDelayMs", 600) / 1000.0
|
|
442
|
+
total_chars = sum(_plain_len(t) for t in lines)
|
|
443
|
+
inter_line_sec = line_gap_sec * max(0, len(lines) - 1)
|
|
444
|
+
fixed_overhead = start_delay_sec + inter_line_sec + _DEFAULT_TAIL_HOLD_SEC
|
|
445
|
+
return fixed_overhead + total_chars / natural_cps
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _estimate_carousel_min_sec(carousel: dict) -> float:
|
|
449
|
+
"""轮播至少需要的秒数(每张图/视频的最小停留,减去重叠的过渡)。"""
|
|
450
|
+
items = carousel.get("items") or []
|
|
451
|
+
n = len(items)
|
|
452
|
+
if n == 0:
|
|
453
|
+
return 0.0
|
|
454
|
+
transition_sec = carousel.get("transitionMs", 400) / 1000.0
|
|
455
|
+
explicit_pacing = carousel.get("pacing")
|
|
456
|
+
any_explicit = any(it.get("holdSec") is not None for it in items)
|
|
457
|
+
has_default_hold = carousel.get("defaultHoldSec") is not None
|
|
458
|
+
pacing = explicit_pacing or ("fixed" if (any_explicit or has_default_hold) else "auto")
|
|
459
|
+
if pacing == "fixed":
|
|
460
|
+
default_hold = carousel.get("defaultHoldSec", 3.0)
|
|
461
|
+
total = sum(it.get("holdSec", default_hold) for it in items)
|
|
462
|
+
return total - (n - 1) * transition_sec
|
|
463
|
+
total = sum(
|
|
464
|
+
_CAROUSEL_MIN_PER_VIDEO_SEC if it.get("kind") == "video" else _CAROUSEL_MIN_PER_ITEM_SEC
|
|
465
|
+
for it in items
|
|
466
|
+
)
|
|
467
|
+
return total - (n - 1) * transition_sec
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _estimate_fit_caption_duration(custom_payload: dict) -> float:
|
|
471
|
+
"""fit-caption 策略:按打字机 + 轮播 + 标题入场推算建议时长(秒)。
|
|
472
|
+
|
|
473
|
+
等价于 template-library 的 estimateSpotlightCardDuration().recommendedSec:
|
|
474
|
+
各组件并行,取 max 再加收尾留白。
|
|
475
|
+
"""
|
|
476
|
+
caption = custom_payload.get("caption") or {}
|
|
477
|
+
carousel = custom_payload.get("carousel") or {}
|
|
478
|
+
caption_sec = _estimate_caption_natural_sec(caption)
|
|
479
|
+
carousel_sec = _estimate_carousel_min_sec(carousel)
|
|
480
|
+
recommended = max(_HEADLINE_INTRO_SEC, caption_sec, carousel_sec) + _TAIL_PADDING_SEC
|
|
481
|
+
return recommended
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _load_input_schema(template_id: str) -> dict | None:
|
|
485
|
+
"""Load input-schema.json for a template. Returns None if not found."""
|
|
486
|
+
tpl_dir = _locate_template_dir(template_id)
|
|
487
|
+
if not tpl_dir:
|
|
488
|
+
return None
|
|
489
|
+
schema_file = os.path.join(tpl_dir, "input-schema.json")
|
|
490
|
+
if not os.path.isfile(schema_file):
|
|
491
|
+
return None
|
|
492
|
+
try:
|
|
493
|
+
with open(schema_file, "r", encoding="utf-8") as f:
|
|
494
|
+
return json.load(f)
|
|
495
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
496
|
+
print(f"⚠️ failed to load input-schema.json for {template_id}: {exc}", file=sys.stderr)
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _build_dsl_from_input_schema(
|
|
501
|
+
*,
|
|
502
|
+
template_id: str,
|
|
503
|
+
template_config: dict,
|
|
504
|
+
input_schema: dict,
|
|
505
|
+
topic: str,
|
|
506
|
+
headline: str,
|
|
507
|
+
subheadline: str,
|
|
508
|
+
carousel_items: list[str],
|
|
509
|
+
caption_lines: list[str],
|
|
510
|
+
duration: int,
|
|
511
|
+
platform: str,
|
|
512
|
+
style: str,
|
|
513
|
+
ratio: str,
|
|
514
|
+
resolution: str,
|
|
515
|
+
voice_id: str,
|
|
516
|
+
output_language: str,
|
|
517
|
+
resolved_voice_id: str,
|
|
518
|
+
) -> dict:
|
|
519
|
+
"""Schema-driven DSL assembly.
|
|
520
|
+
|
|
521
|
+
Uses the template's input-schema.json + defaults to build a complete DSL
|
|
522
|
+
with the correct customPayload structure. No template-specific if-else.
|
|
523
|
+
"""
|
|
524
|
+
defaults = input_schema.get("defaults", {})
|
|
525
|
+
|
|
526
|
+
# 旁白开关:部分模板(如 spotlight-card)是打字机 / 画面驱动,刻意无配音。
|
|
527
|
+
# 由 input-schema.json 的 narration.enabled 声明,缺省 True 保持其它 schema
|
|
528
|
+
# 模板的向后兼容。关闭时不产出 gen-voice 音频资产,scene 也不带 audio.narration,
|
|
529
|
+
# 这样 prepare_video_assets 不会触发 TTS、render_video 也不会按 TTS 拉长场景时长。
|
|
530
|
+
narration_enabled = bool(input_schema.get("narration", {}).get("enabled", True))
|
|
531
|
+
|
|
532
|
+
# ── Build assets from carousel_items ──────────────────────────────────
|
|
533
|
+
assets = []
|
|
534
|
+
carousel_asset_ids = []
|
|
535
|
+
for idx, url in enumerate(carousel_items):
|
|
536
|
+
asset_id = f"carousel-img-{idx + 1:02d}"
|
|
537
|
+
# Determine if URL is a video (basic heuristic on extension)
|
|
538
|
+
is_video = any(url.lower().endswith(ext) for ext in (".mp4", ".mov", ".webm", ".avi"))
|
|
539
|
+
assets.append({
|
|
540
|
+
"assetId": asset_id,
|
|
541
|
+
"type": "video" if is_video else "image",
|
|
542
|
+
"source": "existing",
|
|
543
|
+
"status": "generated",
|
|
544
|
+
"url": url,
|
|
545
|
+
})
|
|
546
|
+
carousel_asset_ids.append((asset_id, "video" if is_video else "image"))
|
|
547
|
+
|
|
548
|
+
# ── Narration asset (可选;仅在模板声明 narration.enabled 时产出) ───────
|
|
549
|
+
narration_asset_id = "narration-scene-01"
|
|
550
|
+
if narration_enabled:
|
|
551
|
+
assets.append({
|
|
552
|
+
"assetId": narration_asset_id,
|
|
553
|
+
"type": "audio",
|
|
554
|
+
"source": "gen-voice",
|
|
555
|
+
"status": "planned",
|
|
556
|
+
"payload": {
|
|
557
|
+
"voiceId": resolved_voice_id,
|
|
558
|
+
},
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
# ── Build carousel config ─────────────────────────────────────────────
|
|
562
|
+
carousel_defaults = defaults.get("carousel", {})
|
|
563
|
+
carousel_config = {
|
|
564
|
+
"items": [],
|
|
565
|
+
**carousel_defaults,
|
|
566
|
+
}
|
|
567
|
+
for asset_id, kind in carousel_asset_ids:
|
|
568
|
+
item = {
|
|
569
|
+
"assetRef": asset_id,
|
|
570
|
+
"entrance": carousel_defaults.get("defaultEntrance", "random"),
|
|
571
|
+
"exit": carousel_defaults.get("defaultExit", "random"),
|
|
572
|
+
}
|
|
573
|
+
if kind == "video":
|
|
574
|
+
item["kind"] = "video"
|
|
575
|
+
item["muted"] = True
|
|
576
|
+
carousel_config["items"].append(item)
|
|
577
|
+
|
|
578
|
+
# ── Build caption config ──────────────────────────────────────────────
|
|
579
|
+
caption_defaults = defaults.get("caption", {})
|
|
580
|
+
caption_config = {
|
|
581
|
+
"lines": caption_lines if caption_lines else [],
|
|
582
|
+
**caption_defaults,
|
|
583
|
+
}
|
|
584
|
+
# Override lines explicitly (defaults shouldn't clobber user-provided lines)
|
|
585
|
+
if caption_lines:
|
|
586
|
+
caption_config["lines"] = caption_lines
|
|
587
|
+
|
|
588
|
+
# ── Build customPayload ───────────────────────────────────────────────
|
|
589
|
+
custom_payload = {
|
|
590
|
+
"background": defaults.get("background", {"preset": "grid-particles"}),
|
|
591
|
+
"headlineStyle": defaults.get("headlineStyle", {"pill": True}),
|
|
592
|
+
"carousel": carousel_config,
|
|
593
|
+
"caption": caption_config,
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
# ── Narration skeleton ────────────────────────────────────────────────
|
|
597
|
+
narration_text = _localize_label(output_language, "narration_opening",
|
|
598
|
+
label="", topic=topic, duration=duration)
|
|
599
|
+
|
|
600
|
+
# ── Effective scene duration ──────────────────────────────────────────
|
|
601
|
+
# fit-caption 策略(input-schema.sceneStrategy.fixedScenes[].durationStrategy):
|
|
602
|
+
# 时长由打字机自然节奏决定,而不是盲取调用方传入的 targetDuration。
|
|
603
|
+
# 这是 spotlight-card 这类打字机/画面驱动模板的核心——没有旁白来"撑"时长,
|
|
604
|
+
# 必须由 caption 自己定,否则文字打完后画面会静止到 targetDuration。
|
|
605
|
+
scene_strategy = input_schema.get("sceneStrategy", {})
|
|
606
|
+
fixed_scenes = scene_strategy.get("fixedScenes", [])
|
|
607
|
+
duration_strategy = (
|
|
608
|
+
fixed_scenes[0].get("durationStrategy") if fixed_scenes else None
|
|
609
|
+
)
|
|
610
|
+
effective_duration = duration
|
|
611
|
+
if duration_strategy == "fit-caption":
|
|
612
|
+
est = _estimate_fit_caption_duration(custom_payload)
|
|
613
|
+
if est > 0:
|
|
614
|
+
effective_duration = int(math.ceil(est))
|
|
615
|
+
print(
|
|
616
|
+
f"ℹ️ fit-caption: scene duration {effective_duration}s "
|
|
617
|
+
f"(estimated from caption/carousel; ignoring target {duration}s)",
|
|
618
|
+
file=sys.stderr,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
# ── Build scene (spotlight-card uses a single scene) ──────────────────
|
|
622
|
+
text_layers = [
|
|
623
|
+
{"role": "headline", "content": headline, "animation": "slide-up"},
|
|
624
|
+
]
|
|
625
|
+
if subheadline:
|
|
626
|
+
text_layers.append({"role": "subheadline", "content": subheadline, "animation": "fade-in"})
|
|
627
|
+
|
|
628
|
+
scene = {
|
|
629
|
+
"id": "scene-01",
|
|
630
|
+
"purpose": "opening",
|
|
631
|
+
"duration": effective_duration,
|
|
632
|
+
"layout": template_id,
|
|
633
|
+
"textLayers": text_layers,
|
|
634
|
+
"customPayload": custom_payload,
|
|
635
|
+
}
|
|
636
|
+
if narration_enabled:
|
|
637
|
+
scene["audio"] = {
|
|
638
|
+
"narration": {
|
|
639
|
+
"text": narration_text,
|
|
640
|
+
"assetRef": narration_asset_id,
|
|
641
|
+
"needsFill": True,
|
|
642
|
+
},
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
# ── Assemble full DSL ─────────────────────────────────────────────────
|
|
646
|
+
effective_headline = headline or topic
|
|
647
|
+
effective_subheadline = subheadline or ""
|
|
648
|
+
|
|
649
|
+
dsl = {
|
|
650
|
+
"version": "v1alpha1",
|
|
651
|
+
"meta": {
|
|
652
|
+
"title": topic,
|
|
653
|
+
"topic": topic,
|
|
654
|
+
"headline": effective_headline,
|
|
655
|
+
"subheadline": effective_subheadline,
|
|
656
|
+
"platform": platform,
|
|
657
|
+
"targetDuration": effective_duration,
|
|
658
|
+
"language": LANGUAGE_TO_META.get(output_language, "zh-CN"),
|
|
659
|
+
"outputLanguage": output_language,
|
|
660
|
+
"style": style or "",
|
|
661
|
+
"description": _localize_label(output_language, "video_description", topic=topic, duration=duration),
|
|
662
|
+
"templateId": template_id,
|
|
663
|
+
},
|
|
664
|
+
"global": {
|
|
665
|
+
"aspectRatio": ratio,
|
|
666
|
+
"resolution": resolution,
|
|
667
|
+
"fps": 30,
|
|
668
|
+
# 无配音模板:关闭 CC 字幕(字幕段是 narration→TTS 的派生物),
|
|
669
|
+
# 并省略 global.narration,避免下游误判存在旁白。
|
|
670
|
+
"subtitle": {"enabled": narration_enabled, "style": "bottom"},
|
|
671
|
+
**({"narration": {"voiceId": resolved_voice_id, "speed": 1.0}} if narration_enabled else {}),
|
|
672
|
+
"bgm": {"enabled": True, "volume": 0.12},
|
|
673
|
+
},
|
|
674
|
+
"assets": assets,
|
|
675
|
+
"scenes": [scene],
|
|
676
|
+
"transitions": {"default": "fade", "duration": 0.5},
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
return dsl
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _build_custom_payload(purpose: str, topic: str, label: str, idx: int, total_scenes: int, language: str = DEFAULT_LANGUAGE) -> dict:
|
|
683
|
+
"""Build customPayload with slideId and templateData for template-native scenes."""
|
|
684
|
+
if purpose == "opening":
|
|
685
|
+
return {
|
|
686
|
+
"slideId": "demo-concept-overview",
|
|
687
|
+
"templateData": {
|
|
688
|
+
"title": topic,
|
|
689
|
+
},
|
|
690
|
+
}
|
|
691
|
+
elif purpose == "cta":
|
|
692
|
+
return {
|
|
693
|
+
"slideId": "demo-concept-overview",
|
|
694
|
+
"templateData": {
|
|
695
|
+
"title": topic,
|
|
696
|
+
},
|
|
697
|
+
}
|
|
698
|
+
else:
|
|
699
|
+
palette = ["#6C5CE7", "#00B894", "#E17055", "#0984E3", "#FDCB6E", "#A29BFE", "#FF7675"]
|
|
700
|
+
return {
|
|
701
|
+
"slideId": "demo-single-concept",
|
|
702
|
+
"templateData": {
|
|
703
|
+
"icon": "💡",
|
|
704
|
+
"title": label,
|
|
705
|
+
"description": _localize_label(language, "point_description", topic=topic),
|
|
706
|
+
"accentColor": palette[idx % len(palette)],
|
|
707
|
+
},
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def build_dsl(
|
|
712
|
+
topic: str,
|
|
713
|
+
platform: str,
|
|
714
|
+
duration: int,
|
|
715
|
+
style: str,
|
|
716
|
+
ratio: str,
|
|
717
|
+
resolution: str,
|
|
718
|
+
voice_id: str,
|
|
719
|
+
scene_count: int | None,
|
|
720
|
+
allow_digital_human: bool,
|
|
721
|
+
allow_ai_video: bool,
|
|
722
|
+
template_id: str | None = None,
|
|
723
|
+
stub_image_url: str = "",
|
|
724
|
+
stub_video_url: str = "",
|
|
725
|
+
headline: str | None = None,
|
|
726
|
+
subheadline: str | None = None,
|
|
727
|
+
carousel_items: list[str] | None = None,
|
|
728
|
+
caption_lines: list[str] | None = None,
|
|
729
|
+
) -> dict:
|
|
730
|
+
"""Build a complete Video DSL JSON.
|
|
731
|
+
|
|
732
|
+
When template_id is provided, loads the template config and adapts the DSL
|
|
733
|
+
structure to match the template's assetRequirements and scene patterns.
|
|
734
|
+
|
|
735
|
+
If the template has an input-schema.json AND the caller provides structured
|
|
736
|
+
inputs (carousel_items / caption_lines), the schema-driven assembly path is
|
|
737
|
+
used — producing a template-native DSL without any template-specific if-else.
|
|
738
|
+
|
|
739
|
+
headline / subheadline 让作者显式提供画面上的"短主标题 + 副标题"。
|
|
740
|
+
DSL 元字段命名约定:
|
|
741
|
+
- meta.headline 画面上的主标题(建议 4-12 字)。默认退回 topic
|
|
742
|
+
- meta.subheadline 画面上的副标题(项目名 / 一句标语 / 来源)。默认 ""
|
|
743
|
+
模板的 textLayers[role=headline|subheadline] 会取这两个字段,propExtractors
|
|
744
|
+
通过 role=headline / role=subheadline 抽出对应的 props(如 titleText / projectName)。
|
|
745
|
+
注意:subtitle(CC 字幕)由 global.subtitle 与 render-plan.subtitleSegments
|
|
746
|
+
单独承载,与 subheadline 完全是两个东西,命名上刻意分开避免歧义。
|
|
747
|
+
"""
|
|
748
|
+
# Load template config to determine asset requirements
|
|
749
|
+
template_config = _load_template_config(template_id) if template_id else None
|
|
750
|
+
|
|
751
|
+
# ── Schema-driven path: if input-schema.json exists AND structured inputs provided ──
|
|
752
|
+
if template_id and (carousel_items or caption_lines):
|
|
753
|
+
input_schema = _load_input_schema(template_id)
|
|
754
|
+
if input_schema and template_config:
|
|
755
|
+
# Resolve language + voice (same logic as generic path)
|
|
756
|
+
output_language, template_default_voice_id = _resolve_template_language_meta(template_config)
|
|
757
|
+
resolved_voice_id = _resolve_voice_id(voice_id, template_default_voice_id, output_language)
|
|
758
|
+
if resolved_voice_id != voice_id:
|
|
759
|
+
print(
|
|
760
|
+
f"ℹ️ voice resolution: cli={voice_id!r} → resolved={resolved_voice_id!r} "
|
|
761
|
+
f"(templateDefault={template_default_voice_id!r}, language={output_language})",
|
|
762
|
+
file=sys.stderr,
|
|
763
|
+
)
|
|
764
|
+
effective_headline = (headline or "").strip() or topic
|
|
765
|
+
effective_subheadline = (subheadline or "").strip()
|
|
766
|
+
print(
|
|
767
|
+
f"ℹ️ using input-schema driven assembly for template {template_id}",
|
|
768
|
+
file=sys.stderr,
|
|
769
|
+
)
|
|
770
|
+
return _build_dsl_from_input_schema(
|
|
771
|
+
template_id=template_id,
|
|
772
|
+
template_config=template_config,
|
|
773
|
+
input_schema=input_schema,
|
|
774
|
+
topic=topic,
|
|
775
|
+
headline=effective_headline,
|
|
776
|
+
subheadline=effective_subheadline,
|
|
777
|
+
carousel_items=carousel_items or [],
|
|
778
|
+
caption_lines=caption_lines or [],
|
|
779
|
+
duration=duration,
|
|
780
|
+
platform=platform,
|
|
781
|
+
style=style,
|
|
782
|
+
ratio=ratio,
|
|
783
|
+
resolution=resolution,
|
|
784
|
+
voice_id=voice_id,
|
|
785
|
+
output_language=output_language,
|
|
786
|
+
resolved_voice_id=resolved_voice_id,
|
|
787
|
+
)
|
|
788
|
+
primary_visual_type = _template_primary_visual_type(template_config)
|
|
789
|
+
has_visual = primary_visual_type in ("image", "video")
|
|
790
|
+
|
|
791
|
+
# ── Resolve template-driven output language + voice ────────────────────
|
|
792
|
+
# outputLanguage is owned by the template (template.json). Missing/invalid
|
|
793
|
+
# values fall back to "zh" so old templates keep their current behavior.
|
|
794
|
+
output_language, template_default_voice_id = _resolve_template_language_meta(template_config)
|
|
795
|
+
resolved_voice_id = _resolve_voice_id(voice_id, template_default_voice_id, output_language)
|
|
796
|
+
if resolved_voice_id != voice_id:
|
|
797
|
+
print(
|
|
798
|
+
f"ℹ️ voice resolution: cli={voice_id!r} → resolved={resolved_voice_id!r} "
|
|
799
|
+
f"(templateDefault={template_default_voice_id!r}, language={output_language})",
|
|
800
|
+
file=sys.stderr,
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
# 缺省值:headline 退回 topic(向后兼容),subheadline 默认空字符串
|
|
804
|
+
effective_headline = (headline or "").strip() or topic
|
|
805
|
+
effective_subheadline = (subheadline or "").strip()
|
|
806
|
+
|
|
807
|
+
scene_plans = plan_scenes(topic, duration, scene_count, allow_digital_human, language=output_language)
|
|
808
|
+
|
|
809
|
+
assets = []
|
|
810
|
+
scenes = []
|
|
811
|
+
|
|
812
|
+
for idx, plan in enumerate(scene_plans):
|
|
813
|
+
scene_id = f"scene-{idx + 1:02d}"
|
|
814
|
+
# 视觉素材的 assetId 命名遵循类型前缀:image → img-,video → video-
|
|
815
|
+
if primary_visual_type == "video":
|
|
816
|
+
visual_asset_id = f"video-{scene_id}"
|
|
817
|
+
else:
|
|
818
|
+
visual_asset_id = f"img-{scene_id}"
|
|
819
|
+
narration_asset_id = f"narration-{scene_id}"
|
|
820
|
+
narration_text = estimate_narration_text(plan["label"], plan["purpose"], plan["duration"], topic, language=output_language)
|
|
821
|
+
|
|
822
|
+
# 根据模板 assetRequirements 生成对应类型的视觉素材
|
|
823
|
+
if primary_visual_type == "image":
|
|
824
|
+
if stub_image_url:
|
|
825
|
+
assets.append({
|
|
826
|
+
"assetId": visual_asset_id,
|
|
827
|
+
"type": "image",
|
|
828
|
+
"source": "existing",
|
|
829
|
+
"status": "generated",
|
|
830
|
+
"url": stub_image_url,
|
|
831
|
+
})
|
|
832
|
+
else:
|
|
833
|
+
image_result = generate_image_prompt(plan["purpose"], topic, style, narration_text=narration_text)
|
|
834
|
+
|
|
835
|
+
img_payload = {
|
|
836
|
+
"prompt": image_result["prompt"],
|
|
837
|
+
"model": os.environ.get("DEFAULT_IMAGE_MODEL", "doubao/doubao-seedream-5-0-260128"),
|
|
838
|
+
"ratio": ratio,
|
|
839
|
+
}
|
|
840
|
+
if "negativePrompt" in image_result:
|
|
841
|
+
img_payload["negativePrompt"] = image_result["negativePrompt"]
|
|
842
|
+
if "guidanceScale" in image_result:
|
|
843
|
+
img_payload["guidanceScale"] = image_result["guidanceScale"]
|
|
844
|
+
|
|
845
|
+
assets.append({
|
|
846
|
+
"assetId": visual_asset_id,
|
|
847
|
+
"type": "image",
|
|
848
|
+
"source": "gen-image",
|
|
849
|
+
"status": "planned",
|
|
850
|
+
"payload": img_payload,
|
|
851
|
+
})
|
|
852
|
+
elif primary_visual_type == "video":
|
|
853
|
+
if stub_video_url:
|
|
854
|
+
assets.append({
|
|
855
|
+
"assetId": visual_asset_id,
|
|
856
|
+
"type": "video",
|
|
857
|
+
"source": "existing",
|
|
858
|
+
"status": "generated",
|
|
859
|
+
"url": stub_video_url,
|
|
860
|
+
})
|
|
861
|
+
else:
|
|
862
|
+
# 视频素材:复用 image prompt 生成器作为兜底,再追加 "视频/动态" 关键词
|
|
863
|
+
# 模型 / 时长 / 比例都遵循 gen-video 校验规则(Seedance 4-12s、Veo 4/6/8s)
|
|
864
|
+
image_result = generate_image_prompt(plan["purpose"], topic, style, narration_text=narration_text)
|
|
865
|
+
video_prompt = image_result["prompt"]
|
|
866
|
+
# 视频时长上限按 gen-video Seedance 模型的 12s 截断,下限 4s
|
|
867
|
+
video_duration = max(4, min(int(plan["duration"]), 12))
|
|
868
|
+
vid_payload = {
|
|
869
|
+
"prompt": video_prompt,
|
|
870
|
+
"model": os.environ.get("DEFAULT_VIDEO_MODEL", "doubao-seedance-1-5-pro-251215"),
|
|
871
|
+
"ratio": ratio,
|
|
872
|
+
"duration": video_duration,
|
|
873
|
+
}
|
|
874
|
+
assets.append({
|
|
875
|
+
"assetId": visual_asset_id,
|
|
876
|
+
"type": "video",
|
|
877
|
+
"source": "gen-video",
|
|
878
|
+
"status": "planned",
|
|
879
|
+
"payload": vid_payload,
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
# narration text 不再在 audio asset 的 payload 里冗余存放——
|
|
883
|
+
# 唯一来源是下游 scenes[].audio.narration.text,render_video 在
|
|
884
|
+
# 调用 TTS skill 前会按 assetRef 回查 scene 文本注入。
|
|
885
|
+
assets.append({
|
|
886
|
+
"assetId": narration_asset_id,
|
|
887
|
+
"type": "audio",
|
|
888
|
+
"source": "gen-voice",
|
|
889
|
+
"status": "planned",
|
|
890
|
+
"payload": {
|
|
891
|
+
"voiceId": resolved_voice_id,
|
|
892
|
+
},
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
if has_visual:
|
|
896
|
+
layout = "text-overlay" if plan["purpose"] in ("opening", "cta") else "full-visual"
|
|
897
|
+
text_layers = []
|
|
898
|
+
if plan["purpose"] == "opening":
|
|
899
|
+
text_layers = [
|
|
900
|
+
{"role": "headline", "content": effective_headline, "animation": "slide-up"},
|
|
901
|
+
]
|
|
902
|
+
if effective_subheadline:
|
|
903
|
+
text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
|
|
904
|
+
elif plan["purpose"] == "point":
|
|
905
|
+
text_layers = [
|
|
906
|
+
{"role": "badge", "content": plan["label"], "animation": "slide-up"},
|
|
907
|
+
]
|
|
908
|
+
# 给所有 point 场景补一个 headline textLayer,让 screen-walkthrough
|
|
909
|
+
# 等模板的 propExtractors 直接拿到主标题,不再依赖 binding 兜底逻辑。
|
|
910
|
+
text_layers.insert(0, {"role": "headline", "content": effective_headline, "animation": "fade-in"})
|
|
911
|
+
if effective_subheadline:
|
|
912
|
+
text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
|
|
913
|
+
elif plan["purpose"] == "cta":
|
|
914
|
+
text_layers = [
|
|
915
|
+
{"role": "headline", "content": _localize_label(output_language, "follow_us"), "animation": "fade-in"},
|
|
916
|
+
]
|
|
917
|
+
if effective_subheadline:
|
|
918
|
+
text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
|
|
919
|
+
|
|
920
|
+
scenes.append({
|
|
921
|
+
"id": scene_id,
|
|
922
|
+
"purpose": plan["purpose"],
|
|
923
|
+
"duration": plan["duration"],
|
|
924
|
+
"layout": layout,
|
|
925
|
+
"visuals": {"background": {"assetRef": visual_asset_id}},
|
|
926
|
+
"audio": {
|
|
927
|
+
"narration": {
|
|
928
|
+
"text": narration_text,
|
|
929
|
+
"assetRef": narration_asset_id,
|
|
930
|
+
# 骨架标记:scenes[].audio.narration.text 同样需要被真实旁白替换
|
|
931
|
+
"needsFill": True,
|
|
932
|
+
},
|
|
933
|
+
},
|
|
934
|
+
"textLayers": text_layers,
|
|
935
|
+
"animationHints": {
|
|
936
|
+
"entrance": "fade",
|
|
937
|
+
"motion": "kenburns-in" if idx % 2 == 0 else "kenburns-out",
|
|
938
|
+
},
|
|
939
|
+
})
|
|
940
|
+
else:
|
|
941
|
+
# The "no-visual" branch labels each scene with a layout hint.
|
|
942
|
+
# Historical fallback was hardcoded "html-slide" — when no template
|
|
943
|
+
# was specified, every audio-only scene defaulted to html-slide
|
|
944
|
+
# styling. We replace the hardcoded fallback chain with:
|
|
945
|
+
# 1. template.capabilities.defaultLayout ⇐ the template tells
|
|
946
|
+
# ab-skill what layout name it wants on its no-visual scenes
|
|
947
|
+
# 2. template_id ⇐ legacy: pass id as
|
|
948
|
+
# layout name (existing behavior when no capability declared)
|
|
949
|
+
# 3. "html-slide" ⇐ absolute fallback
|
|
950
|
+
# for the no-template case (preserves prior behavior)
|
|
951
|
+
_layout_cfg = (template_config or {}).get("capabilities") or {}
|
|
952
|
+
layout = _layout_cfg.get("defaultLayout") or template_id or "html-slide"
|
|
953
|
+
text_layers = []
|
|
954
|
+
if plan["purpose"] == "opening":
|
|
955
|
+
text_layers = [
|
|
956
|
+
{"role": "headline", "content": topic, "animation": "fade-in"},
|
|
957
|
+
]
|
|
958
|
+
elif plan["purpose"] == "point":
|
|
959
|
+
text_layers = [
|
|
960
|
+
{"role": "headline", "content": plan["label"], "animation": "slide-up"},
|
|
961
|
+
]
|
|
962
|
+
elif plan["purpose"] == "cta":
|
|
963
|
+
text_layers = [
|
|
964
|
+
{"role": "headline", "content": _localize_label(output_language, "follow_us"), "animation": "zoom-in"},
|
|
965
|
+
{"role": "subheadline", "content": _localize_label(output_language, "more_about", topic=topic), "animation": "fade-in"},
|
|
966
|
+
]
|
|
967
|
+
|
|
968
|
+
custom_payload = _build_custom_payload(plan["purpose"], topic, plan["label"], idx, len(scene_plans), language=output_language)
|
|
969
|
+
|
|
970
|
+
scenes.append({
|
|
971
|
+
"id": scene_id,
|
|
972
|
+
"purpose": plan["purpose"],
|
|
973
|
+
"duration": plan["duration"],
|
|
974
|
+
"layout": layout,
|
|
975
|
+
"audio": {
|
|
976
|
+
"narration": {
|
|
977
|
+
"text": narration_text,
|
|
978
|
+
"assetRef": narration_asset_id,
|
|
979
|
+
# 骨架标记:scenes[].audio.narration.text 同样需要被真实旁白替换
|
|
980
|
+
"needsFill": True,
|
|
981
|
+
},
|
|
982
|
+
},
|
|
983
|
+
"textLayers": text_layers,
|
|
984
|
+
"customPayload": custom_payload,
|
|
985
|
+
})
|
|
986
|
+
|
|
987
|
+
dsl = {
|
|
988
|
+
"version": "v1alpha1",
|
|
989
|
+
"meta": {
|
|
990
|
+
"title": topic,
|
|
991
|
+
"topic": topic,
|
|
992
|
+
"headline": effective_headline,
|
|
993
|
+
"subheadline": effective_subheadline,
|
|
994
|
+
"platform": platform,
|
|
995
|
+
"targetDuration": duration,
|
|
996
|
+
"language": LANGUAGE_TO_META[output_language],
|
|
997
|
+
"outputLanguage": output_language,
|
|
998
|
+
"style": style or "",
|
|
999
|
+
"description": _localize_label(output_language, "video_description", topic=topic, duration=duration),
|
|
1000
|
+
},
|
|
1001
|
+
"global": {
|
|
1002
|
+
"aspectRatio": ratio,
|
|
1003
|
+
"resolution": resolution,
|
|
1004
|
+
"fps": 30,
|
|
1005
|
+
"subtitle": {"enabled": True, "style": "bottom"},
|
|
1006
|
+
"narration": {"voiceId": resolved_voice_id, "speed": 1.0},
|
|
1007
|
+
"bgm": {"enabled": True, "volume": 0.12},
|
|
1008
|
+
},
|
|
1009
|
+
"assets": assets,
|
|
1010
|
+
"scenes": scenes,
|
|
1011
|
+
"transitions": {"default": "fade", "duration": 0.5},
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
if template_id:
|
|
1015
|
+
dsl["meta"]["templateId"] = template_id
|
|
1016
|
+
|
|
1017
|
+
return dsl
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def main():
|
|
1021
|
+
parser = argparse.ArgumentParser(
|
|
1022
|
+
description="Video script generator — produces Video DSL v1alpha1 JSON",
|
|
1023
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1024
|
+
epilog="""
|
|
1025
|
+
Examples:
|
|
1026
|
+
python gen_script.py --topic "3 AI study hacks" --duration 30 --ratio "9:16"
|
|
1027
|
+
python gen_script.py --validate --input my-video.dsl.json
|
|
1028
|
+
""",
|
|
1029
|
+
)
|
|
1030
|
+
parser.add_argument("--topic", help="Video topic (required for generation mode)")
|
|
1031
|
+
parser.add_argument(
|
|
1032
|
+
"--platform",
|
|
1033
|
+
default="generic",
|
|
1034
|
+
choices=VALID_PLATFORMS,
|
|
1035
|
+
help="Target platform (default: generic)",
|
|
1036
|
+
)
|
|
1037
|
+
parser.add_argument("--duration", type=int, default=30, help="Target duration in seconds (default: 30)")
|
|
1038
|
+
parser.add_argument("--style", default="", help="Style tag")
|
|
1039
|
+
parser.add_argument("--ratio", default=None, choices=VALID_RATIOS, help="Aspect ratio. When omitted: if --template-id is set, use the template's supportedAspectRatios[0]; otherwise default 16:9.")
|
|
1040
|
+
parser.add_argument("--resolution", default="1080p", help="Resolution (default: 1080p)")
|
|
1041
|
+
parser.add_argument("--scenes", type=int, default=None, help="Scene count (default: auto-planned)")
|
|
1042
|
+
parser.add_argument("--voice-id", default=None, help="Narration voice id. When omitted, the resolver picks template.defaultVoiceId, then the language-keyed fallback (zh→Chinese (Mandarin)_Male_Announcer, en→TBD English voice). gen_voice --list-voices --local prints the language-tagged catalog.")
|
|
1043
|
+
parser.add_argument("--allow-digital-human", action="store_true", help="Allow digital-human assets")
|
|
1044
|
+
parser.add_argument("--allow-ai-video", action="store_true", help="Allow AI-generated video assets")
|
|
1045
|
+
parser.add_argument("--template-id", default=None, help="Template id. The template owns outputLanguage and may declare a defaultVoiceId.")
|
|
1046
|
+
parser.add_argument("--validate", action="store_true", help="Validate-only mode: only validate the input DSL")
|
|
1047
|
+
parser.add_argument("--input", "-i", help="Input DSL file path (required for validate mode)")
|
|
1048
|
+
parser.add_argument(
|
|
1049
|
+
"--headline",
|
|
1050
|
+
default=None,
|
|
1051
|
+
help="On-screen headline (recommended 4-12 chars / ~3 words). Stored at meta.headline and pushed into textLayers[role=headline]. Falls back to topic when not provided.",
|
|
1052
|
+
)
|
|
1053
|
+
parser.add_argument(
|
|
1054
|
+
"--subheadline",
|
|
1055
|
+
default=None,
|
|
1056
|
+
help="On-screen subheadline / project name (e.g. 'Pixelle-Video'). Stored at meta.subheadline and pushed into textLayers[role=subheadline]. Independent from CC subtitles (global.subtitle).",
|
|
1057
|
+
)
|
|
1058
|
+
parser.add_argument(
|
|
1059
|
+
"--stub-image-url",
|
|
1060
|
+
default=None,
|
|
1061
|
+
help="Test mode: every image AssetRef is written as source=existing, status=generated, url=<this URL> (no prompt; no gen-image call). STUB_IMAGE_URL env var also works but the CLI flag is preferred to avoid cross-session leakage.",
|
|
1062
|
+
)
|
|
1063
|
+
parser.add_argument(
|
|
1064
|
+
"--stub-video-url",
|
|
1065
|
+
default=None,
|
|
1066
|
+
help="Test mode: every video AssetRef is written as source=existing, status=generated, url=<this URL> (no prompt; no gen-video call). STUB_VIDEO_URL env var also works but the CLI flag is preferred to avoid cross-session leakage.",
|
|
1067
|
+
)
|
|
1068
|
+
parser.add_argument(
|
|
1069
|
+
"--carousel-items",
|
|
1070
|
+
action="append",
|
|
1071
|
+
default=None,
|
|
1072
|
+
help="Media URL for the template's carousel/gallery. Can be repeated: "
|
|
1073
|
+
"--carousel-items url1 --carousel-items url2. "
|
|
1074
|
+
"For templates with input-schema.json (e.g. spotlight-card), these URLs "
|
|
1075
|
+
"are placed directly into customPayload.carousel.items as existing assets, "
|
|
1076
|
+
"bypassing AI image generation entirely.",
|
|
1077
|
+
)
|
|
1078
|
+
parser.add_argument(
|
|
1079
|
+
"--caption-lines",
|
|
1080
|
+
action="append",
|
|
1081
|
+
default=None,
|
|
1082
|
+
help="Bottom typewriter text line. Can be repeated: "
|
|
1083
|
+
"--caption-lines 'line1' --caption-lines 'line2'. "
|
|
1084
|
+
"For templates with input-schema.json (e.g. spotlight-card), these are placed "
|
|
1085
|
+
"into customPayload.caption.lines. Supports **emphasis** syntax.",
|
|
1086
|
+
)
|
|
1087
|
+
|
|
1088
|
+
args = parser.parse_args()
|
|
1089
|
+
|
|
1090
|
+
if args.validate:
|
|
1091
|
+
if not args.input:
|
|
1092
|
+
print("❌ validate mode requires --input", file=sys.stderr)
|
|
1093
|
+
sys.exit(1)
|
|
1094
|
+
if not os.path.exists(args.input):
|
|
1095
|
+
print(f"❌ file not found: {args.input}", file=sys.stderr)
|
|
1096
|
+
sys.exit(1)
|
|
1097
|
+
with open(args.input, "r", encoding="utf-8") as f:
|
|
1098
|
+
dsl = json.load(f)
|
|
1099
|
+
errors = validate_dsl(dsl)
|
|
1100
|
+
if errors:
|
|
1101
|
+
print("❌ DSL validation failed:", file=sys.stderr)
|
|
1102
|
+
for err in errors:
|
|
1103
|
+
print(f" - {err}", file=sys.stderr)
|
|
1104
|
+
sys.exit(1)
|
|
1105
|
+
else:
|
|
1106
|
+
print("✅ DSL validation passed")
|
|
1107
|
+
return
|
|
1108
|
+
|
|
1109
|
+
if not args.topic:
|
|
1110
|
+
print("❌ please provide --topic", file=sys.stderr)
|
|
1111
|
+
parser.print_help()
|
|
1112
|
+
sys.exit(1)
|
|
1113
|
+
|
|
1114
|
+
# ── 模板感知的 ratio 自动推导 ────────────────────────────────────────────
|
|
1115
|
+
# 用户没显式传 --ratio 时:
|
|
1116
|
+
# 1. 若指定了 --template-id,取模板 supportedAspectRatios[0](避免横竖屏不匹配)
|
|
1117
|
+
# 2. 否则回退默认 16:9
|
|
1118
|
+
resolved_ratio = args.ratio
|
|
1119
|
+
if resolved_ratio is None:
|
|
1120
|
+
if args.template_id:
|
|
1121
|
+
tpl_cfg = _load_template_config(args.template_id)
|
|
1122
|
+
preferred = _template_supported_ratio(tpl_cfg)
|
|
1123
|
+
if preferred:
|
|
1124
|
+
resolved_ratio = preferred
|
|
1125
|
+
print(
|
|
1126
|
+
f"ℹ️ using aspect ratio {preferred} from template {args.template_id}",
|
|
1127
|
+
file=sys.stderr,
|
|
1128
|
+
)
|
|
1129
|
+
else:
|
|
1130
|
+
resolved_ratio = "16:9"
|
|
1131
|
+
if tpl_cfg is None:
|
|
1132
|
+
print(
|
|
1133
|
+
f"⚠️ template {args.template_id} not found; falling back to 16:9",
|
|
1134
|
+
file=sys.stderr,
|
|
1135
|
+
)
|
|
1136
|
+
else:
|
|
1137
|
+
resolved_ratio = "16:9"
|
|
1138
|
+
args.ratio = resolved_ratio
|
|
1139
|
+
|
|
1140
|
+
print(f"📝 generating video script...", file=sys.stderr)
|
|
1141
|
+
print(f" topic: {args.topic}", file=sys.stderr)
|
|
1142
|
+
print(f" platform: {args.platform}", file=sys.stderr)
|
|
1143
|
+
print(f" duration: {args.duration}s", file=sys.stderr)
|
|
1144
|
+
print(f" ratio: {args.ratio}", file=sys.stderr)
|
|
1145
|
+
|
|
1146
|
+
# CLI flag takes precedence; env vars act as fallback with a visible warning
|
|
1147
|
+
# so silent cross-session leakage is always observable.
|
|
1148
|
+
stub_image_url = args.stub_image_url
|
|
1149
|
+
stub_video_url = args.stub_video_url
|
|
1150
|
+
if stub_image_url is None:
|
|
1151
|
+
env_v = os.environ.get("STUB_IMAGE_URL", "")
|
|
1152
|
+
if env_v:
|
|
1153
|
+
print(f"⚠️ STUB_IMAGE_URL env var detected ({env_v}); using it as the image stub. Prefer passing --stub-image-url explicitly, or unset the env var.", file=sys.stderr)
|
|
1154
|
+
stub_image_url = env_v
|
|
1155
|
+
if stub_video_url is None:
|
|
1156
|
+
env_v = os.environ.get("STUB_VIDEO_URL", "")
|
|
1157
|
+
if env_v:
|
|
1158
|
+
print(f"⚠️ STUB_VIDEO_URL env var detected ({env_v}); using it as the video stub. Prefer passing --stub-video-url explicitly, or unset the env var.", file=sys.stderr)
|
|
1159
|
+
stub_video_url = env_v
|
|
1160
|
+
|
|
1161
|
+
dsl = build_dsl(
|
|
1162
|
+
topic=args.topic,
|
|
1163
|
+
platform=args.platform,
|
|
1164
|
+
duration=args.duration,
|
|
1165
|
+
style=args.style,
|
|
1166
|
+
ratio=args.ratio,
|
|
1167
|
+
resolution=args.resolution,
|
|
1168
|
+
voice_id=args.voice_id,
|
|
1169
|
+
scene_count=args.scenes,
|
|
1170
|
+
allow_digital_human=args.allow_digital_human,
|
|
1171
|
+
allow_ai_video=args.allow_ai_video,
|
|
1172
|
+
template_id=args.template_id,
|
|
1173
|
+
stub_image_url=stub_image_url,
|
|
1174
|
+
stub_video_url=stub_video_url,
|
|
1175
|
+
headline=args.headline,
|
|
1176
|
+
subheadline=args.subheadline,
|
|
1177
|
+
carousel_items=args.carousel_items,
|
|
1178
|
+
caption_lines=args.caption_lines,
|
|
1179
|
+
)
|
|
1180
|
+
|
|
1181
|
+
errors = validate_dsl(dsl)
|
|
1182
|
+
if errors:
|
|
1183
|
+
print("❌ generated DSL failed validation:", file=sys.stderr)
|
|
1184
|
+
for err in errors:
|
|
1185
|
+
print(f" - {err}", file=sys.stderr)
|
|
1186
|
+
sys.exit(1)
|
|
1187
|
+
|
|
1188
|
+
# Non-blocking language sanity check — only narrations from scenes[].
|
|
1189
|
+
expected_language = dsl.get("meta", {}).get("outputLanguage", DEFAULT_LANGUAGE)
|
|
1190
|
+
if expected_language in ("en", "zh"):
|
|
1191
|
+
narrations_for_check = [
|
|
1192
|
+
((scene.get("audio") or {}).get("narration") or {}).get("text", "")
|
|
1193
|
+
for scene in dsl.get("scenes", [])
|
|
1194
|
+
]
|
|
1195
|
+
_check_narration_language(narrations_for_check, expected_language)
|
|
1196
|
+
|
|
1197
|
+
output_json = json.dumps(dsl, ensure_ascii=False, indent=2)
|
|
1198
|
+
print(output_json)
|
|
1199
|
+
|
|
1200
|
+
scene_count = len(dsl["scenes"])
|
|
1201
|
+
asset_count = len(dsl["assets"])
|
|
1202
|
+
total_duration = sum(s.get("duration", 0) for s in dsl["scenes"])
|
|
1203
|
+
print(f"\n📊 script summary: {scene_count} scenes, {asset_count} assets, total {total_duration}s", file=sys.stderr)
|
|
1204
|
+
print("🎬 script generation complete!", file=sys.stderr)
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
if __name__ == "__main__":
|
|
1208
|
+
main()
|