@remixmate/cli 0.9.44 → 0.9.45
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 +116 -5
- 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 +107 -1
- 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 +253 -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
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fill a template-declared ``customPayload`` field from a source document, by reference.
|
|
3
|
+
|
|
4
|
+
问题(2026-09-23 线上 conv_Sesw2g8oM81 的根因)
|
|
5
|
+
---------------------------------------------
|
|
6
|
+
``ppt-to-video`` 要求每个场景都有 ``customPayload.slide`` —— 一整页 PPT 的节点树、
|
|
7
|
+
坐标、配色、beats。这份数据只有一个合法产出方(ab-render 的 ``/extract`` layout 层),
|
|
8
|
+
而它**体积大到不能过模型**:一页带内嵌图标可达几百 KB,23 页就是好几 MB。
|
|
9
|
+
|
|
10
|
+
于是模板的 llmHint 写着「把返回的 slides[i].slide 整个填进 customPayload.slide」,
|
|
11
|
+
doc-parse 的 SKILL.md 写着「feed slides[i] straight into customPayload.slide」,而实际
|
|
12
|
+
链路是:doc_parse 把全量写进本地文件 → 模型没有任何文件读取工具 → 拿不到 →
|
|
13
|
+
prepare_video_assets 报 ``customPayload.slide (required)`` ×N。模型无论怎么改都过不了。
|
|
14
|
+
|
|
15
|
+
解法
|
|
16
|
+
----
|
|
17
|
+
**不让这份数据过模型**。DSL 里只留一个引用(``meta.sourceDocument``),skill 侧在
|
|
18
|
+
校验之前自己去把全量取回来填进每个场景。模型负责的是它真正能负责的东西:旁白、
|
|
19
|
+
场景与页的对应关系、以及 mode 的下压。
|
|
20
|
+
|
|
21
|
+
这与 ``extractCustomPayloadOverrides`` / ``extractNarrationOverrides`` 是同一类教训的
|
|
22
|
+
第三次:**合并层丢掉了数据,而下游报的错指向模型**。前两次丢的是 slideId 和 items。
|
|
23
|
+
|
|
24
|
+
模板怎么声明
|
|
25
|
+
-----------
|
|
26
|
+
``template.json`` 的 ``capabilities.payloadHydration``::
|
|
27
|
+
|
|
28
|
+
"payloadHydration": {
|
|
29
|
+
"payloadField": "slide", # 要填的 customPayload 字段名
|
|
30
|
+
"source": "doc-extract", # 数据来源(目前只有这一个)
|
|
31
|
+
"docKind": "pptx",
|
|
32
|
+
"fields": "content,layout", # 传给 /extract 的层
|
|
33
|
+
"indexField": "slideIndex" # 场景→页的映射键(写在 customPayload 里)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
不写 ``if template_id == "ppt-to-video"``(AGENTS.md 明令禁止):任何模板声明了这一段
|
|
37
|
+
就自动获得同样的行为。
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import os
|
|
43
|
+
import sys
|
|
44
|
+
from typing import Callable, Optional
|
|
45
|
+
|
|
46
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
47
|
+
|
|
48
|
+
from doc_extract import ( # noqa: E402
|
|
49
|
+
DocExtractError,
|
|
50
|
+
extract_document,
|
|
51
|
+
slides_by_index,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
SUPPORTED_SOURCES = ("doc-extract",)
|
|
55
|
+
|
|
56
|
+
DEFAULT_INDEX_FIELD = "slideIndex"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class HydrationError(RuntimeError):
|
|
60
|
+
"""注入失败。message 必须直接告诉调用方下一步做什么。
|
|
61
|
+
|
|
62
|
+
这个类存在的全部意义是替换掉 ``customPayload.slide (required)`` ×20 那种报错 ——
|
|
63
|
+
它说的是"某个字段没填",而真相是"这个字段的数据源没有通路",两者要采取的行动
|
|
64
|
+
完全不同,而模型只看得到前者。
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def hydration_spec(template_config: Optional[dict]) -> Optional[dict]:
|
|
69
|
+
"""读 ``capabilities.payloadHydration``;未声明 / 声明无效返回 None。
|
|
70
|
+
|
|
71
|
+
宽容读取:一个写坏的 template.json 不该让所有模板的渲染一起失败。但
|
|
72
|
+
``source`` 认不出来时要报错而不是静默跳过 —— 静默跳过会退回到「必填字段缺失」
|
|
73
|
+
那条死路,也就是这个模块本来要修掉的东西。
|
|
74
|
+
"""
|
|
75
|
+
caps = (template_config or {}).get("capabilities")
|
|
76
|
+
if not isinstance(caps, dict):
|
|
77
|
+
return None
|
|
78
|
+
spec = caps.get("payloadHydration")
|
|
79
|
+
if not isinstance(spec, dict) or not spec:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
payload_field = spec.get("payloadField")
|
|
83
|
+
if not isinstance(payload_field, str) or not payload_field:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
source = spec.get("source") or "doc-extract"
|
|
87
|
+
if source not in SUPPORTED_SOURCES:
|
|
88
|
+
raise HydrationError(
|
|
89
|
+
f"template declares capabilities.payloadHydration.source='{source}', which this "
|
|
90
|
+
f"version of remixmate does not implement (supported: {', '.join(SUPPORTED_SOURCES)}). "
|
|
91
|
+
"Upgrade @remixmate/cli or fix the template."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
index_field = spec.get("indexField")
|
|
95
|
+
return {
|
|
96
|
+
"payload_field": payload_field,
|
|
97
|
+
"source": source,
|
|
98
|
+
"doc_kind": spec.get("docKind") or "pptx",
|
|
99
|
+
"fields": spec.get("fields") or "content,layout",
|
|
100
|
+
"index_field": index_field if isinstance(index_field, str) and index_field else DEFAULT_INDEX_FIELD,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def source_document_ref(dsl: dict, override_url: Optional[str] = None) -> Optional[dict]:
|
|
105
|
+
"""取 DSL 里的源文件引用。``override_url`` 优先(CLI 显式传入时)。
|
|
106
|
+
|
|
107
|
+
位置是 ``meta.sourceDocument`` 而不是场景级:一条片子对一份源文件,写在场景里
|
|
108
|
+
会得到 23 份同样的 URL,还得处理它们互相矛盾的情况。
|
|
109
|
+
|
|
110
|
+
为什么是 ``meta``:ab-agent 的合并层(``applyNarrationToSkeleton``)以 gen_script
|
|
111
|
+
骨架为基底,只从模型提交的 DSL 里搬 narration 与 customPayload,``meta`` / ``global``
|
|
112
|
+
归骨架所有。所以写在 meta 的引用能活着穿过合并层,而模型自己往 dsl_json 里写
|
|
113
|
+
``meta.sourceDocument`` 会被丢掉 —— 引用必须在 gen_script 那一步就进骨架。
|
|
114
|
+
"""
|
|
115
|
+
if override_url:
|
|
116
|
+
return {"url": override_url}
|
|
117
|
+
meta = dsl.get("meta")
|
|
118
|
+
if not isinstance(meta, dict):
|
|
119
|
+
return None
|
|
120
|
+
ref = meta.get("sourceDocument")
|
|
121
|
+
if isinstance(ref, str) and ref.strip():
|
|
122
|
+
return {"url": ref.strip()}
|
|
123
|
+
if isinstance(ref, dict) and isinstance(ref.get("url"), str) and ref["url"].strip():
|
|
124
|
+
return {"url": ref["url"].strip(), "kind": ref.get("kind")}
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _scene_payload(scene: dict) -> dict:
|
|
129
|
+
payload = scene.get("customPayload")
|
|
130
|
+
if not isinstance(payload, dict):
|
|
131
|
+
payload = {}
|
|
132
|
+
scene["customPayload"] = payload
|
|
133
|
+
return payload
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def hydrate_dsl(
|
|
137
|
+
dsl: dict,
|
|
138
|
+
template_config: Optional[dict],
|
|
139
|
+
*,
|
|
140
|
+
override_url: Optional[str] = None,
|
|
141
|
+
log: Optional[Callable[[str], None]] = None,
|
|
142
|
+
) -> list[str]:
|
|
143
|
+
"""把源文件的每一页填进对应场景的 ``customPayload.<payloadField>``。
|
|
144
|
+
|
|
145
|
+
返回一份给人看的报告行列表(调用方自己决定打到哪)。什么都没做时返回空列表。
|
|
146
|
+
|
|
147
|
+
映射规则:场景的 ``customPayload.<indexField>`` 是 1-based 页号,缺省按场景顺序
|
|
148
|
+
取第 i 页。显式页号优先 —— 用户可能只讲其中几页,或者把两页并成一个场景。
|
|
149
|
+
|
|
150
|
+
已有值不覆盖:场景里已经有 ``customPayload.slide`` 时跳过。直接调 CLI 的调试流程
|
|
151
|
+
会手动塞一份进去,把它冲掉等于让调试者的输入人间蒸发。
|
|
152
|
+
"""
|
|
153
|
+
emit = log or (lambda _msg: None)
|
|
154
|
+
|
|
155
|
+
spec = hydration_spec(template_config)
|
|
156
|
+
if not spec:
|
|
157
|
+
return []
|
|
158
|
+
|
|
159
|
+
scenes = [s for s in (dsl.get("scenes") or []) if isinstance(s, dict)]
|
|
160
|
+
if not scenes:
|
|
161
|
+
return []
|
|
162
|
+
|
|
163
|
+
field = spec["payload_field"]
|
|
164
|
+
index_field = spec["index_field"]
|
|
165
|
+
|
|
166
|
+
pending = [s for s in scenes if _scene_payload(s).get(field) is None]
|
|
167
|
+
if not pending:
|
|
168
|
+
return [f"customPayload.{field} already present on all {len(scenes)} scene(s); nothing to hydrate"]
|
|
169
|
+
|
|
170
|
+
ref = source_document_ref(dsl, override_url)
|
|
171
|
+
if not ref:
|
|
172
|
+
template_id = ((dsl.get("meta") or {}).get("templateId")) or "this template"
|
|
173
|
+
raise HydrationError(
|
|
174
|
+
f"{len(pending)} of {len(scenes)} scene(s) need customPayload.{field}, and "
|
|
175
|
+
f"'{template_id}' builds it from the source document rather than from hand-written JSON — "
|
|
176
|
+
"but the DSL carries no source document reference.\n"
|
|
177
|
+
" This field is intentionally NOT something you author: one page of layout data is "
|
|
178
|
+
"hundreds of KB, so it never travels through the conversation.\n"
|
|
179
|
+
f" Fix: pass the document URL once and the pipeline fills all {len(scenes)} scenes itself.\n"
|
|
180
|
+
" • preferred — call gen_script with --source-document-url <url> so the reference is "
|
|
181
|
+
"recorded in meta.sourceDocument\n"
|
|
182
|
+
" • or pass --source-document-url <url> to this call directly\n"
|
|
183
|
+
" The URL is the same .pptx you gave doc_parse."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
kind = ref.get("kind") or spec["doc_kind"]
|
|
187
|
+
emit(f"🧩 hydrating customPayload.{field} from the source document ({kind}, fields={spec['fields']})")
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
result = extract_document(
|
|
191
|
+
ref["url"], kind=kind, fields=spec["fields"], log=emit
|
|
192
|
+
)
|
|
193
|
+
except DocExtractError as exc:
|
|
194
|
+
raise HydrationError(
|
|
195
|
+
f"could not read the source document needed for customPayload.{field}: {exc}"
|
|
196
|
+
) from exc
|
|
197
|
+
|
|
198
|
+
pages = slides_by_index(result)
|
|
199
|
+
if not pages:
|
|
200
|
+
raise HydrationError(
|
|
201
|
+
f"the source document extraction returned no pages, so customPayload.{field} "
|
|
202
|
+
"cannot be filled. Check that the URL points at the .pptx file itself."
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
report: list[str] = []
|
|
206
|
+
filled = 0
|
|
207
|
+
missing: list[str] = []
|
|
208
|
+
|
|
209
|
+
for position, scene in enumerate(scenes, start=1):
|
|
210
|
+
payload = _scene_payload(scene)
|
|
211
|
+
if payload.get(field) is not None:
|
|
212
|
+
continue
|
|
213
|
+
declared = payload.get(index_field)
|
|
214
|
+
page_no = declared if isinstance(declared, int) and declared > 0 else position
|
|
215
|
+
page = pages.get(page_no)
|
|
216
|
+
if page is None:
|
|
217
|
+
missing.append(f"scene {scene.get('id', position)} → page {page_no}")
|
|
218
|
+
continue
|
|
219
|
+
payload[field] = page
|
|
220
|
+
# 把页号写回去,让 RenderPlan 里留下"这一场用的是第几页"的痕迹。
|
|
221
|
+
payload.setdefault(index_field, page_no)
|
|
222
|
+
filled += 1
|
|
223
|
+
|
|
224
|
+
source_meta = result.get("source") or {}
|
|
225
|
+
report.append(
|
|
226
|
+
f"✅ filled customPayload.{field} on {filled}/{len(scenes)} scene(s) from "
|
|
227
|
+
f"{source_meta.get('slideCount', len(pages))} page(s) of "
|
|
228
|
+
f"{source_meta.get('name', 'the source document')}"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if missing:
|
|
232
|
+
# 页号对不上不是警告而是错误:少一页 = 成片里一个空白场景,而它渲得出来。
|
|
233
|
+
raise HydrationError(
|
|
234
|
+
f"{len(missing)} scene(s) point at a page that the source document does not have "
|
|
235
|
+
f"({'; '.join(missing[:5])}"
|
|
236
|
+
f"{f'; … {len(missing)} total' if len(missing) > 5 else ''}).\n"
|
|
237
|
+
f" The document has {len(pages)} page(s). Either lower the scene count to match, "
|
|
238
|
+
f"or fix customPayload.{index_field} on those scenes."
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# 降级记录要顺着传上来 —— 画面上少一块,这里不说就没人知道为什么。
|
|
242
|
+
flagged = [
|
|
243
|
+
f"page {no}: {'; '.join(page.get('flags') or [])}"
|
|
244
|
+
for no, page in sorted(pages.items())
|
|
245
|
+
if page.get("flags")
|
|
246
|
+
]
|
|
247
|
+
if flagged:
|
|
248
|
+
report.append(
|
|
249
|
+
"⚠️ the extractor refused to reconstruct some elements (they render as an "
|
|
250
|
+
"approximation): " + " | ".join(flagged[:5])
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
return report
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"skillName": "template-registry",
|
|
3
3
|
"repoName": "agent-skill-media-maker",
|
|
4
4
|
"skillId": "475",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "V20",
|
|
6
6
|
"skillDescription": "Video-template registry skill. Stores every video-template definition and lists the available templates (templateId / name / aspect ratio / style tags).\n\nUse this skill as soon as the user mentions any of these intents:\n- View available templates / list every template\n\nNote: DSL→TemplateBinding is no longer a separate exposed step — once prepare_video_assets receives a template_id it builds the binding internally."
|
|
7
7
|
}
|
|
@@ -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
|
|