@remixmate/cli 0.9.31 → 0.9.33

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.9.31",
4
- "generatedAt": "2026-09-14T15:38:04.716Z",
3
+ "version": "0.9.33",
4
+ "generatedAt": "2026-09-16T01:00:58.193Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "export-jianying",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.31",
3
+ "version": "0.9.33",
4
4
  "description": "AI media generation skills for Claude Code / Codex — 12 skills covering image, video, voice, digital human, web screenshot, web recording, script, template registry, rendering, Jianying export, and video deconstruction.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -24,6 +24,7 @@
24
24
  "test:web-read": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-web-read.py",
25
25
  "test:template-pipeline": "PYTHONDONTWRITEBYTECODE=1 node dist/cli.js exec -- python3 scripts/test-template-pipeline.py",
26
26
  "test:props-contract": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-props-contract.py",
27
+ "test:card-array-keys": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-card-array-keys.py",
27
28
  "test:narration-speed": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-narration-speed.py",
28
29
  "test:render-resume": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-resume.py",
29
30
  "test:render-plan": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-plan-snapshot.py",
@@ -55,6 +55,7 @@ The intent is to avoid the failure mode where "the generic DSL looks compatible
55
55
  1. Read the requested template's full definition from the registry — `template_registry` with `template_id=<the id>` and `json_output=true` emits that one template whole. This is the shipping source of truth, and unlike the summary list it truncates nothing. Do **not** ask for every template's full definition (`list_templates=true` + `full=true`): the whole registry is far more JSON than one tool result can carry, and the call fails instead of returning the contract.
56
56
  2. Read its `llmHint` end to end — that is where the template states how its on-screen text and its layouts must be authored.
57
57
  3. Read its `customPayloadSchema`: every template-specific field lives there, including the enum of legal `customPayload.slideId` values for multi-layout templates.
58
+ 3b. If the template ships `slideSchemas` (multi-layout templates do), **author `templateData` straight from `slideSchemas[slideId]`** — it is the only place the per-layout field names exist. Do not infer them: the same concept is named `name` in one layout, `label` in another and `era` in a third, and a wrong name is dropped in silence (see below).
58
59
  4. Combine that with the template's `slotMapping`, `requiredProps`, `optionalProps`, `propExtractors`, `assetRequirements`, `supportedAspectRatios`, `supportedDurations`, `constraints`, and `scenePatterns`.
59
60
  5. Generate the DSL in the template's native shape — not the generic DSL shape.
60
61
  6. Make sure the DSL explicitly contains every template-specific field, e.g. `templateData.words`, `customPayload.slideId`, `visuals.avatar.assetRef`.
@@ -69,6 +70,14 @@ When a template's `customPayloadSchema.slideId.enum` holds more than one value (
69
70
 
70
71
  A missing `slideId` is the quietest failure in the pipeline: the renderer drops the entire `templateData` and draws a single centred title — no error, no log, exit code 0.
71
72
 
73
+ ### Multi-layout templates: field names are per-layout, never guessable
74
+
75
+ Picking the layout is only half of it. Each layout reads its own field names out of `templateData`, and a field the layout does not read is **dropped without a word** — that slot renders empty, or shows the component's built-in placeholder text, and the render still reports success.
76
+
77
+ This has shipped broken videos: a 7-scene deck wrote `items[].title` where `html-slide`'s `feature-grid` reads `name`, `items[].description` where `timeline-axis` reads `detail`, and `title` / `subtitle` / `ctaText` where `closing-cta` reads `headline` / `actionLine` / `ctaLabel` / `ctaUrl`. Five of seven scenes rendered as empty cards and placeholder copy — after paying for TTS and the render.
78
+
79
+ So: **copy the field names out of `slideSchemas[slideId]`.** The DSL validator now rejects unknown field names before any asset is generated and tells you the declared ones, but that check only fires for templates that ship `slideSchemas` — reading the contract is still the primary move, not the fallback.
80
+
72
81
  ### On-screen text: the rules live in the template, not here
73
82
 
74
83
  How the on-screen text layers should be *written* — whether the headline is a hook or a product name, how many subheadline lines survive, whether `**emphasis**` is parsed, where a link is allowed to appear — is a property of each template's layout, and its single source of truth is that template's `llmHint` in `template.json`. This skill deliberately does not restate any of it: a copy here would drift from the registry, and the registry is what actually renders.
@@ -476,4 +485,4 @@ prefix and version — not a short alias:
476
485
  |------|---------|
477
486
  | `gen_script.py` | Core script — produces the Video DSL JSON from a topic. |
478
487
 
479
- The authoritative per-template contract is the registry's `template.json` (`llmHint` + `customPayloadSchema` + `slotMapping`). `template_registry list_examples=true` lists local reference DSLs when any exist, but that directory does not ship — see *DSL schema* above.
488
+ The authoritative per-template contract is the registry's `template.json` (`llmHint` + `customPayloadSchema` + `slideSchemas` + `slotMapping`) — everything you need to author against is in there, so read it rather than inferring field names from the template's name or from another template. `template_registry list_examples=true` lists local reference DSLs when any exist, but that directory does not ship — see *DSL schema* above.
@@ -461,12 +461,23 @@ def validate_dsl(dsl: dict) -> list:
461
461
  ``global`` presence are intentionally NOT enforced here — by the time
462
462
  a DSL reaches render_video those have already been gated upstream, and
463
463
  enforcing them again would change historical behavior.
464
+
465
+ ``severity="warning"`` 的条目**不阻断**:它们描述的是"能渲染出来但有一处会静默
466
+ 退化"(如黑板版 highlight 不是 title 的子串 → 标题退回单色)。把它们和硬错误混在
467
+ 同一个 fatal 列表里,作者就只能二选一:要么被无关的软问题挡住,要么把整条校验关掉。
468
+ 警告改为打印到 stderr,退出码不受影响。
464
469
  """
465
470
  from video_dsl.runtime.dsl_validator import ( # noqa: E402
466
471
  validate_integrity,
467
472
  errors_as_strings,
468
473
  )
469
- return errors_as_strings(validate_integrity(dsl))
474
+ found = validate_integrity(dsl)
475
+ warnings = [e for e in found if getattr(e, "severity", "error") != "error"]
476
+ if warnings:
477
+ LogPrint("⚠️ DSL warnings (not blocking):", file=sys.stderr)
478
+ for w in errors_as_strings(warnings):
479
+ LogPrint(f" - {w}", file=sys.stderr)
480
+ return errors_as_strings([e for e in found if getattr(e, "severity", "error") == "error"])
470
481
 
471
482
 
472
483
  def resolve_dimensions(dsl: dict) -> tuple:
@@ -1245,8 +1256,19 @@ def _auto_highlight_map(entry: dict, narration_asset: Optional[dict], render_pla
1245
1256
  # outro counts toward line_count too; cap item_count at the authored
1246
1257
  # items-array length so trailing outro lines don't spill into card
1247
1258
  # indices.
1259
+ # ⚠️ 这份键表必须与模板侧 utils/slidePayload.ts 的 CARD_ARRAY_KEYS 完全一致:
1260
+ # 两处是同一算法的两份实现(这里是生产路径,那边是摊平 binder 形态下的本地兜底)。
1261
+ # 2026-09-15 修复:此前这里少了 steps / annotations,于是 step-flow 与
1262
+ # code-highlight 拿不到下面那行的上限裁剪,旁白带 outro 行时 highlightMap 会多出
1263
+ # 一个指向数组外的下标 —— 那一行播放期间"全部卡片变暗、没有一张点亮"。
1264
+ # 顺序也有意义:取第一个命中的键,新键一律追加在末尾。
1265
+ # template-library 的 `pnpm validate:contracts` 现在会双向核对这两份表。
1248
1266
  items_key = next(
1249
- (k for k in ("concepts", "pillars", "eras", "items") if isinstance(tdata.get(k), list)),
1267
+ (
1268
+ k
1269
+ for k in ("concepts", "pillars", "eras", "items", "steps", "annotations")
1270
+ if isinstance(tdata.get(k), list)
1271
+ ),
1250
1272
  None,
1251
1273
  )
1252
1274
  if items_key:
@@ -119,7 +119,7 @@ def _summarize(tpl: dict) -> dict:
119
119
  """The "choose a template" view: identity + selection criteria only.
120
120
 
121
121
  Deliberately excludes the authoring contract (llmHint in full,
122
- customPayloadSchema, slotMapping, compositions, variants' style bodies) —
122
+ customPayloadSchema, slideSchemas, slotMapping, compositions, variants' style bodies) —
123
123
  that is what ``--template-id`` returns, one template at a time.
124
124
  """
125
125
  variants = tpl.get("variants")
@@ -154,8 +154,8 @@ def _summarize(tpl: dict) -> dict:
154
154
  def _detail_hint(template_id: str = "<templateId>") -> str:
155
155
  return (
156
156
  f"Summaries only. Run --template-id {template_id} --json-output for one "
157
- "template's full definition (llmHint / customPayloadSchema / slotMapping / "
158
- "variants) — that is the per-template authoring contract."
157
+ "template's full definition (llmHint / customPayloadSchema / slideSchemas / "
158
+ "slotMapping / variants) — that is the per-template authoring contract."
159
159
  )
160
160
 
161
161
 
@@ -258,6 +258,7 @@ def main() -> None:
258
258
  "contract that does ship is the registry's template.json:\n"
259
259
  " • llmHint — how this template's on-screen text and layouts must be authored\n"
260
260
  " • customPayloadSchema — every template-specific field, incl. the legal slideId values\n"
261
+ " • slideSchemas — for multi-layout templates: what templateData each slideId eats\n"
261
262
  " • slotMapping — propExtractors / requiredProps / optionalProps\n"
262
263
  " Read it with `--template-id <id> --json-output` (one template's full "
263
264
  "definition). Run `--list-templates` first if you need the ids;\n"
@@ -394,7 +395,7 @@ def main() -> None:
394
395
  first_id = str(visible[0].get("templateId", "<templateId>"))
395
396
  print(
396
397
  f"Full definition of one template: --template-id {first_id} --json-output "
397
- "(llmHint / customPayloadSchema / slotMapping / variants)",
398
+ "(llmHint / customPayloadSchema / slideSchemas / slotMapping / variants)",
398
399
  )
399
400
 
400
401
 
@@ -20,6 +20,7 @@ DSL 校验器 - 对 Video DSL v1alpha1 做结构校验与标准化。
20
20
  处理的模板时**不需要再改 remixmate 代码**。
21
21
  """
22
22
 
23
+ import difflib
23
24
  import re
24
25
  from typing import Iterable, Optional
25
26
 
@@ -32,6 +33,22 @@ VALID_LAYOUTS = {"full-visual", "split-left-right", "split-top-bottom", "picture
32
33
  VALID_ASSET_TYPES = {"image", "video", "audio", "avatar", "subtitle", "bgm"}
33
34
  VALID_ASSET_SOURCES = {"existing", "gen-image", "gen-video", "gen-voice", "gen-digital-human"}
34
35
 
36
+ # 由 Composition 消费、而非 slide 组件消费的字段。前八个与模板侧
37
+ # utils/slidePayload.ts 的 FRAME_LEVEL_KEYS 同源 ——「整包态」binder 会把它们混进
38
+ # templateData,出现在那里是正当的,不该被当成未声明字段。highlightMap 同理(模板
39
+ # 一般在 slideSchemas._shared 里声明它,这里兜底没声明的情况)。
40
+ _SLIDE_FRAME_LEVEL_KEYS = frozenset({
41
+ "slideId",
42
+ "titleText",
43
+ "subtitleText",
44
+ "watermarkText",
45
+ "narrationAssetId",
46
+ "narrationSrc",
47
+ "background",
48
+ "templateData",
49
+ "highlightMap",
50
+ })
51
+
35
52
  # CJK 汉字范围(简繁通用)+ 常见中文标点
36
53
  _CJK_PATTERN = re.compile(r"[一-鿿㐀-䶿＀-￯ -〿]")
37
54
 
@@ -438,6 +455,446 @@ def _check_slide_id_chosen(dsl: dict) -> list[ValidationError]:
438
455
  return errors
439
456
 
440
457
 
458
+ def _check_slide_cross_field(dsl: dict) -> list[ValidationError]:
459
+ """字段之间的一致性 —— 单看任何一个字段都合法,凑在一起才不成立。
460
+
461
+ schema 管不了这一类:它逐字段校验类型与枚举,表达不了"这个数必须落在另一个字段的
462
+ 长度内"。三条规则都**静态可判定**,而且失败形态一致 —— 画面少东西、零报错:
463
+
464
+ · ``annotations[].line`` 指向不存在的代码行 → 那句旁白期间没有任何一行点亮
465
+ · ``highlightMap`` 的值越过卡片数组 → 那一行全屏变暗、无一张点亮
466
+ · 黑板版 ``highlight`` 不是 ``title`` 的子串 → 标题静默退回单色(模板 llmHint
467
+ 自己写明了这个行为,正因为写明了才更该校验)
468
+
469
+ 与 `_check_slide_payload_fields` 分开是因为那个函数按 schema 逐层递归,这里要的是
470
+ 跨字段视角;混在一起会让两边都难读。
471
+ """
472
+ tpl = _get_template_cfg(_pick_template_id(dsl))
473
+ if not tpl:
474
+ return []
475
+ schemas = tpl.get("slideSchemas")
476
+ if not isinstance(schemas, dict):
477
+ return []
478
+ line_sync = {k: v for k, v in (tpl.get("lineSyncSlides") or {}).items()
479
+ if isinstance(v, str)}
480
+
481
+ errors: list[ValidationError] = []
482
+
483
+ for idx, scene in enumerate(dsl.get("scenes", []) or []):
484
+ sid = scene.get("id", f"scenes[{idx}]")
485
+ custom = scene.get("customPayload") or {}
486
+ if not isinstance(custom, dict):
487
+ continue
488
+ slide_id = custom.get("slideId") or (custom.get("templateData") or {}).get("slideId")
489
+ if not isinstance(slide_id, str) or slide_id not in schemas:
490
+ continue
491
+ tdata = custom.get("templateData")
492
+ if not isinstance(tdata, dict):
493
+ tdata = scene.get("templateData") if isinstance(scene.get("templateData"), dict) else {}
494
+ if not tdata:
495
+ continue
496
+ base = f"scenes[{idx}].customPayload.templateData"
497
+
498
+ # ① 注解行号 ≤ 代码行数。`line` 是 1 起算的(契约里写明了)。
499
+ code = tdata.get("code")
500
+ annotations = tdata.get("annotations")
501
+ if isinstance(code, str) and isinstance(annotations, list):
502
+ total = len(code.split("\n"))
503
+ for i, note in enumerate(annotations):
504
+ if not isinstance(note, dict):
505
+ continue
506
+ line = note.get("line")
507
+ if not isinstance(line, int) or isinstance(line, bool):
508
+ continue
509
+ if line < 1 or line > total:
510
+ errors.append(ValidationError(
511
+ f"{base}.annotations[{i}].line",
512
+ f"scene '{sid}': annotation points at line {line} but the code block has "
513
+ f"{total} line(s) — while that narration line plays, no code line lights "
514
+ "up at all. Line numbers are 1-based.",
515
+ ))
516
+
517
+ # ② highlightMap 的值必须落在卡片数组里。
518
+ hmap = tdata.get("highlightMap")
519
+ cards_key = line_sync.get(slide_id)
520
+ cards = tdata.get(cards_key) if cards_key else None
521
+ if isinstance(hmap, dict) and isinstance(cards, list):
522
+ for k, v in hmap.items():
523
+ if not isinstance(v, int) or isinstance(v, bool):
524
+ continue
525
+ if v < 0 or v >= len(cards):
526
+ errors.append(ValidationError(
527
+ f"{base}.highlightMap.{k}",
528
+ f"scene '{sid}': highlightMap sends subtitle line {k} to card index {v}, "
529
+ f"but templateData.{cards_key} only has {len(cards)} entries — during that "
530
+ "line every card goes dim and none lights up.",
531
+ ))
532
+
533
+ # ③ 黑板版的 highlight 必须是 title 的子串。字段是否存在由 schema 决定,
534
+ # 这里不写死模板名 —— 没有声明 highlight 的模板天然跳过。
535
+ if "highlight" in ((schemas.get(slide_id) or {}).get("properties") or {}):
536
+ highlight = tdata.get("highlight")
537
+ title = tdata.get("title")
538
+ if (
539
+ isinstance(highlight, str)
540
+ and highlight.strip()
541
+ and isinstance(title, str)
542
+ and highlight not in title
543
+ ):
544
+ errors.append(ValidationError(
545
+ f"{base}.highlight",
546
+ f"scene '{sid}': highlight '{highlight}' is not a substring of title "
547
+ f"'{title}' — the two-colour split silently falls back to a single colour.",
548
+ severity="warning",
549
+ ))
550
+
551
+ return errors
552
+
553
+
554
+ def _check_line_sync_narration(dsl: dict) -> list[ValidationError]:
555
+ """走逐行高亮的版式,旁白必须是**分行**的,否则整屏一个卡片都不会点亮。
556
+
557
+ 为什么必须拦:这是"画面看起来没渲染完"的主路径,而且全链路零日志。链条是——
558
+ 旁白只给了整段 ``text``(单卡版式的合法写法)→ 绑定不写 ``narrationLineCount``
559
+ → ``render_video`` 的 ``_auto_highlight_map`` 在第一个 if 就 return → 没有
560
+ ``highlightMap`` → 模板侧 ``resolveHighlightMap`` 同样拿不到行数 →
561
+ ``activeHighlightIndex`` 恒为 -1 → **整场所有卡片停在未点亮态**。
562
+ 连 ``_auto_highlight_map`` 那条 warn 日志都打不出来,因为它在更前面就返回了。
563
+
564
+ 哪些版式受约束由模板的 ``lineSyncSlides`` 声明,不写死模板名/版式名。
565
+
566
+ 两个逃生阀(命中任一即放行,都是模板自己声明的能力,不是本函数的特例):
567
+ - ``templateData.highlightMap`` —— 作者显式给了映射表,那他知道自己在做什么;
568
+ - schema 里标了 ``x-staticHighlight`` 的字段(如 timeline-axis 的
569
+ ``progressIndex``、timeline / column-compare 的 ``highlightIndex``)——
570
+ 这些字段的存在本身就是"本屏走静态高亮、不跟旁白走"的声明。
571
+ """
572
+ tpl = _get_template_cfg(_pick_template_id(dsl))
573
+ if not tpl:
574
+ return []
575
+ line_sync = {k: v for k, v in (tpl.get("lineSyncSlides") or {}).items()
576
+ if isinstance(v, str)}
577
+ if not line_sync:
578
+ return []
579
+ schemas = tpl.get("slideSchemas") if isinstance(tpl.get("slideSchemas"), dict) else {}
580
+
581
+ errors: list[ValidationError] = []
582
+
583
+ for idx, scene in enumerate(dsl.get("scenes", []) or []):
584
+ sid = scene.get("id", f"scenes[{idx}]")
585
+ custom = scene.get("customPayload") or {}
586
+ if not isinstance(custom, dict):
587
+ continue
588
+ slide_id = custom.get("slideId") or (custom.get("templateData") or {}).get("slideId")
589
+ if not isinstance(slide_id, str) or slide_id not in line_sync:
590
+ continue
591
+
592
+ narration = (scene.get("audio") or {}).get("narration") or {}
593
+ items = narration.get("items")
594
+ if isinstance(items, list) and any(
595
+ (isinstance(x, str) and x.strip()) or isinstance(x, dict) for x in items
596
+ ):
597
+ continue # 有分行旁白,数量对不对交给 _check_narration_items_count
598
+
599
+ tdata = custom.get("templateData")
600
+ if not isinstance(tdata, dict):
601
+ tdata = scene.get("templateData") if isinstance(scene.get("templateData"), dict) else {}
602
+
603
+ if isinstance(tdata.get("highlightMap"), dict) and tdata["highlightMap"]:
604
+ continue
605
+
606
+ static_keys = [
607
+ k for k, sub in ((schemas.get(slide_id) or {}).get("properties") or {}).items()
608
+ if isinstance(sub, dict) and sub.get("x-staticHighlight") is True
609
+ ]
610
+ if any(tdata.get(k) is not None for k in static_keys):
611
+ continue
612
+
613
+ cards_key = line_sync[slide_id]
614
+ escape = (
615
+ f" If this scene is meant to stay static, set one of: {', '.join(sorted(static_keys))}."
616
+ if static_keys else ""
617
+ )
618
+ errors.append(ValidationError(
619
+ f"scenes[{idx}].audio.narration.items",
620
+ f"scene '{sid}': layout '{slide_id}' lights its {cards_key} one at a time, driven by "
621
+ "one narration line per card — but this scene's narration is a single block with no "
622
+ "'items'. Without per-line narration nothing ever lights up: every card stays in the "
623
+ "dimmed state for the whole scene and the frame looks half-rendered, with no error "
624
+ f"and no log. Split the narration into one line per entry of templateData.{cards_key} "
625
+ "under audio.narration.items." + escape,
626
+ ))
627
+
628
+ return errors
629
+
630
+
631
+ def _check_slide_payload_fields(dsl: dict) -> list[ValidationError]:
632
+ """逐版式校验 ``customPayload.templateData`` 的字段名。
633
+
634
+ 为什么需要这道门禁:多版式模板里「slideId 决定 templateData 吃哪些字段」这条
635
+ 契约,此前只存在于模板的 React 源码里 —— 而写 DSL 的 agent 只能看到 registry
636
+ 下发的 ``template.json``。字段名猜错(把 ``name`` 写成 ``title``、把 ``detail``
637
+ 写成 ``description``)时组件读到 undefined,要么渲染出内置的占位文案、要么整块
638
+ 空白,**渲染成功、零日志、退出码 0**。真实案例:一份 7 场景的 DSL 里 5 个场景
639
+ 中招,TTS 和渲染的钱全花完才在成片里看出来。
640
+
641
+ 契约现在写在 ``template.json`` 的 ``slideSchemas`` 里(键 = slideId,值 = 该版式
642
+ 的 JSON Schema),由 template-library 的 ``check:slide-schemas`` 与组件源码双向
643
+ 对账。本函数按它核对每个场景,**未声明的字段名带 "did you mean" 提示** ——
644
+ 收到这条错误的多半正是那个猜错了名字的 agent,直接把正确的名字给它。
645
+
646
+ 没有声明 ``slideSchemas`` 的模板(全部单版式模板)一律跳过,不产生噪音。
647
+ """
648
+ tpl = _get_template_cfg(_pick_template_id(dsl))
649
+ if not tpl:
650
+ return []
651
+ schemas = tpl.get("slideSchemas")
652
+ if not isinstance(schemas, dict):
653
+ return []
654
+
655
+ tid = tpl.get("templateId", "<unknown>")
656
+ shared = ((schemas.get("_shared") or {}).get("properties") or {})
657
+ known_slides = [k for k in schemas if not k.startswith("_")]
658
+
659
+ errors: list[ValidationError] = []
660
+
661
+ for idx, scene in enumerate(dsl.get("scenes", []) or []):
662
+ sid = scene.get("id", f"scenes[{idx}]")
663
+ custom = scene.get("customPayload") or {}
664
+ if not isinstance(custom, dict):
665
+ continue
666
+ data = custom.get("templateData")
667
+ if not isinstance(data, dict):
668
+ data = scene.get("templateData")
669
+ if not isinstance(data, dict):
670
+ continue
671
+
672
+ slide_id = custom.get("slideId") or data.get("slideId")
673
+ if not isinstance(slide_id, str) or not slide_id:
674
+ continue # 「没选版式」由 _check_slide_id_chosen 负责报
675
+
676
+ schema = schemas.get(slide_id)
677
+ if not isinstance(schema, dict):
678
+ # 版式名写错 —— 同样静默回落到 DefaultSlide,和没写一样致命。
679
+ errors.append(ValidationError(
680
+ f"scenes[{idx}].customPayload.slideId",
681
+ f"scene '{sid}' uses slideId '{slide_id}', which template '{tid}' does not "
682
+ "register — the frame silently falls back to a single centred title.\n"
683
+ f" Available layouts: {', '.join(sorted(known_slides))}",
684
+ ))
685
+ continue
686
+
687
+ errors += _walk_payload(
688
+ data,
689
+ schema,
690
+ path=f"scenes[{idx}].customPayload.templateData",
691
+ scene=sid,
692
+ extra_allowed=shared,
693
+ # frame 级字段由 Composition 消费,「整包态」binder 会把它们混进
694
+ # templateData —— 出现在这里是正当的,不算未声明字段。
695
+ skip_keys=_SLIDE_FRAME_LEVEL_KEYS,
696
+ )
697
+
698
+ return errors
699
+
700
+
701
+ def _check_custom_payload_fields(dsl: dict) -> list[ValidationError]:
702
+ """单版式模板:按 ``customPayloadSchema`` 逐字段核对 ``customPayload``。
703
+
704
+ 与 `_check_slide_payload_fields` 是同一件事的两种形态 —— 多版式模板的契约按 slideId
705
+ 分支(``slideSchemas``),单版式模板只有一份(``customPayloadSchema``)。多版式模板在
706
+ 这里**跳过**,避免同一份数据被两套规则各报一遍。
707
+
708
+ 为什么值得做:在此之前,10 个模板里只有 2 个的字段名会被校验,其余 8 个写错字段名
709
+ 照样一路渲染到成片。契约本来就在 ``template.json`` 里躺着,只是没人读。
710
+ """
711
+ tpl = _get_template_cfg(_pick_template_id(dsl))
712
+ if not tpl:
713
+ return []
714
+ if isinstance(tpl.get("slideSchemas"), dict):
715
+ return [] # 多版式模板走 _check_slide_payload_fields
716
+ cps = tpl.get("customPayloadSchema")
717
+ if not isinstance(cps, dict) or not cps:
718
+ return []
719
+
720
+ root = {"type": "object", "properties": cps}
721
+ errors: list[ValidationError] = []
722
+ for idx, scene in enumerate(dsl.get("scenes", []) or []):
723
+ custom = scene.get("customPayload")
724
+ if not isinstance(custom, dict) or not custom:
725
+ continue
726
+ errors += _walk_payload(
727
+ custom,
728
+ root,
729
+ path=f"scenes[{idx}].customPayload",
730
+ scene=scene.get("id", f"scenes[{idx}]"),
731
+ )
732
+ return errors
733
+
734
+
735
+ def _walk_payload(
736
+ data: dict,
737
+ schema: dict,
738
+ *,
739
+ path: str,
740
+ scene: str,
741
+ extra_allowed: Optional[dict] = None,
742
+ skip_keys: frozenset = frozenset(),
743
+ fallback_desc: Optional[str] = None,
744
+ ) -> list[ValidationError]:
745
+ """沿 schema 递归下钻,逐层做「未声明字段 + 缺必填 + 空数组」核对。
746
+
747
+ 只下钻**描述了形状的**层:schema 节点没有 ``properties`` 就停(那是作者显式开的
748
+ 开放包,如 ppt-to-video 由抽取脚本生成的 ``slide.nodes[]``),不去猜它的字段。
749
+
750
+ ⚠️ 这里刻意**不做**类型 / enum / 区间校验 —— 那些由 template-library 的
751
+ `check:payload` 在仓库侧对 dsl-example 做,已有一份完整实现。运行时这份只管
752
+ "字段名对不对、该给的给没给",两边职责不重叠,也就不需要在 Python 里再养一份
753
+ JSON Schema 引擎(这个仓库已经被"同一算法两份实现"坑过不止一次)。
754
+ """
755
+ errors = _check_payload_object(
756
+ data,
757
+ schema,
758
+ path=path,
759
+ scene=scene,
760
+ extra_allowed=extra_allowed,
761
+ skip_keys=skip_keys,
762
+ fallback_desc=fallback_desc,
763
+ )
764
+
765
+ own_desc = schema.get("description") if isinstance(schema.get("description"), str) else None
766
+
767
+ for key, sub in (schema.get("properties") or {}).items():
768
+ if not isinstance(sub, dict):
769
+ continue
770
+ value = data.get(key)
771
+ if sub.get("type") == "object" and sub.get("properties") and isinstance(value, dict):
772
+ errors += _walk_payload(
773
+ value, sub, path=f"{path}.{key}", scene=scene,
774
+ fallback_desc=own_desc or fallback_desc,
775
+ )
776
+ elif sub.get("type") == "array" and isinstance(value, list):
777
+ item_schema = sub.get("items")
778
+ if not isinstance(item_schema, dict) or not item_schema.get("properties"):
779
+ continue
780
+ for i, item in enumerate(value):
781
+ if isinstance(item, dict):
782
+ errors += _walk_payload(
783
+ item, item_schema, path=f"{path}.{key}[{i}]", scene=scene,
784
+ fallback_desc=own_desc or fallback_desc,
785
+ )
786
+
787
+ return errors
788
+
789
+
790
+ def _check_payload_object(
791
+ data: dict,
792
+ schema: dict,
793
+ *,
794
+ path: str,
795
+ scene: str,
796
+ extra_allowed: Optional[dict] = None,
797
+ skip_keys: frozenset = frozenset(),
798
+ fallback_desc: Optional[str] = None,
799
+ ) -> list[ValidationError]:
800
+ """一层对象的「未声明字段 + 缺必填字段 + 空数组」核对。数组元素与顶层共用这一份。"""
801
+ props = schema.get("properties")
802
+ if not isinstance(props, dict):
803
+ return []
804
+ allowed = set(props) | set(extra_allowed or {})
805
+ merged_props = {**(extra_allowed or {}), **props}
806
+
807
+ errors: list[ValidationError] = []
808
+
809
+ for key in data:
810
+ if key in allowed or key in skip_keys:
811
+ continue
812
+ errors.append(ValidationError(
813
+ f"{path}.{key}",
814
+ f"scene '{scene}': the slide does not read '{key}' — the value is silently "
815
+ f"dropped and that slot renders empty (or shows the component's placeholder)."
816
+ + _suggest_field(key, merged_props, schema, fallback_desc),
817
+ ))
818
+
819
+ for key in schema.get("required") or []:
820
+ if _is_blank(data.get(key)):
821
+ errors.append(ValidationError(
822
+ f"{path}.{key}",
823
+ f"scene '{scene}': required field '{key}' is missing or empty — without it "
824
+ "the slide renders its built-in placeholder instead of your content.",
825
+ ))
826
+
827
+ # 空数组 = 空屏。这些数组就是版式的画面主体,给了个 [] 与没给没有区别,
828
+ # 但 required 判定不出来(键确实在)。用 schema 的 minItems 表达,不写死键名。
829
+ for key, sub in props.items():
830
+ if not isinstance(sub, dict) or sub.get("type") != "array":
831
+ continue
832
+ min_items = sub.get("minItems")
833
+ value = data.get(key)
834
+ if isinstance(min_items, int) and isinstance(value, list) and len(value) < min_items:
835
+ errors.append(ValidationError(
836
+ f"{path}.{key}",
837
+ f"scene '{scene}': '{key}' has {len(value)} entries but this layout needs at "
838
+ f"least {min_items} — the array is what the slide draws, an empty one renders "
839
+ "a blank frame.",
840
+ ))
841
+
842
+ return errors
843
+
844
+
845
+ def _is_blank(value: object) -> bool:
846
+ """必填判定:``None`` 与纯空白字符串都算没给。
847
+
848
+ 为什么空串也算:组件里普遍写成 ``props.title || "占位文案"``,而 ``""`` 是 falsy ——
849
+ 写空串拿到的**同样是占位文案**,不是空白。也就是说空串从来就不是"有意留空"的表达,
850
+ 只是另一种漏填。放行它等于给静默失效留一道后门。
851
+ """
852
+ if value is None:
853
+ return True
854
+ return isinstance(value, str) and not value.strip()
855
+
856
+
857
+ def _suggest_field(
858
+ wrong: str,
859
+ props: dict,
860
+ schema: dict,
861
+ fallback_desc: Optional[str] = None,
862
+ ) -> str:
863
+ """把写错的字段名换成一句能照着改的提示。三级降级。
864
+
865
+ ① **在各字段的 description 里搜「不是 <wrong>」**。写契约时易错点已经写进了描述
866
+ (``卡片标题(⚠️ 键名是 name,不是 title)``),直接拿错的名字去搜即可。实测对
867
+ 真实事故里那 6 个错误 6/6 命中,而 difflib 是 0/6 —— 因为 title↔name、
868
+ description↔detail 是**同义词**而非拼写错误,字符串相似度对它们天然无效。
869
+ 这一级还自带维护性:提示与契约同处一地,改字段名时顺手就改了。
870
+ ② difflib —— 留给真正的拼写错误(``titel`` → ``title``)。
871
+ ③ 列出全部合法字段,并附上**该版式自己的 description**。后者回答的是"那我该用哪个
872
+ 版式":demo-concept-overview 的描述里就写着「卡片不支持描述文字,要配说明改用
873
+ feature-grid」,而这正是写错的人真正需要的下一步。
874
+ """
875
+ for name, sub in props.items():
876
+ desc = (sub or {}).get("description") if isinstance(sub, dict) else None
877
+ if not isinstance(desc, str):
878
+ continue
879
+ # 「不是 title」/「不是 title,」/「不是 `title`」都要认。
880
+ if re.search(rf"不是\s*[`'\"]?{re.escape(wrong)}[`'\"]?(?![\w-])", desc):
881
+ return f" This layout calls it '{name}' — {desc}"
882
+
883
+ hit = difflib.get_close_matches(wrong, sorted(props), n=1, cutoff=0.6)
884
+ if hit:
885
+ return f" Did you mean '{hit[0]}'?"
886
+
887
+ tail = f" Declared fields: {', '.join(sorted(props))}."
888
+ # 走到这一级说明「改个名字」解决不了 —— 多半是这个版式压根没有这种能力。
889
+ # 把版式自己的说明带出来,那里通常写着该换成哪个版式(demo-concept-overview 的
890
+ # 描述就写着「卡片不支持描述文字,每项要配一句说明时改用 feature-grid」)。
891
+ # 嵌套 item 层没有自己的 description,所以由调用方把版式级的那句传进来。
892
+ slide_desc = schema.get("description") or fallback_desc
893
+ if isinstance(slide_desc, str) and slide_desc.strip():
894
+ tail += f" About this layout: {slide_desc}"
895
+ return tail
896
+
897
+
441
898
  def _check_narration_language_strict(dsl: dict) -> list[ValidationError]:
442
899
  """完整版:模板若声明 ``capabilities.narrationLanguageStrict`` 则强约束旁白语言。
443
900
 
@@ -525,6 +982,12 @@ def validate_integrity(dsl: dict) -> list[ValidationError]:
525
982
  # render-video 与 prepare_video_assets 都走这条 —— 而"没选版式"正是要在烧掉渲染
526
983
  # 积分之前拦住的东西。单版式模板不受影响,见 _check_slide_id_chosen。
527
984
  errors += _check_slide_id_chosen(dsl)
985
+ # 同理:字段名写错同样是「渲染成功但画面是空壳」,必须在烧掉 TTS / 渲染积分
986
+ # 之前拦住。只对声明了 slideSchemas 的模板生效,见 _check_slide_payload_fields。
987
+ errors += _check_slide_payload_fields(dsl)
988
+ errors += _check_custom_payload_fields(dsl)
989
+ errors += _check_line_sync_narration(dsl)
990
+ errors += _check_slide_cross_field(dsl)
528
991
  return errors
529
992
 
530
993
 
@@ -553,6 +1016,10 @@ def validate_dsl(dsl: dict) -> list[ValidationError]:
553
1016
  errors += _check_narration_items_count(dsl)
554
1017
  errors += _check_narration_language_strict(dsl)
555
1018
  errors += _check_slide_id_chosen(dsl)
1019
+ errors += _check_slide_payload_fields(dsl)
1020
+ errors += _check_custom_payload_fields(dsl)
1021
+ errors += _check_line_sync_narration(dsl)
1022
+ errors += _check_slide_cross_field(dsl)
556
1023
  return errors
557
1024
 
558
1025