@remixmate/cli 0.9.32 → 0.9.34

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.32",
4
- "generatedAt": "2026-09-15T01:33:27.056Z",
3
+ "version": "0.9.34",
4
+ "generatedAt": "2026-09-16T14:16:15.526Z",
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.32",
3
+ "version": "0.9.34",
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",
@@ -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:
@@ -93,6 +93,18 @@ def create_job(priv_token: str, dsl: str = "", dsl_meta: dict = None) -> int:
93
93
  """
94
94
  创建渲染任务。
95
95
 
96
+ 会话归属:本函数会自动带上环境变量 CONVERSATION_ID(若已设置),让服务端在
97
+ 创建时就把 job 挂到该会话的 take 上。
98
+
99
+ 为什么要在这里传:渲染任务过去出生就是孤儿——归属全靠事后一次独立的补绑调用,
100
+ 而那条路上有四个静默盲区(CLI 的 take 绑定在 job 出生前就跑完了;云端因为
101
+ CONVERSATION_ID 已被注入而整段跳过它;兜底补绑的 jobId 末位来源是从 stdout
102
+ 正则抠的)。归属断了,成片在旁白面板里就查不到已生成的音频。
103
+ 而 CONVERSATION_ID 在这一刻本来就已经在环境里了——信息在场,以前只是没传。
104
+ 见 ab-platform/docs/render-job-binding-fix.md。
105
+
106
+ 服务端对该字段是软失败:绑不上只记日志,不影响任务创建。
107
+
96
108
  Args:
97
109
  priv_token: PrivToken
98
110
  dsl: 完整 DSL JSON 字符串(可选)
@@ -107,6 +119,10 @@ def create_job(priv_token: str, dsl: str = "", dsl_meta: dict = None) -> int:
107
119
  if dsl_meta:
108
120
  payload["dslMeta"] = dsl_meta
109
121
 
122
+ conversation_id = os.environ.get("CONVERSATION_ID", "").strip()
123
+ if conversation_id:
124
+ payload["conversationId"] = conversation_id
125
+
110
126
  data = _post("/renderJob/create", payload, priv_token)
111
127
  job_id = data.get("jobId")
112
128
  if not job_id:
@@ -455,6 +455,179 @@ def _check_slide_id_chosen(dsl: dict) -> list[ValidationError]:
455
455
  return errors
456
456
 
457
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
+
458
631
  def _check_slide_payload_fields(dsl: dict) -> list[ValidationError]:
459
632
  """逐版式校验 ``customPayload.templateData`` 的字段名。
460
633
 
@@ -511,11 +684,10 @@ def _check_slide_payload_fields(dsl: dict) -> list[ValidationError]:
511
684
  ))
512
685
  continue
513
686
 
514
- base = f"scenes[{idx}].customPayload.templateData"
515
- errors += _check_payload_object(
687
+ errors += _walk_payload(
516
688
  data,
517
689
  schema,
518
- path=base,
690
+ path=f"scenes[{idx}].customPayload.templateData",
519
691
  scene=sid,
520
692
  extra_allowed=shared,
521
693
  # frame 级字段由 Composition 消费,「整包态」binder 会把它们混进
@@ -523,22 +695,94 @@ def _check_slide_payload_fields(dsl: dict) -> list[ValidationError]:
523
695
  skip_keys=_SLIDE_FRAME_LEVEL_KEYS,
524
696
  )
525
697
 
526
- # 卡片数组:用户真正会写错的地方在这一层(items[].title vs items[].name)。
527
- for key, sub in (schema.get("properties") or {}).items():
528
- if not isinstance(sub, dict) or sub.get("type") != "array":
529
- continue
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):
530
777
  item_schema = sub.get("items")
531
778
  if not isinstance(item_schema, dict) or not item_schema.get("properties"):
532
779
  continue
533
- value = data.get(key)
534
- if not isinstance(value, list):
535
- continue
536
780
  for i, item in enumerate(value):
537
- if not isinstance(item, dict):
538
- continue
539
- errors += _check_payload_object(
540
- item, item_schema, path=f"{base}.{key}[{i}]", scene=sid,
541
- )
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
+ )
542
786
 
543
787
  return errors
544
788
 
@@ -551,41 +795,106 @@ def _check_payload_object(
551
795
  scene: str,
552
796
  extra_allowed: Optional[dict] = None,
553
797
  skip_keys: frozenset = frozenset(),
798
+ fallback_desc: Optional[str] = None,
554
799
  ) -> list[ValidationError]:
555
- """一层对象的「未声明字段 + 缺必填字段」核对。数组元素与顶层共用这一份。"""
800
+ """一层对象的「未声明字段 + 缺必填字段 + 空数组」核对。数组元素与顶层共用这一份。"""
556
801
  props = schema.get("properties")
557
802
  if not isinstance(props, dict):
558
803
  return []
559
804
  allowed = set(props) | set(extra_allowed or {})
805
+ merged_props = {**(extra_allowed or {}), **props}
560
806
 
561
807
  errors: list[ValidationError] = []
562
808
 
563
809
  for key in data:
564
810
  if key in allowed or key in skip_keys:
565
811
  continue
566
- hint = difflib.get_close_matches(key, sorted(allowed), n=1, cutoff=0.6)
567
- suggestion = (
568
- f" Did you mean '{hint[0]}'?" if hint
569
- else f" Declared fields: {', '.join(sorted(allowed))}."
570
- )
571
812
  errors.append(ValidationError(
572
813
  f"{path}.{key}",
573
814
  f"scene '{scene}': the slide does not read '{key}' — the value is silently "
574
815
  f"dropped and that slot renders empty (or shows the component's placeholder)."
575
- f"{suggestion}",
816
+ + _suggest_field(key, merged_props, schema, fallback_desc),
576
817
  ))
577
818
 
578
819
  for key in schema.get("required") or []:
579
- if data.get(key) is None:
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:
580
835
  errors.append(ValidationError(
581
836
  f"{path}.{key}",
582
- f"scene '{scene}': required field '{key}' is missing — without it the slide "
583
- "renders its built-in placeholder instead of your content.",
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.",
584
840
  ))
585
841
 
586
842
  return errors
587
843
 
588
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
+
589
898
  def _check_narration_language_strict(dsl: dict) -> list[ValidationError]:
590
899
  """完整版:模板若声明 ``capabilities.narrationLanguageStrict`` 则强约束旁白语言。
591
900
 
@@ -676,6 +985,9 @@ def validate_integrity(dsl: dict) -> list[ValidationError]:
676
985
  # 同理:字段名写错同样是「渲染成功但画面是空壳」,必须在烧掉 TTS / 渲染积分
677
986
  # 之前拦住。只对声明了 slideSchemas 的模板生效,见 _check_slide_payload_fields。
678
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)
679
991
  return errors
680
992
 
681
993
 
@@ -705,6 +1017,9 @@ def validate_dsl(dsl: dict) -> list[ValidationError]:
705
1017
  errors += _check_narration_language_strict(dsl)
706
1018
  errors += _check_slide_id_chosen(dsl)
707
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)
708
1023
  return errors
709
1024
 
710
1025