@remixmate/cli 0.9.50 → 0.9.52
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/README.md +14 -26
- package/dist/manifest.json +27 -14
- package/package.json +10 -13
- package/skills/doc-parse/SKILL.md +6 -6
- package/skills/doc-parse/scripts/extract_via_render.py +3 -2
- package/skills/doc-parse/skill.json +1 -1
- package/skills/gen-script/SKILL.md +19 -18
- package/skills/gen-script/scripts/gen_script.py +170 -38
- package/skills/gen-script/skill.json +14 -4
- package/skills/gen-script/version.json +1 -1
- package/skills/prepare-video-assets/SKILL.md +3 -3
- package/skills/prepare-video-assets/skill.json +1 -1
- package/skills/prepare-video-assets/version.json +1 -1
- package/skills/render-video/scripts/render_video.py +53 -42
- package/skills/render-video/version.json +1 -1
- package/skills/template-registry/README.md +4 -15
- package/skills/template-registry/SKILL.md +20 -52
- package/skills/template-registry/scripts/countdown_spec.py +56 -6
- package/skills/template-registry/scripts/list_templates.py +133 -8
- package/skills/template-registry/scripts/registry_loader.py +3 -4
- package/skills/template-registry/skill.json +2 -2
- package/skills/template-registry/version.json +1 -1
- package/skills/template-registry/video_dsl/README.md +10 -53
- package/skills/template-registry/video_dsl/runtime/__init__.py +0 -1
- package/skills/template-registry/video_dsl/runtime/dsl_validator.py +108 -35
- package/skills/template-registry/video_dsl/runtime/narration_budget.py +129 -0
- package/skills/template-registry/video_dsl/schema/video-dsl-v1alpha1.json +1 -1
- package/skills/template-registry/scripts/check_contracts.py +0 -179
- package/skills/template-registry/scripts/sync_registry.py +0 -44
- package/skills/template-registry/scripts/template_paths.py +0 -72
- package/skills/template-registry/scripts/verify_props_contract.py +0 -273
- package/skills/template-registry/video_dsl/runtime/template_binder.py +0 -87
|
@@ -32,10 +32,27 @@ List vs. detail (why ``--json-output`` no longer dumps everything):
|
|
|
32
32
|
programmatic callers, guarded by a size check so it can't silently recreate
|
|
33
33
|
the overflow.
|
|
34
34
|
|
|
35
|
+
Slide menu (why --template-id no longer returns every slideSchemas body):
|
|
36
|
+
slideSchemas is two thirds of a multi-layout template (html-slide: 24.6 K of
|
|
37
|
+
37.2 K chars, 26 layouts), while one video uses 5–9 layouts. Returning all of
|
|
38
|
+
it every time pushed the detail view past the agent's tool-result size cap
|
|
39
|
+
(the result got spilled to a file the agent cannot open, so it re-called
|
|
40
|
+
with other flags), and the agent re-read the whole thing on the next turn
|
|
41
|
+
anyway. So detail mode returns a per-layout *menu* — description, field
|
|
42
|
+
names, list key / item fields / maxItems, and whether narration must be
|
|
43
|
+
split into items — which is enough to choose layouts and draft a script;
|
|
44
|
+
``--slide-id`` then returns the exact schemas of the chosen layouts for
|
|
45
|
+
writing ``templateData``. Registry consumers inside the pipeline (validator,
|
|
46
|
+
render) read the registry directly and are unaffected.
|
|
47
|
+
|
|
35
48
|
Flags:
|
|
36
49
|
--list-templates (accepted; listing is the default verb)
|
|
37
50
|
--template-id <id> print the FULL definition of this template
|
|
38
|
-
(repeatable); this is the detail verb
|
|
51
|
+
(repeatable); this is the detail verb.
|
|
52
|
+
Multi-layout templates come back with a
|
|
53
|
+
slideSchemas *menu* (see "Slide menu" below)
|
|
54
|
+
--slide-id <slideId|all> detail mode: expand these layouts' full
|
|
55
|
+
templateData schemas (repeatable)
|
|
39
56
|
--full in list mode, emit full definitions instead
|
|
40
57
|
of summaries (size-guarded; prefer
|
|
41
58
|
--template-id)
|
|
@@ -159,6 +176,96 @@ def _detail_hint(template_id: str = "<templateId>") -> str:
|
|
|
159
176
|
)
|
|
160
177
|
|
|
161
178
|
|
|
179
|
+
# ----- Slide menu (--template-id without --slide-id) -------------------------
|
|
180
|
+
|
|
181
|
+
_SLIDE_EXPAND_ALL = "all"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _slide_schema_ids(tpl: dict) -> list[str]:
|
|
185
|
+
ss = tpl.get("slideSchemas")
|
|
186
|
+
if not isinstance(ss, dict):
|
|
187
|
+
return []
|
|
188
|
+
return [k for k, v in ss.items() if not k.startswith("_") and isinstance(v, dict)]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _menu_entry(schema: dict, line_sync_key: str | None) -> dict:
|
|
192
|
+
"""One layout's menu row: enough to pick it and draft a script, not to fill it."""
|
|
193
|
+
props = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
|
|
194
|
+
entry: dict = {}
|
|
195
|
+
if schema.get("description"):
|
|
196
|
+
entry["description"] = schema["description"]
|
|
197
|
+
entry["fields"] = list(props.keys())
|
|
198
|
+
if schema.get("required"):
|
|
199
|
+
entry["required"] = schema["required"]
|
|
200
|
+
# The card list: the line-sync key when declared, else the first array of objects.
|
|
201
|
+
list_key = line_sync_key if line_sync_key in props else None
|
|
202
|
+
if list_key is None:
|
|
203
|
+
for k, v in props.items():
|
|
204
|
+
if isinstance(v, dict) and v.get("type") == "array" and isinstance(v.get("items"), dict) \
|
|
205
|
+
and v["items"].get("type") == "object":
|
|
206
|
+
list_key = k
|
|
207
|
+
break
|
|
208
|
+
if list_key is not None:
|
|
209
|
+
arr = props[list_key]
|
|
210
|
+
item = arr.get("items") if isinstance(arr.get("items"), dict) else {}
|
|
211
|
+
item_props = item.get("properties") if isinstance(item.get("properties"), dict) else {}
|
|
212
|
+
lst = {"key": list_key, "itemFields": list(item_props.keys())}
|
|
213
|
+
if item.get("required"):
|
|
214
|
+
lst["itemRequired"] = item["required"]
|
|
215
|
+
if "maxItems" in arr:
|
|
216
|
+
lst["maxItems"] = arr["maxItems"]
|
|
217
|
+
entry["list"] = lst
|
|
218
|
+
if line_sync_key:
|
|
219
|
+
# The single most expensive miss in practice: authoring one narration
|
|
220
|
+
# block for a layout that lights cards one by one (validator rejects it).
|
|
221
|
+
entry["narration"] = (
|
|
222
|
+
f"items — one audio.narration.items line per templateData.{line_sync_key} entry, no text"
|
|
223
|
+
)
|
|
224
|
+
static = [k for k, v in props.items() if isinstance(v, dict) and v.get("x-staticHighlight")]
|
|
225
|
+
if static:
|
|
226
|
+
entry["narrationStaticAlternative"] = f"set templateData.{static[0]} to keep it static instead"
|
|
227
|
+
return entry
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _apply_slide_view(tpl: dict, slide_ids: list[str] | None) -> dict:
|
|
231
|
+
"""Return tpl with slideSchemas reduced to a menu, or to the requested layouts."""
|
|
232
|
+
ss = tpl.get("slideSchemas")
|
|
233
|
+
if not isinstance(ss, dict):
|
|
234
|
+
return tpl
|
|
235
|
+
ids = _slide_schema_ids(tpl)
|
|
236
|
+
out = dict(tpl)
|
|
237
|
+
if slide_ids and _SLIDE_EXPAND_ALL in slide_ids:
|
|
238
|
+
out["slideSchemas"] = {k: v for k, v in ss.items() if k != "_comment"}
|
|
239
|
+
return out
|
|
240
|
+
line_sync = tpl.get("lineSyncSlides") if isinstance(tpl.get("lineSyncSlides"), dict) else {}
|
|
241
|
+
if slide_ids:
|
|
242
|
+
view: dict = {
|
|
243
|
+
"_view": (
|
|
244
|
+
f"Full templateData schemas for: {', '.join(slide_ids)}. Copy field names from here. "
|
|
245
|
+
"Other layouts are omitted — pass their ids in slide_id to read them."
|
|
246
|
+
),
|
|
247
|
+
}
|
|
248
|
+
if isinstance(ss.get("_shared"), dict):
|
|
249
|
+
view["_shared"] = ss["_shared"]
|
|
250
|
+
for sid in slide_ids:
|
|
251
|
+
view[sid] = ss[sid]
|
|
252
|
+
out["slideSchemas"] = view
|
|
253
|
+
return out
|
|
254
|
+
menu: dict = {
|
|
255
|
+
"_view": (
|
|
256
|
+
"MENU ONLY — per-layout summary, not the full schema. Use it to choose layouts and draft the "
|
|
257
|
+
"script. Before writing templateData, call template_registry again with this template_id and "
|
|
258
|
+
"slide_id=<each chosen layout> (repeatable) to get their exact field schemas. "
|
|
259
|
+
"If you already did that earlier in this conversation, do not call it again."
|
|
260
|
+
),
|
|
261
|
+
}
|
|
262
|
+
for sid in ids:
|
|
263
|
+
lk = line_sync.get(sid)
|
|
264
|
+
menu[sid] = _menu_entry(ss[sid], lk if isinstance(lk, str) else None)
|
|
265
|
+
out["slideSchemas"] = menu
|
|
266
|
+
return out
|
|
267
|
+
|
|
268
|
+
|
|
162
269
|
# ----- Examples discovery (--list-examples) ---------------------------------
|
|
163
270
|
#
|
|
164
271
|
# Examples live under <SkillDir>/video_dsl/schema/examples/ as `*.dsl.json` and
|
|
@@ -217,6 +324,9 @@ def main() -> None:
|
|
|
217
324
|
help="List every available template (summaries; the default verb).")
|
|
218
325
|
ap.add_argument("--template-id", action="append", dest="template_ids", metavar="ID",
|
|
219
326
|
help="Print this template's FULL definition (repeatable).")
|
|
327
|
+
ap.add_argument("--slide-id", action="append", dest="slide_ids", metavar="SLIDE_ID",
|
|
328
|
+
help="Detail mode: expand these layouts' full slideSchemas (repeatable; "
|
|
329
|
+
"'all' = every layout). Without it multi-layout templates return a menu.")
|
|
220
330
|
ap.add_argument("--full", action="store_true",
|
|
221
331
|
help="List mode: emit full definitions instead of summaries "
|
|
222
332
|
"(size-guarded; prefer --template-id).")
|
|
@@ -326,13 +436,28 @@ def main() -> None:
|
|
|
326
436
|
known = ", ".join(sorted(str(t.get("templateId")) for t in templates))
|
|
327
437
|
print(f" Available ids: {known}", file=sys.stderr)
|
|
328
438
|
sys.exit(1)
|
|
329
|
-
if args.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
439
|
+
if args.slide_ids:
|
|
440
|
+
wanted = [s for s in args.slide_ids if s != _SLIDE_EXPAND_ALL]
|
|
441
|
+
known_slides = {sid for t in picked for sid in _slide_schema_ids(t)}
|
|
442
|
+
if not known_slides:
|
|
443
|
+
print("⚠️ --slide-id ignored: this template has no slideSchemas (single layout).",
|
|
444
|
+
file=sys.stderr)
|
|
445
|
+
else:
|
|
446
|
+
unknown = [s for s in wanted if s not in known_slides]
|
|
447
|
+
if unknown:
|
|
448
|
+
print(f"❌ Unknown slide id(s): {', '.join(unknown)}", file=sys.stderr)
|
|
449
|
+
print(f" Legal slide ids: {', '.join(sorted(known_slides))}", file=sys.stderr)
|
|
450
|
+
sys.exit(1)
|
|
451
|
+
picked = [
|
|
452
|
+
_apply_slide_view(t, [s for s in (args.slide_ids or []) if s in _slide_schema_ids(t)
|
|
453
|
+
or s == _SLIDE_EXPAND_ALL] or None)
|
|
454
|
+
for t in picked
|
|
455
|
+
]
|
|
456
|
+
# Always compact, with or without --json-output. Every caller is an LLM
|
|
457
|
+
# tool call: indent=2 nearly doubled the size (html-slide 37 K → 65 K
|
|
458
|
+
# chars), which alone pushed the plain call past the tool-result cap and
|
|
459
|
+
# got it spilled to a file the agent cannot read. Humans can `| jq`.
|
|
460
|
+
print(json.dumps({"templates": picked}, ensure_ascii=False))
|
|
336
461
|
return
|
|
337
462
|
|
|
338
463
|
visible = [
|
|
@@ -22,9 +22,8 @@
|
|
|
22
22
|
鉴权:只读 PRIV_TOKEN 环境变量。凭证库 / 钥匙串 / 设备授权流由 CLI 的 Node 侧
|
|
23
23
|
唯一鉴权入口负责(src/auth/ensure.ts),并在 spawn 时注入本进程环境。
|
|
24
24
|
|
|
25
|
-
monorepo /
|
|
26
|
-
|
|
27
|
-
那是 monorepo-only 工具,不是运行时数据源。
|
|
25
|
+
monorepo / 显式本地文件等其它来源已**移除**。模板仓库自己的维护工具住在
|
|
26
|
+
template-library,不经过本模块。
|
|
28
27
|
|
|
29
28
|
P1.2 — list/detail split (待后端就绪):
|
|
30
29
|
100+ 模板时 ``GET /api/template/list`` 应该只返摘要(id / name / aspects /
|
|
@@ -379,7 +378,7 @@ def load_registry_indexed(
|
|
|
379
378
|
"""Return registry as ``{templateId: template_dict}``.
|
|
380
379
|
|
|
381
380
|
Filters by the process-wide status gate unless ``include_all_statuses``
|
|
382
|
-
is set (
|
|
381
|
+
is set (e.g. render-video reading an already-chosen template). Templates
|
|
383
382
|
missing ``status`` are treated as ``stable`` for backwards compatibility.
|
|
384
383
|
|
|
385
384
|
``ttl_seconds`` / ``http_timeout`` are accepted for API symmetry with
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
"type": "object",
|
|
13
13
|
"properties": {
|
|
14
14
|
"list_templates": { "type": "boolean", "description": "List available templates as summaries — templateId / name / description / aspect ratios / language / status / styleTags / variantIds / key capabilities / a truncated llmHint (default behavior; also implied when other flags are passed)." },
|
|
15
|
-
"template_id": { "type": "string", "description": "Return this template's
|
|
15
|
+
"template_id": { "type": "string", "description": "Return this template's full authoring contract instead of the summary list: llmHint in full, customPayloadSchema (every template-specific field incl. the legal slideId values), slotMapping, compositions, variants. Multi-layout templates return slideSchemas as a MENU (per layout: description, field names, card-list key / item fields / maxItems, and whether narration must be split into items) — enough to pick layouts and draft the script. Read it once per conversation; it does not change between turns." },
|
|
16
|
+
"slide_id": { "type": "array", "items": { "type": "string" }, "description": "With template_id: return the FULL templateData schema for these layouts (the slideIds you chose from the menu). Call this once, right before writing templateData, with every layout the script uses. 'all' returns every layout (large — avoid)." },
|
|
16
17
|
"full": { "type": "boolean", "description": "List mode only: emit full definitions for every listed template instead of summaries. Rejected when the result would be too large for one tool result — prefer template_id, or narrow with the filter_* parameters." },
|
|
17
|
-
"list_examples": { "type": "boolean", "description": "List the *.dsl.json / *.binding.json reference examples under template-registry/video_dsl/schema/examples/, grouped by templateId. That directory is optional and is not part of the published package, so this is normally empty — to read a template's reference shape, use template_id instead (the registry's own contract)." },
|
|
18
18
|
"filter_tag": { "type": "string", "description": "Show only templates whose styleTags contain (or are contained in) this string. Case-insensitive. E.g. 'tech' / '科普' / 'walkthrough'." },
|
|
19
19
|
"filter_aspect": { "type": "string", "description": "Show only templates that declare this aspect ratio. E.g. '9:16' / '16:9' / '1:1'." },
|
|
20
20
|
"filter_language": { "type": "string", "description": "Show only templates whose contentLanguage includes this code ('zh' or 'en'). Templates with no declared language are always shown (treated as language-agnostic)." },
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"skillName": "template-registry",
|
|
3
3
|
"repoName": "agent-skill-media-maker",
|
|
4
4
|
"skillId": "475",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "V24",
|
|
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
|
}
|
|
@@ -9,27 +9,17 @@ video_dsl/
|
|
|
9
9
|
├── schema/ # DSL Schema 定义
|
|
10
10
|
│ ├── video-dsl-v1alpha1.json # Video DSL JSON Schema
|
|
11
11
|
│ ├── template-binding-v1alpha1.json # 模板绑定 Schema
|
|
12
|
-
│
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
├── runtime/ # DSL 运行时工具
|
|
19
|
-
│ ├── dsl_validator.py # DSL Schema 校验
|
|
20
|
-
│ ├── template_binder.py # Scene → Slot 绑定(从 @ab-templates/metadata 读取模板)
|
|
21
|
-
│ ├── timeline_compiler.py # 时间线编译(帧号、字幕切片、旁白分段)
|
|
22
|
-
│ ├── asset_adapter.py # 素材适配器
|
|
23
|
-
│ └── prompt_enhancer.py # Prompt 增强
|
|
24
|
-
└── library/ # 可复用组件库(预留)
|
|
25
|
-
├── blocks/ # 功能块(如 CTA、数字人、全屏背景)
|
|
26
|
-
├── components/ # 基础组件(音频、文本、背景、装饰)
|
|
27
|
-
├── scenes/ # 场景模板(开场钩子、知识点、绘本页)
|
|
28
|
-
└── videos/ # 完整视频模板(英文绘本等)
|
|
12
|
+
│ └── render-plan-v1alpha1.json # RenderPlan Schema
|
|
13
|
+
└── runtime/ # DSL 运行时工具(通用机制,不认识任何具体模板)
|
|
14
|
+
├── dsl_validator.py # DSL 校验(读模板声明做契约检查)
|
|
15
|
+
├── payload_contract.py # customPayload 必填字段检查
|
|
16
|
+
├── timeline_compiler.py # 时间线编译(帧号、字幕切片、旁白分段)
|
|
17
|
+
└── prompt_enhancer.py # Prompt 增强
|
|
29
18
|
```
|
|
30
19
|
|
|
31
|
-
> **注意**:模板元数据(template.json / registry.json
|
|
32
|
-
>
|
|
20
|
+
> **注意**:模板元数据(template.json / registry.json)住在独立的 `template-library` 仓库,
|
|
21
|
+
> 运行时经 ab-api 下发,由 `scripts/registry_loader.py` 读取;DSL → TemplateBinding 在 `scripts/match_template.py`。
|
|
22
|
+
> 逐模板的契约与写法只在各自的 `template.json` 里,本目录不列模板、不带模板示例。
|
|
33
23
|
|
|
34
24
|
## 核心概念
|
|
35
25
|
|
|
@@ -51,46 +41,13 @@ video_dsl/
|
|
|
51
41
|
[render-video] → RenderPlan → MP4 / 剪映草稿
|
|
52
42
|
```
|
|
53
43
|
|
|
54
|
-
## 模板列表
|
|
55
|
-
|
|
56
|
-
| 模板 ID | 名称 | 适用场景 |
|
|
57
|
-
|---------|------|----------|
|
|
58
|
-
| `image-slide` | 图片幻灯片 | 通用知识分享、产品介绍(含 tech 科技风变体) |
|
|
59
|
-
| `picture-book-en` | 英文绘本视频 | 儿童英语启蒙、双语绘本故事 |
|
|
60
|
-
| `image-slideshow` | 图片轮播视频 | 知识分享、种草笔记、技巧总结 |
|
|
61
|
-
| `knowledge-slides` | 知识幻灯片 | 方法论拆解、教程讲解、知识科普 |
|
|
62
|
-
| `html-slide` | 知识看板 | 知识科普、概念讲解、白板风格内容 |
|
|
63
|
-
|
|
64
44
|
## runtime 模块说明
|
|
65
45
|
|
|
66
46
|
| 模块 | 职责 | 被谁调用 |
|
|
67
47
|
|------|------|----------|
|
|
68
48
|
| `dsl_validator.py` | DSL JSON Schema 校验与参数标准化 | gen-script、render-video |
|
|
69
|
-
| `template_binder.py` | 将 DSL scene 映射到模板 slot,生成 TemplateBinding(从 @ab-templates/metadata 读取模板定义) | template-registry |
|
|
70
49
|
| `timeline_compiler.py` | 帧号计算、字幕切片(`split_subtitle`)、旁白分段(`segment_narration`) | render-video(跨 skill import) |
|
|
71
|
-
| `
|
|
50
|
+
| `payload_contract.py` | customPayload 必填字段 / 计划素材检查 | dsl_validator、render-video |
|
|
72
51
|
| `prompt_enhancer.py` | 图片 prompt 增强 | render-video |
|
|
73
52
|
|
|
74
53
|
**注意**:`timeline_compiler.py` 被 `render-video/scripts/render_video.py` 直接 import,修改函数签名需两个 skill 同步测试。
|
|
75
|
-
|
|
76
|
-
## library 组件库(预留)
|
|
77
|
-
|
|
78
|
-
`library/` 目录采用四层分级设计,用于存放可复用的 DSL 片段:
|
|
79
|
-
|
|
80
|
-
- **components/**:最小粒度的基础组件(音频、文本、背景、头像、装饰、媒体、叠加层)
|
|
81
|
-
- **blocks/**:由多个 component 组合而成的功能块(CTA 按钮、数字人区域、全屏背景图、标题区、产品卡片、配音条、双语字幕)
|
|
82
|
-
- **scenes/**:完整的场景模板(开场钩子、知识点讲解、绘本封面/内页/结尾、CTA 结尾、数字人口播)
|
|
83
|
-
- **videos/**:完整的视频模板(英文绘本等)
|
|
84
|
-
|
|
85
|
-
组件库当前为目录骨架,后续可填充具体的 JSON 片段供 Agent 和 gen_script.py 复用。
|
|
86
|
-
|
|
87
|
-
## 示例文件
|
|
88
|
-
|
|
89
|
-
`schema/examples/` 下包含各模板的完整 DSL + Binding 示例,是 Agent 生成 DSL 时的权威参考:
|
|
90
|
-
|
|
91
|
-
- `ai-learning-tips.*` — image-slide 模板示例
|
|
92
|
-
- `html-slide-demo.*` — html-slide 模板示例
|
|
93
|
-
- `knowledge-slides-demo.*` — knowledge-slides 模板示例
|
|
94
|
-
- `animal-friends-picturebook.*` — picture-book-en 模板示例
|
|
95
|
-
|
|
96
|
-
Agent 在为指定模板生成 DSL 时,必须先读取对应示例,按模板原生要求生成。
|
|
@@ -6,6 +6,5 @@ and gen-script). Public modules:
|
|
|
6
6
|
|
|
7
7
|
- timeline_compiler : DSL → Remotion frame-axis timeline + subtitle splits
|
|
8
8
|
- prompt_enhancer : LLM image-prompt enrichment used by gen-script
|
|
9
|
-
- template_binder : registry summary writer (sync_registry helper)
|
|
10
9
|
- dsl_validator : DSL validation (structural / integrity / full presets)
|
|
11
10
|
"""
|
|
@@ -31,7 +31,7 @@ VALID_PURPOSES = {"opening", "intro", "point", "example", "explanation", "transi
|
|
|
31
31
|
VALID_LAYOUTS = {"full-visual", "split-left-right", "split-top-bottom", "picture-in-picture", "text-overlay", "avatar-with-bg", "kenburns"}
|
|
32
32
|
|
|
33
33
|
# 场景数上限的兜底值。叙事弧类模板的合理上界——20 段以上的短视频必然过碎。
|
|
34
|
-
# "一页素材一个场景"的模板(
|
|
34
|
+
# "一页素材一个场景"的模板(capabilities.payloadHydration)场景数由源文件页数决定,应当用
|
|
35
35
|
# ``capabilities.maxScenes`` 抬高;这个常量只是没声明时的默认。
|
|
36
36
|
DEFAULT_MAX_SCENES = 20
|
|
37
37
|
|
|
@@ -61,21 +61,17 @@ def max_scenes_for_template(template_config: Optional[dict]) -> int:
|
|
|
61
61
|
VALID_ASSET_TYPES = {"image", "video", "audio", "avatar", "subtitle", "bgm"}
|
|
62
62
|
VALID_ASSET_SOURCES = {"existing", "gen-image", "gen-video", "gen-voice", "gen-digital-human"}
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
|
|
75
|
-
"background",
|
|
76
|
-
"templateData",
|
|
77
|
-
"highlightMap",
|
|
78
|
-
})
|
|
64
|
+
def _frame_level_keys(tpl: dict) -> frozenset:
|
|
65
|
+
"""模板声明的 frame 级字段(``capabilities.frameLevelKeys.keys``)。
|
|
66
|
+
|
|
67
|
+
这些字段由 Composition 消费、不属于任何 slide 载荷;「整包态」binder 会把它们
|
|
68
|
+
混进 templateData,出现在那里是正当的,不该被当成未声明字段。名单是模板的事实,
|
|
69
|
+
CLI 不保留副本 —— 没声明的模板就是空集。``highlightMap`` 这类跨版式字段走
|
|
70
|
+
``slideSchemas._shared``,不在这里。
|
|
71
|
+
"""
|
|
72
|
+
spec = (tpl.get("capabilities") or {}).get("frameLevelKeys")
|
|
73
|
+
keys = spec.get("keys") if isinstance(spec, dict) else None
|
|
74
|
+
return frozenset(k for k in keys if isinstance(k, str)) if isinstance(keys, list) else frozenset()
|
|
79
75
|
|
|
80
76
|
# CJK 汉字范围(简繁通用)+ 常见中文标点
|
|
81
77
|
_CJK_PATTERN = re.compile(r"[一-鿿㐀-䶿- -〿]")
|
|
@@ -144,11 +140,32 @@ def _line_sync_slides_for_template(template_id: Optional[str]) -> dict[str, str]
|
|
|
144
140
|
O(1) lookup via ``registry_loader.get_template``; safe to call once per
|
|
145
141
|
scene at 100+ templates without scanning the full list.
|
|
146
142
|
"""
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
143
|
+
return line_sync_slides(_get_template_cfg(template_id))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def line_sync_slides(tpl: Optional[dict]) -> dict[str, str]:
|
|
147
|
+
"""模板的 ``lineSyncSlides`` 声明:slideId → templateData 里卡片数组的键。
|
|
148
|
+
|
|
149
|
+
校验层(narration.items 数对齐)与 render-video(自动 highlightMap 的上限裁剪)
|
|
150
|
+
读的是同一份声明,CLI 不保留任何卡片键表的副本。
|
|
151
|
+
"""
|
|
152
|
+
spec = (tpl or {}).get("lineSyncSlides") or {}
|
|
153
|
+
return {k: v for k, v in spec.items() if not k.startswith("_") and isinstance(v, str)}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def step_alignment_field(tpl: Optional[dict]) -> Optional[str]:
|
|
157
|
+
"""模板 ``capabilities.stepAlignment`` 声明的动作数组字段;未声明返回 None。
|
|
158
|
+
|
|
159
|
+
只认 ``alignTo`` 缺省或 ``narration-sentences``(第 i 个动作 = 第 i 句旁白)。
|
|
160
|
+
"""
|
|
161
|
+
caps = (tpl or {}).get("capabilities")
|
|
162
|
+
spec = caps.get("stepAlignment") if isinstance(caps, dict) else None
|
|
163
|
+
if not isinstance(spec, dict):
|
|
164
|
+
return None
|
|
165
|
+
if spec.get("alignTo") not in (None, "narration-sentences"):
|
|
166
|
+
return None
|
|
167
|
+
field = spec.get("payloadField")
|
|
168
|
+
return field if isinstance(field, str) and field else None
|
|
152
169
|
|
|
153
170
|
|
|
154
171
|
def _get_template_cfg(template_id: Optional[str]) -> Optional[dict]:
|
|
@@ -280,7 +297,7 @@ def _check_scenes_max(dsl: dict) -> list[ValidationError]:
|
|
|
280
297
|
|
|
281
298
|
上限由模板的 ``capabilities.maxScenes`` 声明,缺省 ``DEFAULT_MAX_SCENES``(20)。
|
|
282
299
|
20 这个数字是给"叙事弧"类模板定的——一条 60 秒短视频排到 20 段已经过碎。但
|
|
283
|
-
"一页素材一个场景"的模板(
|
|
300
|
+
"一页素材一个场景"的模板(逐页讲解一份源文件)的场景数由源文件页数决定,
|
|
284
301
|
跟叙事节奏无关:2026-09-23 线上一份 23 页 PPT 就是在这里被砍到 20 页,
|
|
285
302
|
而且砍得毫无声响,直到下游报 ``customPayload.slide (required)`` ×20 才暴露。
|
|
286
303
|
所以上限改成模板自己声明,并且这里的报错要说清它是谁定的。
|
|
@@ -576,8 +593,7 @@ def _check_slide_cross_field(dsl: dict) -> list[ValidationError]:
|
|
|
576
593
|
schemas = tpl.get("slideSchemas")
|
|
577
594
|
if not isinstance(schemas, dict):
|
|
578
595
|
return []
|
|
579
|
-
line_sync =
|
|
580
|
-
if isinstance(v, str)}
|
|
596
|
+
line_sync = line_sync_slides(tpl)
|
|
581
597
|
|
|
582
598
|
errors: list[ValidationError] = []
|
|
583
599
|
|
|
@@ -673,8 +689,7 @@ def _check_line_sync_narration(dsl: dict) -> list[ValidationError]:
|
|
|
673
689
|
tpl = _get_template_cfg(_pick_template_id(dsl))
|
|
674
690
|
if not tpl:
|
|
675
691
|
return []
|
|
676
|
-
line_sync =
|
|
677
|
-
if isinstance(v, str)}
|
|
692
|
+
line_sync = line_sync_slides(tpl)
|
|
678
693
|
if not line_sync:
|
|
679
694
|
return []
|
|
680
695
|
schemas = tpl.get("slideSchemas") if isinstance(tpl.get("slideSchemas"), dict) else {}
|
|
@@ -751,14 +766,8 @@ def _check_step_narration_alignment(dsl: dict) -> list[ValidationError]:
|
|
|
751
766
|
tpl = _get_template_cfg(_pick_template_id(dsl))
|
|
752
767
|
if not tpl:
|
|
753
768
|
return []
|
|
754
|
-
|
|
755
|
-
|
|
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"):
|
|
769
|
+
field = step_alignment_field(tpl)
|
|
770
|
+
if not field:
|
|
762
771
|
return []
|
|
763
772
|
|
|
764
773
|
try:
|
|
@@ -813,6 +822,66 @@ def _check_step_narration_alignment(dsl: dict) -> list[ValidationError]:
|
|
|
813
822
|
return errors
|
|
814
823
|
|
|
815
824
|
|
|
825
|
+
def _check_duration_estimate(dsl: dict) -> list[ValidationError]:
|
|
826
|
+
"""按旁白字数估出的成片时长明显超过 ``meta.targetDuration`` → warning。
|
|
827
|
+
|
|
828
|
+
为什么要有:旁白字数决定时长,但没有任何一环在出素材之前核对过。2026-09-27 线上
|
|
829
|
+
一条 html-slide 会话,确认稿写 75s、每场 8–9s,每场却写了 45–58 字,还有 7 场是
|
|
830
|
+
分行旁白(每行之间有停顿)—— 成片 116.9s,用户确认的是一个从来不可能兑现的数。
|
|
831
|
+
|
|
832
|
+
只报 warning 不拦:走到这里用户已经确认过脚本,再拦只会多一轮来回;它存在的意义
|
|
833
|
+
是让这件事**有人看见**(模型据此如实告知用户,复盘据此定位)。估算口径见
|
|
834
|
+
``narration_budget``。骨架占位旁白还没填时不估 —— 那是另一条检查的事。
|
|
835
|
+
"""
|
|
836
|
+
try:
|
|
837
|
+
from . import narration_budget as nb
|
|
838
|
+
except ImportError:
|
|
839
|
+
return []
|
|
840
|
+
|
|
841
|
+
meta = dsl.get("meta") or {}
|
|
842
|
+
target = meta.get("targetDuration")
|
|
843
|
+
if isinstance(target, bool) or not isinstance(target, (int, float)) or target <= 0:
|
|
844
|
+
return []
|
|
845
|
+
narration_cfg = ((dsl.get("global") or {}).get("narration") or {})
|
|
846
|
+
speed = narration_cfg.get("speed")
|
|
847
|
+
speed = float(speed) if isinstance(speed, (int, float)) and not isinstance(speed, bool) and speed > 0 else 1.0
|
|
848
|
+
tail = nb.template_tail_pad_sec(_get_template_cfg(_pick_template_id(dsl)))
|
|
849
|
+
|
|
850
|
+
total = 0.0
|
|
851
|
+
narrated = 0
|
|
852
|
+
overruns: list[tuple[float, str, float, float]] = []
|
|
853
|
+
for idx, scene in enumerate(dsl.get("scenes", []) or []):
|
|
854
|
+
narration = (scene.get("audio") or {}).get("narration") or {}
|
|
855
|
+
lines = nb.narration_lines(narration)
|
|
856
|
+
if any(line.startswith(("[骨架待填充]", "[skeleton placeholder]")) for line in lines):
|
|
857
|
+
return []
|
|
858
|
+
planned = scene.get("duration")
|
|
859
|
+
planned = float(planned) if isinstance(planned, (int, float)) and not isinstance(planned, bool) else 0.0
|
|
860
|
+
est = nb.estimate_scene_sec(narration, speed, tail)
|
|
861
|
+
if est > 0:
|
|
862
|
+
narrated += 1
|
|
863
|
+
if planned > 0 and est - planned > 1.0:
|
|
864
|
+
overruns.append((est - planned, scene.get("id", f"scenes[{idx}]"), est, planned))
|
|
865
|
+
total += max(est, planned) if est > 0 else planned
|
|
866
|
+
|
|
867
|
+
if not narrated or total <= target * (1 + nb.OVERRUN_WARN_RATIO):
|
|
868
|
+
return []
|
|
869
|
+
|
|
870
|
+
overruns.sort(reverse=True)
|
|
871
|
+
detail = "; ".join(f"{sid} ≈{est:.0f}s (planned {planned:.0f}s)" for _, sid, est, planned in overruns[:5])
|
|
872
|
+
return [ValidationError(
|
|
873
|
+
"meta.targetDuration",
|
|
874
|
+
f"estimated video length ≈{total:.0f}s vs target {target:.0f}s "
|
|
875
|
+
f"(+{(total / target - 1) * 100:.0f}%) at narration speed {speed:g}× — "
|
|
876
|
+
f"~{nb.PLAN_CHARS_PER_SEC_1X * speed:.1f} zh chars/s, +{nb.LINE_PAUSE_SEC}s per extra "
|
|
877
|
+
f"narration line, +{tail:g}s tail per scene. Longest overruns: {detail or 'n/a'}. "
|
|
878
|
+
"Report the real length to the user (not the target) when you show the result; "
|
|
879
|
+
"narration drives duration, so to hit the target the narration must get shorter "
|
|
880
|
+
"or use fewer items.",
|
|
881
|
+
severity="warning",
|
|
882
|
+
)]
|
|
883
|
+
|
|
884
|
+
|
|
816
885
|
def _check_slide_payload_fields(dsl: dict) -> list[ValidationError]:
|
|
817
886
|
"""逐版式校验 ``customPayload.templateData`` 的字段名。
|
|
818
887
|
|
|
@@ -877,7 +946,7 @@ def _check_slide_payload_fields(dsl: dict) -> list[ValidationError]:
|
|
|
877
946
|
extra_allowed=shared,
|
|
878
947
|
# frame 级字段由 Composition 消费,「整包态」binder 会把它们混进
|
|
879
948
|
# templateData —— 出现在这里是正当的,不算未声明字段。
|
|
880
|
-
skip_keys=
|
|
949
|
+
skip_keys=_frame_level_keys(tpl),
|
|
881
950
|
)
|
|
882
951
|
|
|
883
952
|
return errors
|
|
@@ -1260,6 +1329,8 @@ def validate_integrity(dsl: dict) -> list[ValidationError]:
|
|
|
1260
1329
|
errors += _check_step_narration_alignment(dsl)
|
|
1261
1330
|
errors += _check_slide_cross_field(dsl)
|
|
1262
1331
|
errors += _check_text_layer_rules(dsl)
|
|
1332
|
+
# warning,不阻断。见 _check_duration_estimate。
|
|
1333
|
+
errors += _check_duration_estimate(dsl)
|
|
1263
1334
|
return errors
|
|
1264
1335
|
|
|
1265
1336
|
|
|
@@ -1295,4 +1366,6 @@ def validate_dsl(dsl: dict) -> list[ValidationError]:
|
|
|
1295
1366
|
errors += _check_step_narration_alignment(dsl)
|
|
1296
1367
|
errors += _check_slide_cross_field(dsl)
|
|
1297
1368
|
errors += _check_text_layer_rules(dsl)
|
|
1369
|
+
# warning,不阻断。见 _check_duration_estimate。
|
|
1370
|
+
errors += _check_duration_estimate(dsl)
|
|
1298
1371
|
return errors
|