@remixmate/cli 0.1.0

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 (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +166 -0
  3. package/dist/argv.d.ts +15 -0
  4. package/dist/argv.js +51 -0
  5. package/dist/cli.d.ts +13 -0
  6. package/dist/cli.js +83 -0
  7. package/dist/handlers/gen-image.d.ts +13 -0
  8. package/dist/handlers/gen-image.js +161 -0
  9. package/dist/handlers/gen-voice.d.ts +16 -0
  10. package/dist/handlers/gen-voice.js +110 -0
  11. package/dist/handlers/index.d.ts +13 -0
  12. package/dist/handlers/index.js +14 -0
  13. package/dist/handlers/template-bind.d.ts +15 -0
  14. package/dist/handlers/template-bind.js +149 -0
  15. package/dist/http.d.ts +52 -0
  16. package/dist/http.js +110 -0
  17. package/dist/index.d.ts +9 -0
  18. package/dist/index.js +8 -0
  19. package/dist/manifest.json +260 -0
  20. package/dist/progress.d.ts +23 -0
  21. package/dist/progress.js +15 -0
  22. package/dist/registry.d.ts +33 -0
  23. package/dist/registry.js +72 -0
  24. package/dist/runner.d.ts +17 -0
  25. package/dist/runner.js +63 -0
  26. package/package.json +54 -0
  27. package/skills/export-jianying/SKILL.md +251 -0
  28. package/skills/export-jianying/scripts/gen_jianying_draft.py +1099 -0
  29. package/skills/export-jianying/skill.json +24 -0
  30. package/skills/export-jianying/version.json +7 -0
  31. package/skills/gen-digital-human/SKILL.md +211 -0
  32. package/skills/gen-digital-human/scripts/gen_digital_human_video.py +574 -0
  33. package/skills/gen-digital-human/skill.json +23 -0
  34. package/skills/gen-digital-human/version.json +7 -0
  35. package/skills/gen-image/SKILL.md +130 -0
  36. package/skills/gen-image/skill.json +22 -0
  37. package/skills/gen-image/version.json +7 -0
  38. package/skills/gen-script/SKILL.md +283 -0
  39. package/skills/gen-script/scripts/gen_script.py +1208 -0
  40. package/skills/gen-script/skill.json +41 -0
  41. package/skills/gen-script/version.json +7 -0
  42. package/skills/gen-video/SKILL.md +170 -0
  43. package/skills/gen-video/scripts/gen_video.py +591 -0
  44. package/skills/gen-video/skill.json +24 -0
  45. package/skills/gen-video/version.json +7 -0
  46. package/skills/gen-voice/SKILL.md +104 -0
  47. package/skills/gen-voice/skill.json +21 -0
  48. package/skills/gen-voice/version.json +7 -0
  49. package/skills/prepare-video-assets/SKILL.md +192 -0
  50. package/skills/prepare-video-assets/scripts/prepare_video_assets.py +57 -0
  51. package/skills/prepare-video-assets/skill.json +23 -0
  52. package/skills/prepare-video-assets/version.json +7 -0
  53. package/skills/render-video/SKILL.md +187 -0
  54. package/skills/render-video/scripts/_chrome_vendor.py +305 -0
  55. package/skills/render-video/scripts/_video_probe.py +174 -0
  56. package/skills/render-video/scripts/_vod_polling.py +86 -0
  57. package/skills/render-video/scripts/remote_renderer_client.py +236 -0
  58. package/skills/render-video/scripts/render_video.py +2288 -0
  59. package/skills/render-video/scripts/upload_video.py +193 -0
  60. package/skills/render-video/skill.json +21 -0
  61. package/skills/render-video/version.json +7 -0
  62. package/skills/template-bind/README.md +63 -0
  63. package/skills/template-bind/SKILL.md +234 -0
  64. package/skills/template-bind/scripts/check_contracts.py +196 -0
  65. package/skills/template-bind/scripts/match_template.py +215 -0
  66. package/skills/template-bind/scripts/registry_loader.py +379 -0
  67. package/skills/template-bind/scripts/render_job_client.py +162 -0
  68. package/skills/template-bind/scripts/sync_registry.py +44 -0
  69. package/skills/template-bind/scripts/template_paths.py +72 -0
  70. package/skills/template-bind/skill.json +20 -0
  71. package/skills/template-bind/version.json +7 -0
  72. package/skills/template-bind/video_dsl/README.md +97 -0
  73. package/skills/template-bind/video_dsl/runtime/__init__.py +11 -0
  74. package/skills/template-bind/video_dsl/runtime/dsl_validator.py +495 -0
  75. package/skills/template-bind/video_dsl/runtime/prompt_enhancer.py +274 -0
  76. package/skills/template-bind/video_dsl/runtime/template_binder.py +87 -0
  77. package/skills/template-bind/video_dsl/runtime/timeline_compiler.py +312 -0
  78. package/skills/template-bind/video_dsl/schema/render-plan-v1alpha1.json +193 -0
  79. package/skills/template-bind/video_dsl/schema/template-binding-v1alpha1.json +122 -0
  80. package/skills/template-bind/video_dsl/schema/template-definition-v1alpha1.json +247 -0
  81. package/skills/template-bind/video_dsl/schema/video-dsl-v1alpha1.json +378 -0
  82. package/skills/video-parser/SKILL.md +238 -0
  83. package/skills/video-parser/scripts/analyze_video.py +1038 -0
  84. package/skills/video-parser/scripts/deconstruct_video.py +602 -0
  85. package/skills/video-parser/scripts/parse_via_render.py +148 -0
  86. package/skills/video-parser/skill.json +21 -0
  87. package/skills/video-parser/version.json +7 -0
  88. package/skills/web-capture/SKILL.md +183 -0
  89. package/skills/web-capture/scripts/web_capture.py +461 -0
  90. package/skills/web-capture/skill.json +29 -0
  91. package/skills/web-capture/version.json +7 -0
@@ -0,0 +1,7 @@
1
+ {
2
+ "skillName": "template-bind",
3
+ "repoName": "agent-skill-media-maker",
4
+ "skillId": "475",
5
+ "version": "V10",
6
+ "skillDescription": "视频模板仓库与绑定技能,存储所有视频模板定义,根据指定模板 ID 生成 TemplateBinding。\n\n当用户提到以下任何需求时,立即使用本 skill:\n- 查看可用模板、列出所有模板\n- 为 DSL 绑定模板(需指定 template-id)\n\n当 Agent 拿到 Video DSL 需要生成 TemplateBinding 时,也应使用本 skill。"
7
+ }
@@ -0,0 +1,97 @@
1
+ # Video DSL 体系
2
+
3
+ 本目录包含视频内容生产的核心数据定义:DSL Schema、模板注册表、运行时工具和可复用组件库。
4
+
5
+ ## 目录结构
6
+
7
+ ```
8
+ video_dsl/
9
+ ├── schema/ # DSL Schema 定义
10
+ │ ├── video-dsl-v1alpha1.json # Video DSL JSON Schema
11
+ │ ├── template-definition-v1alpha1.json # 模板定义 Schema
12
+ │ ├── template-binding-v1alpha1.json # 模板绑定 Schema
13
+ │ ├── render-plan-v1alpha1.json # RenderPlan Schema
14
+ │ └── examples/ # 各模板的 DSL + Binding 示例
15
+ │ ├── ai-learning-tips.dsl.json
16
+ │ ├── ai-learning-tips.binding.json
17
+ │ ├── html-slide-demo.dsl.json
18
+ │ └── ...
19
+ ├── runtime/ # DSL 运行时工具
20
+ │ ├── dsl_validator.py # DSL Schema 校验
21
+ │ ├── template_binder.py # Scene → Slot 绑定(从 @ab-templates/metadata 读取模板)
22
+ │ ├── timeline_compiler.py # 时间线编译(帧号、字幕切片、旁白分段)
23
+ │ ├── asset_adapter.py # 素材适配器
24
+ │ └── prompt_enhancer.py # Prompt 增强
25
+ └── library/ # 可复用组件库(预留)
26
+ ├── blocks/ # 功能块(如 CTA、数字人、全屏背景)
27
+ ├── components/ # 基础组件(音频、文本、背景、装饰)
28
+ ├── scenes/ # 场景模板(开场钩子、知识点、绘本页)
29
+ └── videos/ # 完整视频模板(英文绘本等)
30
+ ```
31
+
32
+ > **注意**:模板元数据(template.json / registry.json)已迁移至独立的 `template-library` 仓库,
33
+ > 通过 `@ab-templates/metadata` 包发布。`template_binder.py` 直接读取该包产出的 `registry.json`。
34
+
35
+ ## 核心概念
36
+
37
+ ### 三个中间态
38
+
39
+ | 中间态 | Schema | 职责 |
40
+ |--------|--------|------|
41
+ | **Video DSL** | `video-dsl-v1alpha1.json` | 描述视频应该长什么样(场景、素材、旁白) |
42
+ | **TemplateBinding** | `template-binding-v1alpha1.json` | 描述 scene 如何映射到模板 slot |
43
+ | **RenderPlan** | `render-plan-v1alpha1.json` | DSL 与渲染引擎之间的防腐层 |
44
+
45
+ ### 数据流
46
+
47
+ ```
48
+ 主题 → [gen-script] → Video DSL
49
+
50
+ [template-bind] → TemplateBinding
51
+
52
+ [render-video] → RenderPlan → MP4 / 剪映草稿
53
+ ```
54
+
55
+ ## 模板列表
56
+
57
+ | 模板 ID | 名称 | 适用场景 |
58
+ |---------|------|----------|
59
+ | `image-slide` | 图片幻灯片 | 通用知识分享、产品介绍(含 tech 科技风变体) |
60
+ | `picture-book-en` | 英文绘本视频 | 儿童英语启蒙、双语绘本故事 |
61
+ | `image-slideshow` | 图片轮播视频 | 知识分享、种草笔记、技巧总结 |
62
+ | `knowledge-slides` | 知识幻灯片 | 方法论拆解、教程讲解、知识科普 |
63
+ | `html-slide` | 知识看板 | 知识科普、概念讲解、白板风格内容 |
64
+
65
+ ## runtime 模块说明
66
+
67
+ | 模块 | 职责 | 被谁调用 |
68
+ |------|------|----------|
69
+ | `dsl_validator.py` | DSL JSON Schema 校验与参数标准化 | gen-script、render-video |
70
+ | `template_binder.py` | 将 DSL scene 映射到模板 slot,生成 TemplateBinding(从 @ab-templates/metadata 读取模板定义) | template-bind |
71
+ | `timeline_compiler.py` | 帧号计算、字幕切片(`split_subtitle`)、旁白分段(`segment_narration`) | render-video(跨 skill import) |
72
+ | `asset_adapter.py` | 素材格式适配 | render-video |
73
+ | `prompt_enhancer.py` | 图片 prompt 增强 | render-video |
74
+
75
+ **注意**:`timeline_compiler.py` 被 `render-video/scripts/render_video.py` 直接 import,修改函数签名需两个 skill 同步测试。
76
+
77
+ ## library 组件库(预留)
78
+
79
+ `library/` 目录采用四层分级设计,用于存放可复用的 DSL 片段:
80
+
81
+ - **components/**:最小粒度的基础组件(音频、文本、背景、头像、装饰、媒体、叠加层)
82
+ - **blocks/**:由多个 component 组合而成的功能块(CTA 按钮、数字人区域、全屏背景图、标题区、产品卡片、配音条、双语字幕)
83
+ - **scenes/**:完整的场景模板(开场钩子、知识点讲解、绘本封面/内页/结尾、CTA 结尾、数字人口播)
84
+ - **videos/**:完整的视频模板(英文绘本等)
85
+
86
+ 组件库当前为目录骨架,后续可填充具体的 JSON 片段供 Agent 和 gen_script.py 复用。
87
+
88
+ ## 示例文件
89
+
90
+ `schema/examples/` 下包含各模板的完整 DSL + Binding 示例,是 Agent 生成 DSL 时的权威参考:
91
+
92
+ - `ai-learning-tips.*` — image-slide 模板示例
93
+ - `html-slide-demo.*` — html-slide 模板示例
94
+ - `knowledge-slides-demo.*` — knowledge-slides 模板示例
95
+ - `animal-friends-picturebook.*` — picture-book-en 模板示例
96
+
97
+ Agent 在为指定模板生成 DSL 时,必须先读取对应示例,按模板原生要求生成。
@@ -0,0 +1,11 @@
1
+ """Package marker for ``video_dsl.runtime`` — kept intentionally empty.
2
+
3
+ This file exists so ``from video_dsl.runtime.<mod> import …`` resolves when
4
+ ``skills/template-bind/`` is on sys.path (the pattern used by render-video
5
+ and gen-script). Public modules:
6
+
7
+ - timeline_compiler : DSL → Remotion frame-axis timeline + subtitle splits
8
+ - prompt_enhancer : LLM image-prompt enrichment used by gen-script
9
+ - template_binder : registry summary writer (sync_registry helper)
10
+ - dsl_validator : DSL validation (structural / integrity / full presets)
11
+ """
@@ -0,0 +1,495 @@
1
+ """
2
+ DSL 校验器 - 对 Video DSL v1alpha1 做结构校验与标准化。
3
+
4
+ 模块结构(T6 重构后):
5
+
6
+ * 细粒度 ``_check_*`` 检查器:每个函数检查一个独立维度,返回
7
+ ``list[ValidationError]``。
8
+ * 三个 preset 聚合器,对应历史上 gen-script / render-video / 完整版三种
9
+ 使用场景:
10
+ - ``validate_structural(dsl)`` ⇐ gen-script 历史调用点
11
+ - ``validate_integrity(dsl)`` ⇐ render-video 历史调用点
12
+ - ``validate_dsl(dsl)`` ⇐ 完整版(含 capabilities-driven 语言约束、字段枚举等)
13
+ 历史的两个 caller 现在 thin-wrap 对应 preset,**所检规则与历史完全等价**,
14
+ 以保证不引入任何"原本能过、现在被拒"的行为变化。
15
+
16
+ 模块以前同时存在三份算法(在 gen_script.py、render_video.py 与本文件内),
17
+ ``lineSyncSlides`` 注册表查询、items 数对齐、模板专属语言约束等代码在三份
18
+ 之间已经静默漂移。统一到本文件后,所有维度只有一份实现。所有模板差异通过
19
+ ``template.json`` 的 ``capabilities`` 字段声明,本文件读取并应用 —— 新增需要特殊
20
+ 处理的模板时**不需要再改 ab-skill 代码**。
21
+ """
22
+
23
+ import re
24
+ from typing import Iterable, Optional
25
+
26
+ VALID_VERSIONS = {"v1alpha1"}
27
+ VALID_PLATFORMS = {"douyin", "xiaohongshu", "bilibili", "wechat", "youtube", "generic"}
28
+ VALID_RATIOS = {"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"}
29
+ VALID_RESOLUTIONS = {"480p", "720p", "1080p", "4k"}
30
+ VALID_PURPOSES = {"opening", "intro", "point", "example", "explanation", "transition", "highlight", "cta", "ending"}
31
+ VALID_LAYOUTS = {"full-visual", "split-left-right", "split-top-bottom", "picture-in-picture", "text-overlay", "avatar-with-bg", "kenburns"}
32
+ VALID_ASSET_TYPES = {"image", "video", "audio", "avatar", "subtitle", "bgm"}
33
+ VALID_ASSET_SOURCES = {"existing", "gen-image", "gen-video", "gen-voice", "gen-digital-human"}
34
+
35
+ # CJK 汉字范围(简繁通用)+ 常见中文标点
36
+ _CJK_PATTERN = re.compile(r"[一-鿿㐀-䶿＀-￯ -〿]")
37
+
38
+
39
+ # ════════════════════════════════════════════════════════════════════════════
40
+ # Error type
41
+ # ════════════════════════════════════════════════════════════════════════════
42
+
43
+
44
+ class ValidationError:
45
+ def __init__(self, path: str, message: str, severity: str = "error"):
46
+ self.path = path
47
+ self.message = message
48
+ self.severity = severity
49
+
50
+ def __str__(self) -> str:
51
+ return f"[{self.severity.upper()}] {self.path}: {self.message}"
52
+
53
+ def to_dict(self) -> dict:
54
+ return {"path": self.path, "message": self.message, "severity": self.severity}
55
+
56
+
57
+ def errors_as_strings(errors: Iterable[ValidationError]) -> list[str]:
58
+ """Convert ValidationError objects to ``"path: message"`` strings.
59
+
60
+ Designed for the two historical callers (gen_script / render_video) that
61
+ print ``f" - {err}"`` after a header line. We drop the ``[ERROR]`` /
62
+ ``[WARNING]`` prefix here because the prior callers' output didn't have
63
+ it; the call site already announced "validation failed".
64
+ """
65
+ return [f"{e.path}: {e.message}" for e in errors]
66
+
67
+
68
+ # ════════════════════════════════════════════════════════════════════════════
69
+ # Helpers
70
+ # ════════════════════════════════════════════════════════════════════════════
71
+
72
+
73
+ def _pick_template_id(dsl: dict) -> Optional[str]:
74
+ """Pick the templateId from DSL meta (single source of truth).
75
+
76
+ ``renderHints.templatePreference`` was the legacy location and is no
77
+ longer generated by gen_script. We still tolerate it during a
78
+ transition period, but new authors should write ``meta.templateId``.
79
+ """
80
+ meta = dsl.get("meta") or {}
81
+ if isinstance(meta, dict):
82
+ tid = meta.get("templateId")
83
+ if isinstance(tid, str) and tid:
84
+ return tid
85
+ hints = dsl.get("renderHints") or {}
86
+ prefs = hints.get("templatePreference") or []
87
+ if isinstance(prefs, list) and prefs:
88
+ return prefs[0]
89
+ return hints.get("templateId") if isinstance(hints, dict) else None
90
+
91
+
92
+ def _line_sync_slides_for_template(template_id: Optional[str]) -> dict[str, str]:
93
+ """Look up ``lineSyncSlides`` for a template from registry.json.
94
+
95
+ 新增多项卡片幻灯片只需在 template.json 加一行 ``lineSyncSlides[slideId] = key``,
96
+ 不再需要改 ab-skill。registry 读取失败(环境缺 registry_loader、HTTP 不通等)
97
+ 时返回空 dict,调用方将跳过相关校验。
98
+
99
+ O(1) lookup via ``registry_loader.get_template``; safe to call once per
100
+ scene at 100+ templates without scanning the full list.
101
+ """
102
+ tpl = _get_template_cfg(template_id)
103
+ if not tpl:
104
+ return {}
105
+ spec = tpl.get("lineSyncSlides") or {}
106
+ return {k: v for k, v in spec.items() if isinstance(v, str)}
107
+
108
+
109
+ def _get_template_cfg(template_id: Optional[str]) -> Optional[dict]:
110
+ """Indexed registry lookup with the legacy import fallback.
111
+
112
+ Returns None silently when:
113
+ - ``template_id`` is empty
114
+ - ``registry_loader`` cannot be imported (e.g. unit tests outside the
115
+ shared-lib sys.path)
116
+ - the registry can't be loaded (network/file/etc.)
117
+ - the template isn't in the visible set (e.g. beta gate hides it)
118
+ """
119
+ if not template_id:
120
+ return None
121
+ try:
122
+ from registry_loader import get_template # type: ignore
123
+ except ImportError:
124
+ return None
125
+ try:
126
+ return get_template(template_id)
127
+ except Exception:
128
+ return None
129
+
130
+
131
+ def _contains_cjk(text: str) -> bool:
132
+ return bool(text) and bool(_CJK_PATTERN.search(text))
133
+
134
+
135
+ # ════════════════════════════════════════════════════════════════════════════
136
+ # Granular checks
137
+ # ════════════════════════════════════════════════════════════════════════════
138
+
139
+
140
+ def _check_version(dsl: dict) -> list[ValidationError]:
141
+ if dsl.get("version") not in VALID_VERSIONS:
142
+ return [ValidationError(
143
+ "version",
144
+ f"must be one of {sorted(VALID_VERSIONS)}, got '{dsl.get('version')}'",
145
+ )]
146
+ return []
147
+
148
+
149
+ def _check_meta_presence(dsl: dict) -> list[ValidationError]:
150
+ """gen-script 历史:meta 必须存在且至少有 title。"""
151
+ errors: list[ValidationError] = []
152
+ meta = dsl.get("meta")
153
+ if not isinstance(meta, dict):
154
+ errors.append(ValidationError("meta", "required and must be an object"))
155
+ return errors
156
+ if not meta.get("title"):
157
+ errors.append(ValidationError("meta.title", "required"))
158
+ return errors
159
+
160
+
161
+ def _check_meta_enums(dsl: dict) -> list[ValidationError]:
162
+ """完整版额外:meta.platform 必须是已知枚举。"""
163
+ errors: list[ValidationError] = []
164
+ meta = dsl.get("meta")
165
+ if isinstance(meta, dict):
166
+ platform = meta.get("platform", "generic")
167
+ if platform not in VALID_PLATFORMS:
168
+ errors.append(ValidationError(
169
+ "meta.platform", f"must be one of {sorted(VALID_PLATFORMS)}",
170
+ ))
171
+ return errors
172
+
173
+
174
+ def _check_global_presence(dsl: dict) -> list[ValidationError]:
175
+ """gen-script 历史:global 必须存在(只检查存在性)。"""
176
+ if not isinstance(dsl.get("global"), dict):
177
+ return [ValidationError("global", "required and must be an object")]
178
+ return []
179
+
180
+
181
+ def _check_global_enums(dsl: dict) -> list[ValidationError]:
182
+ """完整版额外:global.aspectRatio / resolution 必须是已知枚举。"""
183
+ errors: list[ValidationError] = []
184
+ g = dsl.get("global")
185
+ if isinstance(g, dict):
186
+ ratio = g.get("aspectRatio", "16:9")
187
+ if ratio not in VALID_RATIOS:
188
+ errors.append(ValidationError(
189
+ "global.aspectRatio", f"must be one of {sorted(VALID_RATIOS)}",
190
+ ))
191
+ res = g.get("resolution", "1080p")
192
+ if res not in VALID_RESOLUTIONS:
193
+ errors.append(ValidationError(
194
+ "global.resolution", f"must be one of {sorted(VALID_RESOLUTIONS)}",
195
+ ))
196
+ return errors
197
+
198
+
199
+ def _check_scenes_min(dsl: dict) -> list[ValidationError]:
200
+ """Scenes 至少 1。两个 preset 共用。"""
201
+ scenes = dsl.get("scenes", [])
202
+ if not isinstance(scenes, list) or len(scenes) == 0:
203
+ return [ValidationError("scenes", "must contain at least 1 scene")]
204
+ return []
205
+
206
+
207
+ def _check_scenes_max(dsl: dict) -> list[ValidationError]:
208
+ """Scenes 上限 20。仅 gen-script 历史规则集 + 完整版用——render-video
209
+ 历史不查上限,合并时若同时启用此检查会"原本能过的 DSL 现在被拒",
210
+ 故只在 ``validate_structural`` / ``validate_dsl`` 启用。
211
+ """
212
+ scenes = dsl.get("scenes", [])
213
+ if isinstance(scenes, list) and len(scenes) > 20:
214
+ return [ValidationError(
215
+ "scenes", f"must contain at most 20 scenes, got {len(scenes)}",
216
+ )]
217
+ return []
218
+
219
+
220
+ def _check_scene_required_fields(dsl: dict) -> list[ValidationError]:
221
+ """gen-script 历史:每个 scene 必须有 id 和 purpose。"""
222
+ errors: list[ValidationError] = []
223
+ for i, scene in enumerate(dsl.get("scenes", []) or []):
224
+ if not scene.get("id"):
225
+ errors.append(ValidationError(f"scenes[{i}].id", "required"))
226
+ if not scene.get("purpose"):
227
+ errors.append(ValidationError(f"scenes[{i}].purpose", "required"))
228
+ return errors
229
+
230
+
231
+ def _check_scene_id_uniqueness(dsl: dict) -> list[ValidationError]:
232
+ """完整版额外:scene.id 唯一性。"""
233
+ errors: list[ValidationError] = []
234
+ seen: set = set()
235
+ for i, scene in enumerate(dsl.get("scenes", []) or []):
236
+ sid = scene.get("id")
237
+ if sid and sid in seen:
238
+ errors.append(ValidationError(
239
+ f"scenes[{i}].id", f"duplicate scene id '{sid}'",
240
+ ))
241
+ elif sid:
242
+ seen.add(sid)
243
+ return errors
244
+
245
+
246
+ def _check_scene_enum_warnings(dsl: dict) -> list[ValidationError]:
247
+ """完整版额外:scene.purpose / scene.layout 枚举(severity=warning)。"""
248
+ errors: list[ValidationError] = []
249
+ for i, scene in enumerate(dsl.get("scenes", []) or []):
250
+ purpose = scene.get("purpose")
251
+ if purpose and purpose not in VALID_PURPOSES:
252
+ errors.append(ValidationError(
253
+ f"scenes[{i}].purpose",
254
+ f"must be one of {sorted(VALID_PURPOSES)}", "warning",
255
+ ))
256
+ layout = scene.get("layout")
257
+ if layout and layout not in VALID_LAYOUTS:
258
+ errors.append(ValidationError(
259
+ f"scenes[{i}].layout",
260
+ f"must be one of {sorted(VALID_LAYOUTS)}", "warning",
261
+ ))
262
+ return errors
263
+
264
+
265
+ def _check_asset_duplicates(dsl: dict) -> list[ValidationError]:
266
+ """render-video 历史:assets 数组里 assetId 重复检查。"""
267
+ errors: list[ValidationError] = []
268
+ counts: dict[str, int] = {}
269
+ for a in dsl.get("assets", []) or []:
270
+ aid = a.get("assetId", "")
271
+ counts[aid] = counts.get(aid, 0) + 1
272
+ for aid, cnt in counts.items():
273
+ if cnt > 1:
274
+ errors.append(ValidationError(
275
+ f"assets[{aid}]", f"duplicate assetId '{aid}' (appears {cnt} times)",
276
+ ))
277
+ return errors
278
+
279
+
280
+ def _check_asset_enums(dsl: dict) -> list[ValidationError]:
281
+ """完整版额外:asset.type / asset.source 枚举。"""
282
+ errors: list[ValidationError] = []
283
+ for i, asset in enumerate(dsl.get("assets", []) or []):
284
+ atype = asset.get("type")
285
+ if atype and atype not in VALID_ASSET_TYPES:
286
+ errors.append(ValidationError(
287
+ f"assets[{i}].type", f"must be one of {sorted(VALID_ASSET_TYPES)}",
288
+ ))
289
+ asource = asset.get("source")
290
+ if asource and asource not in VALID_ASSET_SOURCES:
291
+ errors.append(ValidationError(
292
+ f"assets[{i}].source", f"must be one of {sorted(VALID_ASSET_SOURCES)}",
293
+ ))
294
+ return errors
295
+
296
+
297
+ def _check_scene_asset_refs(dsl: dict) -> list[ValidationError]:
298
+ """render-video 历史:scene 引用的 assetRef 必须在 assets[] 声明过。
299
+
300
+ 检查范围:
301
+ - scene.audio.narration.assetRef
302
+ - scene.visuals.background.assetRef
303
+ """
304
+ errors: list[ValidationError] = []
305
+ declared = {
306
+ a.get("assetId") for a in (dsl.get("assets") or []) if a.get("assetId")
307
+ }
308
+ for idx, scene in enumerate(dsl.get("scenes", []) or []):
309
+ sid = scene.get("id", f"scenes[{idx}]")
310
+ nar = (scene.get("audio") or {}).get("narration") or {}
311
+ nar_ref = nar.get("assetRef")
312
+ if nar_ref and nar_ref not in declared:
313
+ errors.append(ValidationError(
314
+ f"scenes[{idx}].audio.narration.assetRef",
315
+ f"scene '{sid}': narration.assetRef '{nar_ref}' not found in assets[]",
316
+ ))
317
+ bg_ref = ((scene.get("visuals") or {}).get("background") or {}).get("assetRef")
318
+ if bg_ref and bg_ref not in declared:
319
+ errors.append(ValidationError(
320
+ f"scenes[{idx}].visuals.background.assetRef",
321
+ f"scene '{sid}': visuals.background.assetRef '{bg_ref}' not found in assets[]",
322
+ ))
323
+ return errors
324
+
325
+
326
+ def _check_assetbindings_deprecation(dsl: dict) -> list[ValidationError]:
327
+ """完整版额外:assetBindings 引用未知 id 时给 warning + 提示已弃用。"""
328
+ errors: list[ValidationError] = []
329
+ declared = {
330
+ a.get("assetId") for a in (dsl.get("assets") or []) if a.get("assetId")
331
+ }
332
+ for i, scene in enumerate(dsl.get("scenes", []) or []):
333
+ for ref in scene.get("assetBindings", []) or []:
334
+ if ref not in declared:
335
+ errors.append(ValidationError(
336
+ f"scenes[{i}].assetBindings",
337
+ f"references unknown assetId '{ref}' (note: assetBindings is deprecated, "
338
+ "use visuals.background.assetRef + audio.narration.assetRef instead)",
339
+ "warning",
340
+ ))
341
+ return errors
342
+
343
+
344
+ def _check_narration_items_count(dsl: dict) -> list[ValidationError]:
345
+ """render-video / 完整版:结构化 narration items 数与 templateData 的对应数组等长。
346
+
347
+ 哪些 slideId 走该校验,由模板的 ``lineSyncSlides`` 字段声明(P2.2 起);
348
+ registry 不可用或模板没声明则跳过。两个 preset 共用本检查器,统一来源
349
+ 避免 render-video 与完整版的算法继续漂移。
350
+ """
351
+ errors: list[ValidationError] = []
352
+ template_id = _pick_template_id(dsl)
353
+ line_sync_slides = _line_sync_slides_for_template(template_id)
354
+
355
+ for idx, scene in enumerate(dsl.get("scenes", []) or []):
356
+ sid = scene.get("id", f"scenes[{idx}]")
357
+ narration = (scene.get("audio") or {}).get("narration") or {}
358
+ items = narration.get("items")
359
+ if not items or not isinstance(items, list):
360
+ continue
361
+ custom = scene.get("customPayload") or {}
362
+ slide_id = custom.get("slideId") or (custom.get("templateData") or {}).get("slideId")
363
+ tdata = custom.get("templateData") or scene.get("templateData") or {}
364
+ items_key = line_sync_slides.get(slide_id or "")
365
+ if not items_key:
366
+ continue
367
+ card_list = tdata.get(items_key)
368
+ if not isinstance(card_list, list):
369
+ continue
370
+ # render-video 历史只在 actual!=expected 时报;完整版也是同样语义。
371
+ actual = len([x for x in items if (isinstance(x, str) and x.strip()) or isinstance(x, dict)])
372
+ expected = len(card_list)
373
+ if actual != expected:
374
+ errors.append(ValidationError(
375
+ f"scenes[{idx}].audio.narration.items",
376
+ f"scene '{sid}': narration.items has {actual} entries "
377
+ f"but templateData.{items_key} has {expected} — counts must match "
378
+ "so each card lines up with one narration line",
379
+ ))
380
+ return errors
381
+
382
+
383
+ def _check_narration_language_strict(dsl: dict) -> list[ValidationError]:
384
+ """完整版:模板若声明 ``capabilities.narrationLanguageStrict`` 则强约束旁白语言。
385
+
386
+ Generic capability-driven replacement for what used to be a hardcoded
387
+ ``picture-book-en`` CJK check. Templates declare which language the
388
+ narration MUST be in (currently supported: ``"en"`` rejects any CJK
389
+ character; ``"zh"`` is a placeholder reserved for future symmetric
390
+ enforcement — we don't yet flag latin-only narration on zh templates
391
+ because gen-script regularly mixes brand names in English).
392
+
393
+ Detection points are unchanged from the legacy check:
394
+ - every ``gen-voice`` asset's ``payload.text``
395
+ - every scene's ``audio.narration.text``
396
+ """
397
+ tpl = _get_template_cfg(_pick_template_id(dsl))
398
+ if not tpl:
399
+ return []
400
+ strict = (tpl.get("capabilities") or {}).get("narrationLanguageStrict")
401
+ if strict != "en":
402
+ # Today only the "narration must be English" case is enforced;
403
+ # adding "zh" / other languages goes here without touching callers.
404
+ return []
405
+ tid = tpl.get("templateId", "<unknown>")
406
+ msg = (
407
+ f"narration on template '{tid}' must be English only (no CJK characters); "
408
+ "put the Chinese translation in textLayers[role=subheadline].content instead"
409
+ )
410
+ errors: list[ValidationError] = []
411
+ for i, asset in enumerate(dsl.get("assets", []) or []):
412
+ if asset.get("source") != "gen-voice":
413
+ continue
414
+ voice_text = ((asset.get("payload") or {}).get("text")) or ""
415
+ if _contains_cjk(voice_text):
416
+ errors.append(ValidationError(f"assets[{i}].payload.text", msg))
417
+ for idx, scene in enumerate(dsl.get("scenes", []) or []):
418
+ narration = (scene.get("audio") or {}).get("narration") or {}
419
+ narration_text = narration.get("text")
420
+ if isinstance(narration_text, str) and _contains_cjk(narration_text):
421
+ errors.append(ValidationError(
422
+ f"scenes[{idx}].audio.narration.text", msg,
423
+ ))
424
+ return errors
425
+
426
+
427
+ # ════════════════════════════════════════════════════════════════════════════
428
+ # Aggregator presets
429
+ # ════════════════════════════════════════════════════════════════════════════
430
+
431
+
432
+ def validate_structural(dsl: dict) -> list[ValidationError]:
433
+ """gen-script 历史规则集 — 仅检查 DSL 整体结构 / 必填字段。
434
+
435
+ 与历史 ``gen_script.validate_dsl`` 同集合:
436
+ version + meta(title) + global(presence) + scene 数 1–20 + 每场 id/purpose。
437
+
438
+ 不检查:asset 一致性、enum 枚举、capabilities.narrationLanguageStrict——保持与历史等价。
439
+ """
440
+ errors: list[ValidationError] = []
441
+ errors += _check_version(dsl)
442
+ errors += _check_meta_presence(dsl)
443
+ errors += _check_global_presence(dsl)
444
+ errors += _check_scenes_min(dsl)
445
+ errors += _check_scenes_max(dsl)
446
+ errors += _check_scene_required_fields(dsl)
447
+ return errors
448
+
449
+
450
+ def validate_integrity(dsl: dict) -> list[ValidationError]:
451
+ """render-video 历史规则集 — 关注 asset 引用一致性 + 数量对齐。
452
+
453
+ 与历史 ``render_video.validate_dsl`` 同集合:
454
+ version + scene 数 + assetId 去重 + assetRef 可解析 + 结构化 narration items 数。
455
+
456
+ 不检查:meta.title、global 必填、enum 枚举、capabilities.narrationLanguageStrict——保持等价。
457
+ """
458
+ errors: list[ValidationError] = []
459
+ errors += _check_version(dsl)
460
+ errors += _check_scenes_min(dsl)
461
+ # NOTE: render-video 历史只查下限,不查 ≤20 上限——合并时若同启用上限会
462
+ # 把"超 20 场但其它都对"的 DSL 从「通过」改成「拒绝」,违反"重构不影响功能"。
463
+ errors += _check_asset_duplicates(dsl)
464
+ errors += _check_scene_asset_refs(dsl)
465
+ errors += _check_narration_items_count(dsl)
466
+ return errors
467
+
468
+
469
+ def validate_dsl(dsl: dict) -> list[ValidationError]:
470
+ """完整 DSL 校验:结构 + 一致性 + 枚举 + capabilities.narrationLanguageStrict。
471
+
472
+ 历史上 dsl_validator 暴露的就是这版,无外部 caller(三个真 caller 都
473
+ 走 ``validate_structural`` / ``validate_integrity``)。本函数面向想要"全
474
+ 部规则"的新代码 —— 例如 CI 校验或人工 ``--validate-strict`` 入口。
475
+ """
476
+ errors: list[ValidationError] = []
477
+ errors += _check_version(dsl)
478
+ errors += _check_meta_presence(dsl)
479
+ errors += _check_meta_enums(dsl)
480
+ errors += _check_global_presence(dsl)
481
+ errors += _check_global_enums(dsl)
482
+ errors += _check_scenes_min(dsl)
483
+ errors += _check_scenes_max(dsl)
484
+ errors += _check_scene_required_fields(dsl)
485
+ errors += _check_scene_id_uniqueness(dsl)
486
+ errors += _check_scene_enum_warnings(dsl)
487
+ errors += _check_asset_duplicates(dsl)
488
+ errors += _check_asset_enums(dsl)
489
+ errors += _check_scene_asset_refs(dsl)
490
+ errors += _check_assetbindings_deprecation(dsl)
491
+ errors += _check_narration_items_count(dsl)
492
+ errors += _check_narration_language_strict(dsl)
493
+ return errors
494
+
495
+