@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,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
图片 Prompt 增强模块 — 利用旁白语义 + 模版视觉指南生成高质量图片提示词。
|
|
3
|
+
|
|
4
|
+
纯函数模块,无外部依赖。主要入口:
|
|
5
|
+
- build_enhanced_prompt() : 根据 purpose/topic/style/narration/styleGuide 构建增强 prompt
|
|
6
|
+
- enrich_asset_payload() : 一站式入口,自动判断并增强 asset payload
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# 常量
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
# 口语化填充词 / 无视觉意义的片段
|
|
17
|
+
_FILLER_PATTERNS = re.compile(
|
|
18
|
+
r"(那么|其实|所以说|然后呢|接下来|大家好|你好|对吧|对不对|是不是|"
|
|
19
|
+
r"嗯|啊|呢|吧|哦|哈|了|的话|来说|而言|就是说|换句话说|"
|
|
20
|
+
r"如果觉得有用就|点个关注|下期见|别忘了)"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# 标点符号归一化
|
|
24
|
+
_PUNCT = re.compile("[,。!?、;:\u201c\u201d\u2018\u2019《》【】()\\s]+")
|
|
25
|
+
|
|
26
|
+
# 默认按 purpose 的 prompt 模板(无模版 styleGuide 时的兜底)
|
|
27
|
+
DEFAULT_PURPOSE_PATTERNS: dict[str, str] = {
|
|
28
|
+
"opening": "{narration_summary},{topic}主题概念图,视觉冲击力强,{style_suffix},高清大图",
|
|
29
|
+
"intro": "{narration_summary},{topic}主题引入场景,{style_suffix},清晰直观",
|
|
30
|
+
"point": "{narration_summary},{style_suffix},聚焦核心概念的场景插画,清晰直观",
|
|
31
|
+
"example": "{narration_summary},{style_suffix},具体案例示意图,写实与图解结合",
|
|
32
|
+
"explanation": "{narration_summary},{style_suffix},解释性图示,信息图表风格",
|
|
33
|
+
"highlight": "{narration_summary},{style_suffix},重点概念高亮,视觉聚焦",
|
|
34
|
+
"transition": "{topic}相关过渡画面,{style_suffix},简洁流畅",
|
|
35
|
+
"cta": "关注点赞互动图标,{style_suffix},简洁现代设计,明亮醒目",
|
|
36
|
+
"ending": "感谢观看结尾画面,{style_suffix},温馨大气",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# prompt 长度阈值:超过此字符数的 prompt 视为已精心编写
|
|
40
|
+
WELL_CRAFTED_THRESHOLD = 80
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# 工具函数
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def summarize_narration(text: str, max_chars: int = 60) -> str:
|
|
48
|
+
"""从旁白文本提取视觉关键信息,去除口语化填充词。
|
|
49
|
+
|
|
50
|
+
>>> summarize_narration("那么接下来我们来看一下AI如何帮你高效背单词")
|
|
51
|
+
'AI如何帮你高效背单词'
|
|
52
|
+
"""
|
|
53
|
+
if not text:
|
|
54
|
+
return ""
|
|
55
|
+
# 去除填充词
|
|
56
|
+
cleaned = _FILLER_PATTERNS.sub("", text)
|
|
57
|
+
# 归一化标点 → 空格
|
|
58
|
+
cleaned = _PUNCT.sub(" ", cleaned).strip()
|
|
59
|
+
# 去除开头尾部空白
|
|
60
|
+
if not cleaned:
|
|
61
|
+
return text[:max_chars]
|
|
62
|
+
# 截断到 max_chars
|
|
63
|
+
if len(cleaned) > max_chars:
|
|
64
|
+
cleaned = cleaned[:max_chars]
|
|
65
|
+
return cleaned
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def extract_scene_context(scene: dict) -> dict:
|
|
69
|
+
"""从 DSL scene 提取视觉相关上下文。
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
dict with keys: narration_text, title, badge, purpose
|
|
73
|
+
"""
|
|
74
|
+
purpose = scene.get("purpose", "default")
|
|
75
|
+
narration_text = ""
|
|
76
|
+
audio = scene.get("audio", {})
|
|
77
|
+
narration = audio.get("narration", {})
|
|
78
|
+
if isinstance(narration, dict):
|
|
79
|
+
narration_text = narration.get("text", "")
|
|
80
|
+
|
|
81
|
+
title = ""
|
|
82
|
+
badge = ""
|
|
83
|
+
for layer in scene.get("textLayers", []):
|
|
84
|
+
role = layer.get("role", "")
|
|
85
|
+
if role == "title" and not title:
|
|
86
|
+
title = layer.get("content", "")
|
|
87
|
+
elif role == "badge" and not badge:
|
|
88
|
+
badge = layer.get("content", "")
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
"narration_text": narration_text,
|
|
92
|
+
"title": title,
|
|
93
|
+
"badge": badge,
|
|
94
|
+
"purpose": purpose,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def resolve_style_guide(template: Optional[dict], variant_id: str = "") -> dict:
|
|
99
|
+
"""合并模版根级 + 变体级的 imageStyleGuide。
|
|
100
|
+
|
|
101
|
+
变体级字段覆盖根级同名字段,purposeOverrides 做 merge。
|
|
102
|
+
"""
|
|
103
|
+
if not template:
|
|
104
|
+
return {}
|
|
105
|
+
|
|
106
|
+
base = dict(template.get("imageStyleGuide", {}) or {})
|
|
107
|
+
if not variant_id:
|
|
108
|
+
return base
|
|
109
|
+
|
|
110
|
+
variant = (template.get("variants", {}) or {}).get(variant_id, {})
|
|
111
|
+
variant_guide = dict(variant.get("imageStyleGuide", {}) or {})
|
|
112
|
+
if not variant_guide:
|
|
113
|
+
return base
|
|
114
|
+
|
|
115
|
+
# 合并 purposeOverrides
|
|
116
|
+
base_overrides = dict(base.get("purposeOverrides", {}) or {})
|
|
117
|
+
variant_overrides = dict(variant_guide.get("purposeOverrides", {}) or {})
|
|
118
|
+
|
|
119
|
+
merged = {**base, **variant_guide}
|
|
120
|
+
if base_overrides or variant_overrides:
|
|
121
|
+
merged_po = {**base_overrides, **variant_overrides}
|
|
122
|
+
merged["purposeOverrides"] = merged_po
|
|
123
|
+
|
|
124
|
+
return merged
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# 核心构建函数
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_enhanced_prompt(
|
|
133
|
+
purpose: str,
|
|
134
|
+
topic: str,
|
|
135
|
+
style: str = "",
|
|
136
|
+
narration_text: str = "",
|
|
137
|
+
scene_title: str = "",
|
|
138
|
+
style_guide: Optional[dict] = None,
|
|
139
|
+
) -> dict:
|
|
140
|
+
"""构建增强 prompt,返回 {prompt, negative_prompt, guidance_scale}。
|
|
141
|
+
|
|
142
|
+
优先使用 style_guide 中的模板和修饰符;无 style_guide 时使用内置默认模板。
|
|
143
|
+
"""
|
|
144
|
+
style_guide = style_guide or {}
|
|
145
|
+
style_suffix = f"{style}风格" if style else ""
|
|
146
|
+
|
|
147
|
+
# 提取旁白摘要
|
|
148
|
+
narration_summary = summarize_narration(narration_text)
|
|
149
|
+
if not narration_summary and scene_title:
|
|
150
|
+
narration_summary = scene_title
|
|
151
|
+
if not narration_summary:
|
|
152
|
+
narration_summary = topic or ""
|
|
153
|
+
|
|
154
|
+
# 基础变量
|
|
155
|
+
vars_ = {
|
|
156
|
+
"narration_summary": narration_summary,
|
|
157
|
+
"topic": topic or "",
|
|
158
|
+
"style_suffix": style_suffix,
|
|
159
|
+
"scene_title": scene_title or "",
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# 决定 prompt 模板
|
|
163
|
+
purpose_overrides = style_guide.get("purposeOverrides", {}) or {}
|
|
164
|
+
override = purpose_overrides.get(purpose, {})
|
|
165
|
+
|
|
166
|
+
prompt_template = override.get("promptTemplate", "")
|
|
167
|
+
if not prompt_template:
|
|
168
|
+
prompt_template = DEFAULT_PURPOSE_PATTERNS.get(
|
|
169
|
+
purpose, DEFAULT_PURPOSE_PATTERNS.get("point", "")
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# 格式化模板(安全替换)
|
|
173
|
+
prompt = _safe_format(prompt_template, vars_)
|
|
174
|
+
|
|
175
|
+
# 追加 styleModifier
|
|
176
|
+
style_modifier = override.get("styleModifier", "")
|
|
177
|
+
if style_modifier:
|
|
178
|
+
prompt = f"{prompt},{style_modifier}"
|
|
179
|
+
|
|
180
|
+
# 追加 baseStyle
|
|
181
|
+
base_style = style_guide.get("baseStyle", "")
|
|
182
|
+
if base_style:
|
|
183
|
+
prompt = f"{prompt},{base_style}"
|
|
184
|
+
|
|
185
|
+
# 追加 colorDirective
|
|
186
|
+
color_directive = style_guide.get("colorDirective", "")
|
|
187
|
+
if color_directive:
|
|
188
|
+
prompt = f"{prompt},{color_directive}"
|
|
189
|
+
|
|
190
|
+
# negative prompt
|
|
191
|
+
negative_prompt = override.get("negativePrompt", "") or style_guide.get("negativePrompt", "")
|
|
192
|
+
|
|
193
|
+
# guidance scale
|
|
194
|
+
guidance_scale = style_guide.get("guidanceScale")
|
|
195
|
+
|
|
196
|
+
result = {"prompt": prompt}
|
|
197
|
+
if negative_prompt:
|
|
198
|
+
result["negativePrompt"] = negative_prompt
|
|
199
|
+
if guidance_scale is not None:
|
|
200
|
+
result["guidanceScale"] = guidance_scale
|
|
201
|
+
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _safe_format(template: str, vars_: dict) -> str:
|
|
206
|
+
"""安全的字符串格式化,缺失变量保留占位符原文。"""
|
|
207
|
+
try:
|
|
208
|
+
return template.format(**vars_)
|
|
209
|
+
except (KeyError, IndexError):
|
|
210
|
+
result = template
|
|
211
|
+
for k, v in vars_.items():
|
|
212
|
+
result = result.replace("{" + k + "}", v)
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# 一站式入口
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def enrich_asset_payload(
|
|
222
|
+
payload: dict,
|
|
223
|
+
scene: Optional[dict] = None,
|
|
224
|
+
template: Optional[dict] = None,
|
|
225
|
+
dsl_meta: Optional[dict] = None,
|
|
226
|
+
variant_id: str = "",
|
|
227
|
+
) -> dict:
|
|
228
|
+
"""增强 asset payload 中的图片 prompt。
|
|
229
|
+
|
|
230
|
+
- prompt 已超过 WELL_CRAFTED_THRESHOLD 字符的,仅补充 negativePrompt(如缺失)
|
|
231
|
+
- 否则用 build_enhanced_prompt 重新构建
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
新的 payload dict(不修改原始对象)
|
|
235
|
+
"""
|
|
236
|
+
payload = dict(payload)
|
|
237
|
+
current_prompt = payload.get("prompt", "")
|
|
238
|
+
|
|
239
|
+
meta = dsl_meta or {}
|
|
240
|
+
topic = meta.get("topic", meta.get("title", ""))
|
|
241
|
+
style = meta.get("style", "")
|
|
242
|
+
|
|
243
|
+
style_guide = resolve_style_guide(template, variant_id)
|
|
244
|
+
|
|
245
|
+
# 已精心编写的 prompt → 仅补充 negativePrompt
|
|
246
|
+
if len(current_prompt) > WELL_CRAFTED_THRESHOLD:
|
|
247
|
+
if "negativePrompt" not in payload and style_guide.get("negativePrompt"):
|
|
248
|
+
payload["negativePrompt"] = style_guide["negativePrompt"]
|
|
249
|
+
if "guidanceScale" not in payload and style_guide.get("guidanceScale") is not None:
|
|
250
|
+
payload["guidanceScale"] = style_guide["guidanceScale"]
|
|
251
|
+
return payload
|
|
252
|
+
|
|
253
|
+
# 从 scene 提取上下文
|
|
254
|
+
scene_ctx = extract_scene_context(scene) if scene else {}
|
|
255
|
+
purpose = scene_ctx.get("purpose", "point")
|
|
256
|
+
narration_text = scene_ctx.get("narration_text", "")
|
|
257
|
+
scene_title = scene_ctx.get("title", "") or scene_ctx.get("badge", "")
|
|
258
|
+
|
|
259
|
+
enhanced = build_enhanced_prompt(
|
|
260
|
+
purpose=purpose,
|
|
261
|
+
topic=topic,
|
|
262
|
+
style=style,
|
|
263
|
+
narration_text=narration_text,
|
|
264
|
+
scene_title=scene_title,
|
|
265
|
+
style_guide=style_guide,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
payload["prompt"] = enhanced["prompt"]
|
|
269
|
+
if "negativePrompt" in enhanced:
|
|
270
|
+
payload["negativePrompt"] = enhanced["negativePrompt"]
|
|
271
|
+
if "guidanceScale" in enhanced:
|
|
272
|
+
payload["guidanceScale"] = enhanced["guidanceScale"]
|
|
273
|
+
|
|
274
|
+
return payload
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
模板 registry 摘要导出器 - 把 ``@ab-templates/metadata`` 的 registry.json
|
|
3
|
+
浓缩为 LLM prompt / 离线脚本使用的精简版本并写盘。
|
|
4
|
+
|
|
5
|
+
历史:本模块之前还托管了 ``bind_template`` / ``load_template`` / ``extract_props``
|
|
6
|
+
等 DSL→TemplateBinding 接口;那条路径已被 ``scripts/match_template.py`` 接管
|
|
7
|
+
(由 render-video 直接 import),留在这里的副本无任何外部调用方且与
|
|
8
|
+
match_template 的算法已经漂移。删除这些死接口后,本文件只保留 ``sync_registry``
|
|
9
|
+
所需的最小依赖链 (``_load_registry`` → ``load_all_templates`` → ``build_registry``)。
|
|
10
|
+
|
|
11
|
+
monorepo 路径解析统一走 ``scripts/template_paths``,避免本文件再自己数 ``..``。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
# template_paths 住在 ../../scripts/,把它挂上 sys.path 后才能 import。
|
|
22
|
+
_SCRIPTS_DIR = os.path.abspath(
|
|
23
|
+
os.path.join(os.path.dirname(__file__), "..", "..", "scripts")
|
|
24
|
+
)
|
|
25
|
+
if _SCRIPTS_DIR not in sys.path:
|
|
26
|
+
sys.path.insert(0, _SCRIPTS_DIR)
|
|
27
|
+
|
|
28
|
+
from template_paths import monorepo_registry_path # noqa: E402
|
|
29
|
+
|
|
30
|
+
_registry_cache: Optional[dict] = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_registry(registry_path: Optional[str] = None) -> dict:
|
|
34
|
+
"""Load and cache the template registry from ``@ab-templates/metadata``.
|
|
35
|
+
|
|
36
|
+
File-only: this helper never goes to HTTP (that's ``registry_loader`` 的
|
|
37
|
+
职责);``sync_registry`` 的输出只是给 LLM prompt 用,对一致性要求弱,所以
|
|
38
|
+
本地读盘即可。
|
|
39
|
+
"""
|
|
40
|
+
global _registry_cache
|
|
41
|
+
if _registry_cache is not None:
|
|
42
|
+
return _registry_cache
|
|
43
|
+
|
|
44
|
+
path = registry_path or monorepo_registry_path()
|
|
45
|
+
if not os.path.isfile(path):
|
|
46
|
+
return {"templates": []}
|
|
47
|
+
|
|
48
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
49
|
+
_registry_cache = json.load(f)
|
|
50
|
+
return _registry_cache
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_all_templates(registry_path: Optional[str] = None) -> list[dict]:
|
|
54
|
+
"""Return every template definition from the metadata registry."""
|
|
55
|
+
registry = _load_registry(registry_path)
|
|
56
|
+
return registry.get("templates", [])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_registry(registry_path: Optional[str] = None) -> dict:
|
|
60
|
+
"""Return the registry in the legacy summary format (for LLM prompts etc.)."""
|
|
61
|
+
templates = load_all_templates(registry_path)
|
|
62
|
+
matching_fields = [
|
|
63
|
+
"templateId", "name", "description", "supportedAspectRatios",
|
|
64
|
+
"supportedDurations", "styleTags", "scenePatterns", "assetRequirements",
|
|
65
|
+
]
|
|
66
|
+
summary = []
|
|
67
|
+
for tpl in templates:
|
|
68
|
+
summary.append({k: tpl[k] for k in matching_fields if k in tpl})
|
|
69
|
+
return {
|
|
70
|
+
"version": "v1alpha1",
|
|
71
|
+
"description": "视频模板注册表 - 来自 @ab-templates/metadata",
|
|
72
|
+
"templates": summary,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def sync_registry(output_path: Optional[str] = None, registry_path: Optional[str] = None) -> str:
|
|
77
|
+
"""Write a local registry-cache.json from ``@ab-templates/metadata``.
|
|
78
|
+
|
|
79
|
+
Returns the absolute path of the written file.
|
|
80
|
+
"""
|
|
81
|
+
registry = build_registry(registry_path)
|
|
82
|
+
out = output_path or os.path.join(os.path.dirname(__file__), "..", "registry-cache.json")
|
|
83
|
+
out = os.path.abspath(out)
|
|
84
|
+
with open(out, "w", encoding="utf-8") as f:
|
|
85
|
+
json.dump(registry, f, ensure_ascii=False, indent=2)
|
|
86
|
+
f.write("\n")
|
|
87
|
+
return out
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""
|
|
2
|
+
时间线编译器 - 将 DSL + TemplateBinding 编译为 Remotion 可消费的 RenderPlan。
|
|
3
|
+
|
|
4
|
+
负责: 帧号计算、字幕切片、转场注入、层级分配。
|
|
5
|
+
|
|
6
|
+
⚠️ 跨 skill 契约:
|
|
7
|
+
`skills/render-video/scripts/render_video.py` 通过 sys.path 注入后直接
|
|
8
|
+
`from video_dsl.runtime.timeline_compiler import (split_subtitle,
|
|
9
|
+
split_subtitle_from_lines, segment_narration)`。
|
|
10
|
+
改动以下公共函数的签名 / 返回结构前,请同步核对 render_video.py 的调用点:
|
|
11
|
+
- split_subtitle
|
|
12
|
+
- split_subtitle_from_lines
|
|
13
|
+
- segment_narration
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
CHARS_PER_SECOND = 5.0
|
|
21
|
+
|
|
22
|
+
_PRIMARY_BREAK = re.compile(r"(?<=[。!?;\n])")
|
|
23
|
+
_SECONDARY_BREAK = re.compile(r"(?<=[,,、::])")
|
|
24
|
+
_PRIMARY_TAIL = re.compile(r"[。!?;\n]\s*$")
|
|
25
|
+
|
|
26
|
+
MAX_SEGMENT_CHARS = 16
|
|
27
|
+
MIN_SEGMENT_CHARS = 6
|
|
28
|
+
|
|
29
|
+
# 字幕显示时需要去掉的末尾标点(中英文句末 + 逗号等)
|
|
30
|
+
_TRAILING_PUNCT = re.compile(r"[。!?;,,、::..…]+$")
|
|
31
|
+
# **...** 强调标记(用于计算纯文本长度)
|
|
32
|
+
_EMPHASIS_MARKS = re.compile(r"\*\*")
|
|
33
|
+
|
|
34
|
+
RESOLUTION_MAP = {
|
|
35
|
+
"16:9": {"480p": (854, 480), "720p": (1280, 720), "1080p": (1920, 1080), "4k": (3840, 2160)},
|
|
36
|
+
"9:16": {"480p": (480, 854), "720p": (720, 1280), "1080p": (1080, 1920), "4k": (2160, 3840)},
|
|
37
|
+
"1:1": {"480p": (480, 480), "720p": (720, 720), "1080p": (1080, 1080), "4k": (2160, 2160)},
|
|
38
|
+
"4:3": {"480p": (640, 480), "720p": (960, 720), "1080p": (1440, 1080), "4k": (2880, 2160)},
|
|
39
|
+
"3:4": {"480p": (480, 640), "720p": (720, 960), "1080p": (1080, 1440), "4k": (2160, 2880)},
|
|
40
|
+
"21:9": {"720p": (1720, 720), "1080p": (2560, 1080), "4k": (5120, 2160)},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _now_iso() -> str:
|
|
45
|
+
return datetime.now(timezone.utc).isoformat()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_dimensions(ratio: str, resolution: str) -> tuple[int, int]:
|
|
49
|
+
dims = RESOLUTION_MAP.get(ratio, RESOLUTION_MAP["16:9"])
|
|
50
|
+
return dims.get(resolution, dims.get("1080p", (1920, 1080)))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _split_at_spaces(text: str, max_len: int) -> list[str]:
|
|
54
|
+
"""Split long text at space boundaries to protect English words."""
|
|
55
|
+
if len(text) <= max_len:
|
|
56
|
+
return [text]
|
|
57
|
+
parts: list[str] = []
|
|
58
|
+
current = ""
|
|
59
|
+
for word in text.split(" "):
|
|
60
|
+
candidate = f"{current} {word}" if current else word
|
|
61
|
+
if len(candidate) > max_len and current:
|
|
62
|
+
parts.append(current)
|
|
63
|
+
current = word
|
|
64
|
+
else:
|
|
65
|
+
current = candidate
|
|
66
|
+
if current:
|
|
67
|
+
parts.append(current)
|
|
68
|
+
return parts
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _merge_short(segments: list[str], min_len: int) -> list[str]:
|
|
72
|
+
"""Merge fragments shorter than *min_len* into a neighbour.
|
|
73
|
+
|
|
74
|
+
Never merge a short segment backward into a sentence-ending segment
|
|
75
|
+
(ending with 。!?;\\n) to avoid cross-sentence subtitle lines like
|
|
76
|
+
"可复用技能。第三,". Instead, keep it as a new entry so it merges
|
|
77
|
+
forward with the next segment.
|
|
78
|
+
"""
|
|
79
|
+
if len(segments) <= 1:
|
|
80
|
+
return segments
|
|
81
|
+
merged: list[str] = [segments[0]]
|
|
82
|
+
for seg in segments[1:]:
|
|
83
|
+
prev_is_sentence_end = bool(_PRIMARY_TAIL.search(merged[-1]))
|
|
84
|
+
if len(merged[-1]) < min_len:
|
|
85
|
+
# Previous fragment is short — always merge forward into it
|
|
86
|
+
merged[-1] += seg
|
|
87
|
+
elif len(seg) < min_len and not prev_is_sentence_end:
|
|
88
|
+
# Current fragment is short and prev is NOT a sentence end — merge backward
|
|
89
|
+
merged[-1] += seg
|
|
90
|
+
else:
|
|
91
|
+
# Either both are long enough, or seg is short but prev ends a sentence
|
|
92
|
+
merged.append(seg)
|
|
93
|
+
# Handle trailing short segment (avoid merging across sentence boundary)
|
|
94
|
+
if len(merged) > 1 and len(merged[-1]) < min_len:
|
|
95
|
+
if not _PRIMARY_TAIL.search(merged[-2]):
|
|
96
|
+
merged[-2] += merged.pop()
|
|
97
|
+
return merged
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _plain_len(text: str) -> int:
|
|
101
|
+
"""计算去掉 **...** 标记后的纯文本字符数,用于更准确的时间分配。"""
|
|
102
|
+
return len(_EMPHASIS_MARKS.sub("", text))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _strip_trailing_punct(text: str) -> str:
|
|
106
|
+
"""去掉字幕段末尾的标点符号(中英文句末 + 逗号等),让画面更干净。"""
|
|
107
|
+
return _TRAILING_PUNCT.sub("", text)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def segment_narration(text: str) -> list[str]:
|
|
111
|
+
"""Split narration text into subtitle-display-sized segments (text only).
|
|
112
|
+
|
|
113
|
+
Use before TTS: ``"\\n".join(segment_narration(text))`` sends to Minimax
|
|
114
|
+
so it returns per-segment timestamps.
|
|
115
|
+
"""
|
|
116
|
+
if not text:
|
|
117
|
+
return []
|
|
118
|
+
raw = _PRIMARY_BREAK.split(text.strip())
|
|
119
|
+
raw = [s for s in raw if s.strip()]
|
|
120
|
+
segments: list[str] = []
|
|
121
|
+
for chunk in raw:
|
|
122
|
+
if len(chunk) <= MAX_SEGMENT_CHARS:
|
|
123
|
+
segments.append(chunk)
|
|
124
|
+
continue
|
|
125
|
+
sub = _SECONDARY_BREAK.split(chunk)
|
|
126
|
+
sub = [s for s in sub if s.strip()]
|
|
127
|
+
for part in sub:
|
|
128
|
+
if len(part) <= MAX_SEGMENT_CHARS:
|
|
129
|
+
segments.append(part)
|
|
130
|
+
else:
|
|
131
|
+
segments.extend(_split_at_spaces(part, MAX_SEGMENT_CHARS))
|
|
132
|
+
return _merge_short(segments, MIN_SEGMENT_CHARS)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def split_subtitle_from_lines(
|
|
136
|
+
lines: list[str], duration_frames: int, fps: int, padding_frames: int = 10
|
|
137
|
+
) -> list[dict]:
|
|
138
|
+
"""Fallback subtitle timing for pre-split narration lines.
|
|
139
|
+
|
|
140
|
+
Preserves exactly ``len(lines)`` segments (no secondary comma-split,
|
|
141
|
+
no short-fragment merging). Used when narration was authored as
|
|
142
|
+
``{intro, items, outro}`` and TTS timestamps are unavailable.
|
|
143
|
+
|
|
144
|
+
末尾标点会被去掉(画面更干净),时间分配按纯文本长度(去掉 ** 标记)。
|
|
145
|
+
"""
|
|
146
|
+
segments = [s.strip() for s in lines if s and s.strip()]
|
|
147
|
+
if not segments:
|
|
148
|
+
return []
|
|
149
|
+
|
|
150
|
+
total_chars = max(sum(_plain_len(s) for s in segments), 1)
|
|
151
|
+
estimated_audio_frames = int(total_chars / CHARS_PER_SECOND * fps)
|
|
152
|
+
usable_frames = min(
|
|
153
|
+
max(duration_frames - 2 * padding_frames, fps),
|
|
154
|
+
max(estimated_audio_frames, fps),
|
|
155
|
+
)
|
|
156
|
+
result: list[dict] = []
|
|
157
|
+
current_frame = padding_frames
|
|
158
|
+
for seg in segments:
|
|
159
|
+
ratio = _plain_len(seg) / total_chars
|
|
160
|
+
frames = max(int(usable_frames * ratio), fps // 2)
|
|
161
|
+
result.append({
|
|
162
|
+
"text": _strip_trailing_punct(seg.strip()),
|
|
163
|
+
"startFrame": current_frame,
|
|
164
|
+
"endFrame": current_frame + frames,
|
|
165
|
+
})
|
|
166
|
+
current_frame += frames
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def split_subtitle(text: str, duration_frames: int, fps: int, padding_frames: int = 10) -> list[dict]:
|
|
171
|
+
"""Split narration text into subtitle segments aligned to frames.
|
|
172
|
+
|
|
173
|
+
Fallback path used when real TTS timestamps are not available.
|
|
174
|
+
末尾标点会被去掉(画面更干净),时间分配按纯文本长度(去掉 ** 标记)。
|
|
175
|
+
"""
|
|
176
|
+
segments = segment_narration(text)
|
|
177
|
+
if not segments:
|
|
178
|
+
return []
|
|
179
|
+
|
|
180
|
+
total_plain = max(sum(_plain_len(s) for s in segments), 1)
|
|
181
|
+
estimated_audio_frames = int(total_plain / CHARS_PER_SECOND * fps)
|
|
182
|
+
usable_frames = min(
|
|
183
|
+
max(duration_frames - 2 * padding_frames, fps),
|
|
184
|
+
max(estimated_audio_frames, fps),
|
|
185
|
+
)
|
|
186
|
+
result: list[dict] = []
|
|
187
|
+
current_frame = padding_frames
|
|
188
|
+
for seg in segments:
|
|
189
|
+
ratio = _plain_len(seg) / total_plain
|
|
190
|
+
frames = max(int(usable_frames * ratio), fps // 2)
|
|
191
|
+
result.append({
|
|
192
|
+
"text": _strip_trailing_punct(seg.strip()),
|
|
193
|
+
"startFrame": current_frame,
|
|
194
|
+
"endFrame": current_frame + frames,
|
|
195
|
+
})
|
|
196
|
+
current_frame += frames
|
|
197
|
+
|
|
198
|
+
return result
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _resolve_root_composition(binding: dict, ratio: str) -> str:
|
|
202
|
+
"""Pick Remotion root Composition ID from template's remotionEntry by aspect ratio."""
|
|
203
|
+
entry_map = binding.get("remotionEntry", {})
|
|
204
|
+
if entry_map:
|
|
205
|
+
return entry_map.get(ratio, next(iter(entry_map.values()), "MainVideo"))
|
|
206
|
+
return binding.get("templateId", "MainVideo")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def compile_timeline(dsl: dict, binding: dict) -> dict:
|
|
210
|
+
"""Compile DSL + TemplateBinding into a full RenderPlan."""
|
|
211
|
+
fps = dsl.get("global", {}).get("fps", 30)
|
|
212
|
+
ratio = dsl.get("global", {}).get("aspectRatio", "16:9")
|
|
213
|
+
resolution = dsl.get("global", {}).get("resolution", "1080p")
|
|
214
|
+
width, height = resolve_dimensions(ratio, resolution)
|
|
215
|
+
|
|
216
|
+
binding_map = {}
|
|
217
|
+
for b in binding.get("bindings", []):
|
|
218
|
+
binding_map[b["sceneId"]] = b
|
|
219
|
+
|
|
220
|
+
assets_pool = {}
|
|
221
|
+
for a in dsl.get("assets", []):
|
|
222
|
+
status = a.get("status", "planned")
|
|
223
|
+
if status in ("planned", "missing"):
|
|
224
|
+
status = "pending"
|
|
225
|
+
assets_pool[a["assetId"]] = {
|
|
226
|
+
"assetId": a["assetId"],
|
|
227
|
+
"type": a.get("type", ""),
|
|
228
|
+
"source": a.get("source", "existing"),
|
|
229
|
+
"status": status,
|
|
230
|
+
"url": a.get("url", ""),
|
|
231
|
+
"localPath": a.get("localPath", ""),
|
|
232
|
+
"duration": None,
|
|
233
|
+
"width": None,
|
|
234
|
+
"height": None,
|
|
235
|
+
"mimeType": "",
|
|
236
|
+
"generatedBy": {},
|
|
237
|
+
"retryCount": 0,
|
|
238
|
+
"maxRetries": 3,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
timeline = []
|
|
242
|
+
current_frame = 0
|
|
243
|
+
trans_cfg = dsl.get("transitions", {})
|
|
244
|
+
default_trans = trans_cfg.get("default", "fade")
|
|
245
|
+
default_trans_dur = int(trans_cfg.get("duration", 0.5) * fps)
|
|
246
|
+
|
|
247
|
+
for scene in dsl.get("scenes", []):
|
|
248
|
+
scene_id = scene["id"]
|
|
249
|
+
duration = scene.get("duration", 5)
|
|
250
|
+
dur_frames = int(duration * fps)
|
|
251
|
+
|
|
252
|
+
scene_bind = binding_map.get(scene_id, {})
|
|
253
|
+
|
|
254
|
+
# P2.3: 不再产出 entry.layers——背景视觉资产、旁白音频、文本图层全部
|
|
255
|
+
# 通过 propExtractors 写入 binding.props(backgroundAssetId / narrationAssetId
|
|
256
|
+
# / titleText 等),渲染端用 props 直接消费。
|
|
257
|
+
|
|
258
|
+
narration = scene.get("audio", {}).get("narration", {})
|
|
259
|
+
|
|
260
|
+
narration_text = narration.get("text", "")
|
|
261
|
+
sub_segs = split_subtitle(narration_text, dur_frames, fps)
|
|
262
|
+
|
|
263
|
+
entry = {
|
|
264
|
+
"sceneId": scene_id,
|
|
265
|
+
"startFrame": current_frame,
|
|
266
|
+
"endFrame": current_frame + dur_frames,
|
|
267
|
+
"durationFrames": dur_frames,
|
|
268
|
+
"startTime": round(current_frame / fps, 2),
|
|
269
|
+
"endTime": round((current_frame + dur_frames) / fps, 2),
|
|
270
|
+
"compositionId": scene_bind.get("compositionId", "GenericScene"),
|
|
271
|
+
"props": scene_bind.get("props", {}),
|
|
272
|
+
"subtitleSegments": sub_segs,
|
|
273
|
+
"transition": {"type": default_trans, "durationFrames": default_trans_dur},
|
|
274
|
+
}
|
|
275
|
+
timeline.append(entry)
|
|
276
|
+
current_frame += dur_frames
|
|
277
|
+
|
|
278
|
+
render_plan = {
|
|
279
|
+
"version": "v1alpha1",
|
|
280
|
+
"createdAt": _now_iso(),
|
|
281
|
+
"status": "planning",
|
|
282
|
+
"templateId": binding.get("templateId", ""),
|
|
283
|
+
"resolvedAssets": list(assets_pool.values()),
|
|
284
|
+
"timeline": timeline,
|
|
285
|
+
"renderConfig": {
|
|
286
|
+
"width": width,
|
|
287
|
+
"height": height,
|
|
288
|
+
"fps": fps,
|
|
289
|
+
"totalFrames": current_frame,
|
|
290
|
+
"totalDuration": round(current_frame / fps, 2),
|
|
291
|
+
"codec": "h264",
|
|
292
|
+
"crf": 18,
|
|
293
|
+
"outputFormat": "mp4",
|
|
294
|
+
},
|
|
295
|
+
"remotionProps": {
|
|
296
|
+
"compositionId": _resolve_root_composition(binding, ratio),
|
|
297
|
+
"inputProps": {
|
|
298
|
+
"globalTypography": binding.get("globalOverrides", {}).get("typography", {}),
|
|
299
|
+
"motionPreset": binding.get("globalOverrides", {}).get("motionPreset", "smooth"),
|
|
300
|
+
"colorScheme": binding.get("globalOverrides", {}).get("colorScheme", []),
|
|
301
|
+
**({"themeConfig": binding["themeConfig"]} if binding.get("themeConfig") else {}),
|
|
302
|
+
**({"variantId": binding["variantId"]} if binding.get("variantId") else {}),
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
"errors": [],
|
|
306
|
+
"logs": [
|
|
307
|
+
{"phase": "validate", "message": "DSL validation passed", "timestamp": _now_iso()},
|
|
308
|
+
{"phase": "compile", "message": f"Compiled {len(timeline)} scenes, {current_frame} total frames", "timestamp": _now_iso()},
|
|
309
|
+
],
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return render_plan
|