@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.
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remixmate/cli",
|
|
3
|
-
"version": "0.9.
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
515
|
-
errors += _check_payload_object(
|
|
687
|
+
errors += _walk_payload(
|
|
516
688
|
data,
|
|
517
689
|
schema,
|
|
518
|
-
path=
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
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)
|
|
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}':
|
|
583
|
-
"
|
|
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
|
|