@remixmate/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +166 -0
  3. package/dist/argv.d.ts +15 -0
  4. package/dist/argv.js +51 -0
  5. package/dist/cli.d.ts +13 -0
  6. package/dist/cli.js +83 -0
  7. package/dist/handlers/gen-image.d.ts +13 -0
  8. package/dist/handlers/gen-image.js +161 -0
  9. package/dist/handlers/gen-voice.d.ts +16 -0
  10. package/dist/handlers/gen-voice.js +110 -0
  11. package/dist/handlers/index.d.ts +13 -0
  12. package/dist/handlers/index.js +14 -0
  13. package/dist/handlers/template-bind.d.ts +15 -0
  14. package/dist/handlers/template-bind.js +149 -0
  15. package/dist/http.d.ts +52 -0
  16. package/dist/http.js +110 -0
  17. package/dist/index.d.ts +9 -0
  18. package/dist/index.js +8 -0
  19. package/dist/manifest.json +260 -0
  20. package/dist/progress.d.ts +23 -0
  21. package/dist/progress.js +15 -0
  22. package/dist/registry.d.ts +33 -0
  23. package/dist/registry.js +72 -0
  24. package/dist/runner.d.ts +17 -0
  25. package/dist/runner.js +63 -0
  26. package/package.json +54 -0
  27. package/skills/export-jianying/SKILL.md +251 -0
  28. package/skills/export-jianying/scripts/gen_jianying_draft.py +1099 -0
  29. package/skills/export-jianying/skill.json +24 -0
  30. package/skills/export-jianying/version.json +7 -0
  31. package/skills/gen-digital-human/SKILL.md +211 -0
  32. package/skills/gen-digital-human/scripts/gen_digital_human_video.py +574 -0
  33. package/skills/gen-digital-human/skill.json +23 -0
  34. package/skills/gen-digital-human/version.json +7 -0
  35. package/skills/gen-image/SKILL.md +130 -0
  36. package/skills/gen-image/skill.json +22 -0
  37. package/skills/gen-image/version.json +7 -0
  38. package/skills/gen-script/SKILL.md +283 -0
  39. package/skills/gen-script/scripts/gen_script.py +1208 -0
  40. package/skills/gen-script/skill.json +41 -0
  41. package/skills/gen-script/version.json +7 -0
  42. package/skills/gen-video/SKILL.md +170 -0
  43. package/skills/gen-video/scripts/gen_video.py +591 -0
  44. package/skills/gen-video/skill.json +24 -0
  45. package/skills/gen-video/version.json +7 -0
  46. package/skills/gen-voice/SKILL.md +104 -0
  47. package/skills/gen-voice/skill.json +21 -0
  48. package/skills/gen-voice/version.json +7 -0
  49. package/skills/prepare-video-assets/SKILL.md +192 -0
  50. package/skills/prepare-video-assets/scripts/prepare_video_assets.py +57 -0
  51. package/skills/prepare-video-assets/skill.json +23 -0
  52. package/skills/prepare-video-assets/version.json +7 -0
  53. package/skills/render-video/SKILL.md +187 -0
  54. package/skills/render-video/scripts/_chrome_vendor.py +305 -0
  55. package/skills/render-video/scripts/_video_probe.py +174 -0
  56. package/skills/render-video/scripts/_vod_polling.py +86 -0
  57. package/skills/render-video/scripts/remote_renderer_client.py +236 -0
  58. package/skills/render-video/scripts/render_video.py +2288 -0
  59. package/skills/render-video/scripts/upload_video.py +193 -0
  60. package/skills/render-video/skill.json +21 -0
  61. package/skills/render-video/version.json +7 -0
  62. package/skills/template-bind/README.md +63 -0
  63. package/skills/template-bind/SKILL.md +234 -0
  64. package/skills/template-bind/scripts/check_contracts.py +196 -0
  65. package/skills/template-bind/scripts/match_template.py +215 -0
  66. package/skills/template-bind/scripts/registry_loader.py +379 -0
  67. package/skills/template-bind/scripts/render_job_client.py +162 -0
  68. package/skills/template-bind/scripts/sync_registry.py +44 -0
  69. package/skills/template-bind/scripts/template_paths.py +72 -0
  70. package/skills/template-bind/skill.json +20 -0
  71. package/skills/template-bind/version.json +7 -0
  72. package/skills/template-bind/video_dsl/README.md +97 -0
  73. package/skills/template-bind/video_dsl/runtime/__init__.py +11 -0
  74. package/skills/template-bind/video_dsl/runtime/dsl_validator.py +495 -0
  75. package/skills/template-bind/video_dsl/runtime/prompt_enhancer.py +274 -0
  76. package/skills/template-bind/video_dsl/runtime/template_binder.py +87 -0
  77. package/skills/template-bind/video_dsl/runtime/timeline_compiler.py +312 -0
  78. package/skills/template-bind/video_dsl/schema/render-plan-v1alpha1.json +193 -0
  79. package/skills/template-bind/video_dsl/schema/template-binding-v1alpha1.json +122 -0
  80. package/skills/template-bind/video_dsl/schema/template-definition-v1alpha1.json +247 -0
  81. package/skills/template-bind/video_dsl/schema/video-dsl-v1alpha1.json +378 -0
  82. package/skills/video-parser/SKILL.md +238 -0
  83. package/skills/video-parser/scripts/analyze_video.py +1038 -0
  84. package/skills/video-parser/scripts/deconstruct_video.py +602 -0
  85. package/skills/video-parser/scripts/parse_via_render.py +148 -0
  86. package/skills/video-parser/skill.json +21 -0
  87. package/skills/video-parser/version.json +7 -0
  88. package/skills/web-capture/SKILL.md +183 -0
  89. package/skills/web-capture/scripts/web_capture.py +461 -0
  90. package/skills/web-capture/skill.json +29 -0
  91. package/skills/web-capture/version.json +7 -0
@@ -0,0 +1,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