@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.
Files changed (32) hide show
  1. package/README.md +14 -26
  2. package/dist/manifest.json +27 -14
  3. package/package.json +10 -13
  4. package/skills/doc-parse/SKILL.md +6 -6
  5. package/skills/doc-parse/scripts/extract_via_render.py +3 -2
  6. package/skills/doc-parse/skill.json +1 -1
  7. package/skills/gen-script/SKILL.md +19 -18
  8. package/skills/gen-script/scripts/gen_script.py +170 -38
  9. package/skills/gen-script/skill.json +14 -4
  10. package/skills/gen-script/version.json +1 -1
  11. package/skills/prepare-video-assets/SKILL.md +3 -3
  12. package/skills/prepare-video-assets/skill.json +1 -1
  13. package/skills/prepare-video-assets/version.json +1 -1
  14. package/skills/render-video/scripts/render_video.py +53 -42
  15. package/skills/render-video/version.json +1 -1
  16. package/skills/template-registry/README.md +4 -15
  17. package/skills/template-registry/SKILL.md +20 -52
  18. package/skills/template-registry/scripts/countdown_spec.py +56 -6
  19. package/skills/template-registry/scripts/list_templates.py +133 -8
  20. package/skills/template-registry/scripts/registry_loader.py +3 -4
  21. package/skills/template-registry/skill.json +2 -2
  22. package/skills/template-registry/version.json +1 -1
  23. package/skills/template-registry/video_dsl/README.md +10 -53
  24. package/skills/template-registry/video_dsl/runtime/__init__.py +0 -1
  25. package/skills/template-registry/video_dsl/runtime/dsl_validator.py +108 -35
  26. package/skills/template-registry/video_dsl/runtime/narration_budget.py +129 -0
  27. package/skills/template-registry/video_dsl/schema/video-dsl-v1alpha1.json +1 -1
  28. package/skills/template-registry/scripts/check_contracts.py +0 -179
  29. package/skills/template-registry/scripts/sync_registry.py +0 -44
  30. package/skills/template-registry/scripts/template_paths.py +0 -72
  31. package/skills/template-registry/scripts/verify_props_contract.py +0 -273
  32. 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.json_output:
330
- print(json.dumps({"templates": picked}, ensure_ascii=False))
331
- else:
332
- # Pretty-printed rather than tabular: the whole point of detail mode
333
- # is the nested contract (customPayloadSchema / slotMapping), which
334
- # a table cannot show.
335
- print(json.dumps({"templates": picked}, ensure_ascii=False, indent=2))
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 / 显式本地文件等其它来源已**移除**。需要离线 / pinned 数据的维护脚本
26
- (check_contracts.py / sync_registry.py)直接走 template_paths 读 monorepo,
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 (used by maintenance tooling like ``check_contracts``). Templates
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 FULL definition instead of the summary list: llmHint in full, customPayloadSchema (every template-specific field incl. the legal slideId values), slotMapping, compositions, variants. This is the per-template authoring contract — read it before writing DSL for that template." },
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": "V22",
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
- │ ├── render-plan-v1alpha1.json # RenderPlan Schema
13
- │ └── examples/ # 各模板的 DSL + Binding 示例
14
- │ ├── ai-learning-tips.dsl.json
15
- │ ├── ai-learning-tips.binding.json
16
- │ ├── html-slide-demo.dsl.json
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)已迁移至独立的 `template-library` 仓库,
32
- > 通过 `@ab-templates/metadata` 包发布。`template_binder.py` 直接读取该包产出的 `registry.json`。
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
- | `asset_adapter.py` | 素材格式适配 | render-video |
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
- # "一页素材一个场景"的模板(ppt-to-video)场景数由源文件页数决定,应当用
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
- # 由 Composition 消费、而非 slide 组件消费的字段。前八个与模板侧
65
- # utils/slidePayload.ts 的 FRAME_LEVEL_KEYS 同源 ——「整包态」binder 会把它们混进
66
- # templateData,出现在那里是正当的,不该被当成未声明字段。highlightMap 同理(模板
67
- # 一般在 slideSchemas._shared 里声明它,这里兜底没声明的情况)。
68
- _SLIDE_FRAME_LEVEL_KEYS = frozenset({
69
- "slideId",
70
- "titleText",
71
- "subtitleText",
72
- "watermarkText",
73
- "narrationAssetId",
74
- "narrationSrc",
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
- tpl = _get_template_cfg(template_id)
148
- if not tpl:
149
- return {}
150
- spec = tpl.get("lineSyncSlides") or {}
151
- return {k: v for k, v in spec.items() if isinstance(v, str)}
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
- "一页素材一个场景"的模板(ppt-to-video 逐页讲解)的场景数由源文件页数决定,
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 = {k: v for k, v in (tpl.get("lineSyncSlides") or {}).items()
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 = {k: v for k, v in (tpl.get("lineSyncSlides") or {}).items()
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
- 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"):
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=_SLIDE_FRAME_LEVEL_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