@remixmate/cli 0.9.44 → 0.9.46
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/dist/manifest.json +14 -4
- package/package.json +1 -1
- package/skills/doc-parse/SKILL.md +35 -5
- package/skills/doc-parse/scripts/extract_via_render.py +54 -157
- package/skills/gen-script/SKILL.md +31 -0
- package/skills/gen-script/scripts/gen_script.py +147 -7
- package/skills/gen-script/skill.json +6 -1
- package/skills/gen-script/version.json +1 -1
- package/skills/prepare-video-assets/skill.json +4 -0
- package/skills/prepare-video-assets/version.json +1 -1
- package/skills/render-video/scripts/render_video.py +118 -3
- package/skills/render-video/version.json +1 -1
- package/skills/template-registry/scripts/doc_extract.py +226 -0
- package/skills/template-registry/scripts/payload_hydrator.py +315 -0
- package/skills/template-registry/version.json +1 -1
- package/skills/template-registry/video_dsl/runtime/dsl_validator.py +136 -3
- package/skills/template-registry/video_dsl/runtime/timeline_compiler.py +51 -2
|
@@ -29,6 +29,35 @@ VALID_RATIOS = {"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"}
|
|
|
29
29
|
VALID_RESOLUTIONS = {"480p", "720p", "1080p", "4k"}
|
|
30
30
|
VALID_PURPOSES = {"opening", "intro", "point", "example", "explanation", "transition", "highlight", "cta", "ending"}
|
|
31
31
|
VALID_LAYOUTS = {"full-visual", "split-left-right", "split-top-bottom", "picture-in-picture", "text-overlay", "avatar-with-bg", "kenburns"}
|
|
32
|
+
|
|
33
|
+
# 场景数上限的兜底值。叙事弧类模板的合理上界——20 段以上的短视频必然过碎。
|
|
34
|
+
# "一页素材一个场景"的模板(ppt-to-video)场景数由源文件页数决定,应当用
|
|
35
|
+
# ``capabilities.maxScenes`` 抬高;这个常量只是没声明时的默认。
|
|
36
|
+
DEFAULT_MAX_SCENES = 20
|
|
37
|
+
|
|
38
|
+
# 声明式上界的上界。模板可以抬高 maxScenes,但不能抬到无限——一次请求排上千个场景
|
|
39
|
+
# 会在下游(TTS 逐场景合成、Remotion 逐场景渲染)变成一次谁也杀不掉的长任务。
|
|
40
|
+
MAX_SCENES_CEILING = 200
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def max_scenes_for_template(template_config: Optional[dict]) -> int:
|
|
44
|
+
"""模板声明的场景数上限,缺省 ``DEFAULT_MAX_SCENES``。
|
|
45
|
+
|
|
46
|
+
单一实现:gen-script 规划场景时用它决定 clamp 到几,dsl_validator 用它做门禁。
|
|
47
|
+
两边各写一份 ``min(n, 20)`` 正是这个仓库反复栽的那种跟头。
|
|
48
|
+
|
|
49
|
+
宽容读取:非正整数、非数字、capabilities 缺失一律回落到默认值,不抛异常——
|
|
50
|
+
一个写坏的 template.json 不该让所有模板的场景规划一起失败。
|
|
51
|
+
"""
|
|
52
|
+
caps = (template_config or {}).get("capabilities")
|
|
53
|
+
if not isinstance(caps, dict):
|
|
54
|
+
return DEFAULT_MAX_SCENES
|
|
55
|
+
declared = caps.get("maxScenes")
|
|
56
|
+
if isinstance(declared, bool) or not isinstance(declared, int):
|
|
57
|
+
return DEFAULT_MAX_SCENES
|
|
58
|
+
if declared < 1:
|
|
59
|
+
return DEFAULT_MAX_SCENES
|
|
60
|
+
return min(declared, MAX_SCENES_CEILING)
|
|
32
61
|
VALID_ASSET_TYPES = {"image", "video", "audio", "avatar", "subtitle", "bgm"}
|
|
33
62
|
VALID_ASSET_SOURCES = {"existing", "gen-image", "gen-video", "gen-voice", "gen-digital-human"}
|
|
34
63
|
|
|
@@ -245,14 +274,30 @@ def _check_scenes_min(dsl: dict) -> list[ValidationError]:
|
|
|
245
274
|
|
|
246
275
|
|
|
247
276
|
def _check_scenes_max(dsl: dict) -> list[ValidationError]:
|
|
248
|
-
"""Scenes
|
|
277
|
+
"""Scenes 上限。仅 gen-script 历史规则集 + 完整版用——render-video
|
|
249
278
|
历史不查上限,合并时若同时启用此检查会"原本能过的 DSL 现在被拒",
|
|
250
279
|
故只在 ``validate_structural`` / ``validate_dsl`` 启用。
|
|
280
|
+
|
|
281
|
+
上限由模板的 ``capabilities.maxScenes`` 声明,缺省 ``DEFAULT_MAX_SCENES``(20)。
|
|
282
|
+
20 这个数字是给"叙事弧"类模板定的——一条 60 秒短视频排到 20 段已经过碎。但
|
|
283
|
+
"一页素材一个场景"的模板(ppt-to-video 逐页讲解)的场景数由源文件页数决定,
|
|
284
|
+
跟叙事节奏无关:2026-09-23 线上一份 23 页 PPT 就是在这里被砍到 20 页,
|
|
285
|
+
而且砍得毫无声响,直到下游报 ``customPayload.slide (required)`` ×20 才暴露。
|
|
286
|
+
所以上限改成模板自己声明,并且这里的报错要说清它是谁定的。
|
|
251
287
|
"""
|
|
252
288
|
scenes = dsl.get("scenes", [])
|
|
253
|
-
if isinstance(scenes, list)
|
|
289
|
+
if not isinstance(scenes, list):
|
|
290
|
+
return []
|
|
291
|
+
template_id = _pick_template_id(dsl)
|
|
292
|
+
limit = max_scenes_for_template(_get_template_cfg(template_id))
|
|
293
|
+
if len(scenes) > limit:
|
|
294
|
+
source = (
|
|
295
|
+
f"template '{template_id}' declares capabilities.maxScenes={limit}"
|
|
296
|
+
if limit != DEFAULT_MAX_SCENES
|
|
297
|
+
else f"the default limit is {limit}; a template can raise it via capabilities.maxScenes"
|
|
298
|
+
)
|
|
254
299
|
return [ValidationError(
|
|
255
|
-
"scenes", f"must contain at most
|
|
300
|
+
"scenes", f"must contain at most {limit} scenes, got {len(scenes)} ({source})",
|
|
256
301
|
)]
|
|
257
302
|
return []
|
|
258
303
|
|
|
@@ -684,6 +729,90 @@ def _check_line_sync_narration(dsl: dict) -> list[ValidationError]:
|
|
|
684
729
|
return errors
|
|
685
730
|
|
|
686
731
|
|
|
732
|
+
def _check_step_narration_alignment(dsl: dict) -> list[ValidationError]:
|
|
733
|
+
"""声明「动作与旁白一一对应」的模板:动作条数应当等于旁白句数。
|
|
734
|
+
|
|
735
|
+
由模板的 ``capabilities.stepAlignment`` 声明触发,不写死模板名::
|
|
736
|
+
|
|
737
|
+
"stepAlignment": {
|
|
738
|
+
"payloadField": "steps", # customPayload 里放动作数组的字段
|
|
739
|
+
"alignTo": "narration-sentences"
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
为什么值得单独查:这一类失配**渲得出来**。动作按句下标对齐,条数不等时多出的
|
|
743
|
+
句子没有任何动作(画面停住)或多出的动作挤在最后一句上,而退出码是 0、
|
|
744
|
+
renderJob 是 completed、漏斗记 rendered —— 缺陷只存在于成片里,没有任何
|
|
745
|
+
自动信号会告状。2026-09-23 线上那条鸡兔同笼就是这么交付的。
|
|
746
|
+
|
|
747
|
+
严重度是 **warning**:步数少于句数有时确实是作者的本意(末尾一句总结不配动作)。
|
|
748
|
+
真正要判断的是"作者以为一一对应、实际不是",只有作者做得了这个判断,所以这里
|
|
749
|
+
给两个数字,不给结论。但它必须被打出来 —— 上一版连数字都没有。
|
|
750
|
+
"""
|
|
751
|
+
tpl = _get_template_cfg(_pick_template_id(dsl))
|
|
752
|
+
if not tpl:
|
|
753
|
+
return []
|
|
754
|
+
caps = tpl.get("capabilities")
|
|
755
|
+
spec = caps.get("stepAlignment") if isinstance(caps, dict) else None
|
|
756
|
+
if not isinstance(spec, dict):
|
|
757
|
+
return []
|
|
758
|
+
field = spec.get("payloadField")
|
|
759
|
+
if not isinstance(field, str) or not field:
|
|
760
|
+
return []
|
|
761
|
+
if spec.get("alignTo") not in (None, "narration-sentences"):
|
|
762
|
+
return []
|
|
763
|
+
|
|
764
|
+
try:
|
|
765
|
+
from .timeline_compiler import count_narration_sentences
|
|
766
|
+
except ImportError:
|
|
767
|
+
return []
|
|
768
|
+
|
|
769
|
+
errors: list[ValidationError] = []
|
|
770
|
+
for idx, scene in enumerate(dsl.get("scenes", []) or []):
|
|
771
|
+
custom = scene.get("customPayload")
|
|
772
|
+
if not isinstance(custom, dict):
|
|
773
|
+
continue
|
|
774
|
+
steps = custom.get(field)
|
|
775
|
+
if not isinstance(steps, list) or not steps:
|
|
776
|
+
continue # 不给 steps 走模板自己的兜底,不是本检查的事
|
|
777
|
+
|
|
778
|
+
# 全部显式写了 atSec 的步骤不参与下标对齐,不该算失配。
|
|
779
|
+
# 排除 bool:Python 里 True 是 int 的实例,而渲染端用 `typeof === "number"`
|
|
780
|
+
# 判断,不认 true。两边口径必须一致,否则这里放行的东西那边仍按下标对齐。
|
|
781
|
+
def _has_explicit_at_sec(step: object) -> bool:
|
|
782
|
+
if not isinstance(step, dict):
|
|
783
|
+
return False
|
|
784
|
+
at = step.get("atSec")
|
|
785
|
+
return isinstance(at, (int, float)) and not isinstance(at, bool)
|
|
786
|
+
|
|
787
|
+
if all(_has_explicit_at_sec(s) for s in steps):
|
|
788
|
+
continue
|
|
789
|
+
|
|
790
|
+
narration = (scene.get("audio") or {}).get("narration") or {}
|
|
791
|
+
text = narration.get("text")
|
|
792
|
+
if not isinstance(text, str) or not text.strip():
|
|
793
|
+
continue
|
|
794
|
+
sentences = count_narration_sentences(text)
|
|
795
|
+
if sentences == 0 or len(steps) == sentences:
|
|
796
|
+
continue
|
|
797
|
+
|
|
798
|
+
sid = scene.get("id", f"scenes[{idx}]")
|
|
799
|
+
tail = (
|
|
800
|
+
f"最后 {sentences - len(steps)} 句旁白没有对应动作,画面会在那之前走完并停住"
|
|
801
|
+
if len(steps) < sentences
|
|
802
|
+
else f"多出的 {len(steps) - sentences} 个动作会挤在最后一句上"
|
|
803
|
+
)
|
|
804
|
+
errors.append(ValidationError(
|
|
805
|
+
f"scenes[{idx}].customPayload.{field}",
|
|
806
|
+
f"scene '{sid}': {len(steps)} 个动作 vs 旁白 {sentences} 句 —— {tail}。"
|
|
807
|
+
f"本模板的动作按**句**对齐(第 i 个动作 = 第 i 句的起拍帧),"
|
|
808
|
+
f"要一一对应就让两者等数;确实想不等就忽略这条(它不阻断渲染)。"
|
|
809
|
+
f"注意句数按句末标点(。!?;)统计,一句里放两个句末标点会被数成两句。",
|
|
810
|
+
severity="warning",
|
|
811
|
+
))
|
|
812
|
+
|
|
813
|
+
return errors
|
|
814
|
+
|
|
815
|
+
|
|
687
816
|
def _check_slide_payload_fields(dsl: dict) -> list[ValidationError]:
|
|
688
817
|
"""逐版式校验 ``customPayload.templateData`` 的字段名。
|
|
689
818
|
|
|
@@ -1055,6 +1184,9 @@ def validate_integrity(dsl: dict) -> list[ValidationError]:
|
|
|
1055
1184
|
errors += _check_slide_payload_fields(dsl)
|
|
1056
1185
|
errors += _check_custom_payload_fields(dsl)
|
|
1057
1186
|
errors += _check_line_sync_narration(dsl)
|
|
1187
|
+
# warning,不阻断。放在 integrity 里是因为这是 render-video 的入口:失配的后果
|
|
1188
|
+
# 是**渲得出来但错位**,只有在真要渲之前说才来得及。
|
|
1189
|
+
errors += _check_step_narration_alignment(dsl)
|
|
1058
1190
|
errors += _check_slide_cross_field(dsl)
|
|
1059
1191
|
return errors
|
|
1060
1192
|
|
|
@@ -1088,5 +1220,6 @@ def validate_dsl(dsl: dict) -> list[ValidationError]:
|
|
|
1088
1220
|
errors += _check_slide_payload_fields(dsl)
|
|
1089
1221
|
errors += _check_custom_payload_fields(dsl)
|
|
1090
1222
|
errors += _check_line_sync_narration(dsl)
|
|
1223
|
+
errors += _check_step_narration_alignment(dsl)
|
|
1091
1224
|
errors += _check_slide_cross_field(dsl)
|
|
1092
1225
|
return errors
|
|
@@ -171,6 +171,49 @@ def _merge_short(segments: list[str], min_len: int) -> list[str]:
|
|
|
171
171
|
return merged
|
|
172
172
|
|
|
173
173
|
|
|
174
|
+
def assign_sentence_indices(raw_texts: list[str]) -> list[int]:
|
|
175
|
+
"""给每个字幕/TTS 段标注它属于原旁白的第几**句**(0-based)。
|
|
176
|
+
|
|
177
|
+
为什么必须单独标:一段 **不等于** 一句。``segment_narration`` 在句末断行之后,
|
|
178
|
+
还会把超过 ``MAX_SEGMENT_CHARS`` 的长句在逗号处继续切 —— 那是为了让 TTS 按真实
|
|
179
|
+
停顿返回时间戳,跟"句"没关系。于是 35 字的一句话会变成 3 段。
|
|
180
|
+
|
|
181
|
+
靠下标把「第 i 个动作」对齐到「第 i 段字幕」的模板(math-principles-expl 的
|
|
182
|
+
``steps``、ppt-to-video 的 ``beats``)因此会整体错位,而且是**静默**的:
|
|
183
|
+
2026-09-23 线上一条鸡兔同笼成片里 10 句旁白被切成约 19 段,板面 10 步在音频
|
|
184
|
+
播到一半就走完,之后画面冻结、旁白继续,全程零报错、终态 completed。
|
|
185
|
+
|
|
186
|
+
判据:一段是某句的**末段**,当且仅当它以句末标点(``。!?;`` 或换行)结尾。
|
|
187
|
+
这与 ``_PRIMARY_BREAK`` 是同一个集合 —— 切的时候用它断,标的时候用它认,
|
|
188
|
+
所以两边不会漂移。
|
|
189
|
+
|
|
190
|
+
⚠️ 必须传**未去尾标点**的原文。``_strip_trailing_punct`` 之后句末标点就没了,
|
|
191
|
+
那时所有段都会被认成同一句。
|
|
192
|
+
|
|
193
|
+
极少数情况下一段会跨句:``_merge_short`` 为了不让 TTS 行短到丢语气,会把一个
|
|
194
|
+
过短的段向后并(比如整句只有「对吗?」三个字)。这时该段归**它起始的那一句**,
|
|
195
|
+
因为调用方要的是「这句话从哪一帧开始被说出来」。
|
|
196
|
+
"""
|
|
197
|
+
indices: list[int] = []
|
|
198
|
+
sentence = 0
|
|
199
|
+
for text in raw_texts:
|
|
200
|
+
indices.append(sentence)
|
|
201
|
+
if _PRIMARY_TAIL.search(text or ""):
|
|
202
|
+
sentence += 1
|
|
203
|
+
return indices
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def count_narration_sentences(text: str) -> int:
|
|
207
|
+
"""数一段旁白有多少句。与 ``assign_sentence_indices`` 同一判据。
|
|
208
|
+
|
|
209
|
+
给校验层用:模板声明「steps 与旁白一一对应」时,用它和 steps 条数比对。
|
|
210
|
+
"""
|
|
211
|
+
if not text or not text.strip():
|
|
212
|
+
return 0
|
|
213
|
+
chunks = [s for s in _PRIMARY_BREAK.split(text.strip()) if s.strip()]
|
|
214
|
+
return len(chunks)
|
|
215
|
+
|
|
216
|
+
|
|
174
217
|
def _plain_len(text: str) -> int:
|
|
175
218
|
"""计算去掉 **...** 标记后的纯文本字符数,用于更准确的时间分配。"""
|
|
176
219
|
return len(_EMPHASIS_MARKS.sub("", text))
|
|
@@ -232,15 +275,18 @@ def split_subtitle_from_lines(
|
|
|
232
275
|
max(duration_frames - 2 * padding_frames, fps),
|
|
233
276
|
max(estimated_audio_frames, fps),
|
|
234
277
|
)
|
|
278
|
+
# 句归属必须在去尾标点**之前**算 —— 去掉「。」之后就认不出句末了。
|
|
279
|
+
sentence_indices = assign_sentence_indices(segments)
|
|
235
280
|
result: list[dict] = []
|
|
236
281
|
current_frame = padding_frames
|
|
237
|
-
for seg in segments:
|
|
282
|
+
for i, seg in enumerate(segments):
|
|
238
283
|
ratio = _plain_len(seg) / total_chars
|
|
239
284
|
frames = max(int(usable_frames * ratio), fps // 2)
|
|
240
285
|
result.append({
|
|
241
286
|
"text": _strip_trailing_punct(seg.strip()),
|
|
242
287
|
"startFrame": current_frame,
|
|
243
288
|
"endFrame": current_frame + frames,
|
|
289
|
+
"sentenceIndex": sentence_indices[i],
|
|
244
290
|
})
|
|
245
291
|
current_frame += frames
|
|
246
292
|
return result
|
|
@@ -262,15 +308,18 @@ def split_subtitle(text: str, duration_frames: int, fps: int, padding_frames: in
|
|
|
262
308
|
max(duration_frames - 2 * padding_frames, fps),
|
|
263
309
|
max(estimated_audio_frames, fps),
|
|
264
310
|
)
|
|
311
|
+
# 句归属必须在去尾标点**之前**算(见 assign_sentence_indices)。
|
|
312
|
+
sentence_indices = assign_sentence_indices(segments)
|
|
265
313
|
result: list[dict] = []
|
|
266
314
|
current_frame = padding_frames
|
|
267
|
-
for seg in segments:
|
|
315
|
+
for i, seg in enumerate(segments):
|
|
268
316
|
ratio = _plain_len(seg) / total_plain
|
|
269
317
|
frames = max(int(usable_frames * ratio), fps // 2)
|
|
270
318
|
result.append({
|
|
271
319
|
"text": _strip_trailing_punct(seg.strip()),
|
|
272
320
|
"startFrame": current_frame,
|
|
273
321
|
"endFrame": current_frame + frames,
|
|
322
|
+
"sentenceIndex": sentence_indices[i],
|
|
274
323
|
})
|
|
275
324
|
current_frame += frames
|
|
276
325
|
|