@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.
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/dist/argv.d.ts +15 -0
- package/dist/argv.js +51 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +83 -0
- package/dist/handlers/gen-image.d.ts +13 -0
- package/dist/handlers/gen-image.js +161 -0
- package/dist/handlers/gen-voice.d.ts +16 -0
- package/dist/handlers/gen-voice.js +110 -0
- package/dist/handlers/index.d.ts +13 -0
- package/dist/handlers/index.js +14 -0
- package/dist/handlers/template-bind.d.ts +15 -0
- package/dist/handlers/template-bind.js +149 -0
- package/dist/http.d.ts +52 -0
- package/dist/http.js +110 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/manifest.json +260 -0
- package/dist/progress.d.ts +23 -0
- package/dist/progress.js +15 -0
- package/dist/registry.d.ts +33 -0
- package/dist/registry.js +72 -0
- package/dist/runner.d.ts +17 -0
- package/dist/runner.js +63 -0
- package/package.json +54 -0
- package/skills/export-jianying/SKILL.md +251 -0
- package/skills/export-jianying/scripts/gen_jianying_draft.py +1099 -0
- package/skills/export-jianying/skill.json +24 -0
- package/skills/export-jianying/version.json +7 -0
- package/skills/gen-digital-human/SKILL.md +211 -0
- package/skills/gen-digital-human/scripts/gen_digital_human_video.py +574 -0
- package/skills/gen-digital-human/skill.json +23 -0
- package/skills/gen-digital-human/version.json +7 -0
- package/skills/gen-image/SKILL.md +130 -0
- package/skills/gen-image/skill.json +22 -0
- package/skills/gen-image/version.json +7 -0
- package/skills/gen-script/SKILL.md +283 -0
- package/skills/gen-script/scripts/gen_script.py +1208 -0
- package/skills/gen-script/skill.json +41 -0
- package/skills/gen-script/version.json +7 -0
- package/skills/gen-video/SKILL.md +170 -0
- package/skills/gen-video/scripts/gen_video.py +591 -0
- package/skills/gen-video/skill.json +24 -0
- package/skills/gen-video/version.json +7 -0
- package/skills/gen-voice/SKILL.md +104 -0
- package/skills/gen-voice/skill.json +21 -0
- package/skills/gen-voice/version.json +7 -0
- package/skills/prepare-video-assets/SKILL.md +192 -0
- package/skills/prepare-video-assets/scripts/prepare_video_assets.py +57 -0
- package/skills/prepare-video-assets/skill.json +23 -0
- package/skills/prepare-video-assets/version.json +7 -0
- package/skills/render-video/SKILL.md +187 -0
- package/skills/render-video/scripts/_chrome_vendor.py +305 -0
- package/skills/render-video/scripts/_video_probe.py +174 -0
- package/skills/render-video/scripts/_vod_polling.py +86 -0
- package/skills/render-video/scripts/remote_renderer_client.py +236 -0
- package/skills/render-video/scripts/render_video.py +2288 -0
- package/skills/render-video/scripts/upload_video.py +193 -0
- package/skills/render-video/skill.json +21 -0
- package/skills/render-video/version.json +7 -0
- package/skills/template-bind/README.md +63 -0
- package/skills/template-bind/SKILL.md +234 -0
- package/skills/template-bind/scripts/check_contracts.py +196 -0
- package/skills/template-bind/scripts/match_template.py +215 -0
- package/skills/template-bind/scripts/registry_loader.py +379 -0
- package/skills/template-bind/scripts/render_job_client.py +162 -0
- package/skills/template-bind/scripts/sync_registry.py +44 -0
- package/skills/template-bind/scripts/template_paths.py +72 -0
- package/skills/template-bind/skill.json +20 -0
- package/skills/template-bind/version.json +7 -0
- package/skills/template-bind/video_dsl/README.md +97 -0
- package/skills/template-bind/video_dsl/runtime/__init__.py +11 -0
- package/skills/template-bind/video_dsl/runtime/dsl_validator.py +495 -0
- package/skills/template-bind/video_dsl/runtime/prompt_enhancer.py +274 -0
- package/skills/template-bind/video_dsl/runtime/template_binder.py +87 -0
- package/skills/template-bind/video_dsl/runtime/timeline_compiler.py +312 -0
- package/skills/template-bind/video_dsl/schema/render-plan-v1alpha1.json +193 -0
- package/skills/template-bind/video_dsl/schema/template-binding-v1alpha1.json +122 -0
- package/skills/template-bind/video_dsl/schema/template-definition-v1alpha1.json +247 -0
- package/skills/template-bind/video_dsl/schema/video-dsl-v1alpha1.json +378 -0
- package/skills/video-parser/SKILL.md +238 -0
- package/skills/video-parser/scripts/analyze_video.py +1038 -0
- package/skills/video-parser/scripts/deconstruct_video.py +602 -0
- package/skills/video-parser/scripts/parse_via_render.py +148 -0
- package/skills/video-parser/skill.json +21 -0
- package/skills/video-parser/version.json +7 -0
- package/skills/web-capture/SKILL.md +183 -0
- package/skills/web-capture/scripts/web_capture.py +461 -0
- package/skills/web-capture/skill.json +29 -0
- package/skills/web-capture/version.json +7 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cross-module contract checker: template-library ↔ remotion-renderer.
|
|
3
|
+
|
|
4
|
+
Verifies that every `compositionId` referenced from the @ab-templates/metadata
|
|
5
|
+
registry.json is:
|
|
6
|
+
1. Registered in `<monorepo-root>/remotion-renderer/src/core/compositions/manifest.ts`
|
|
7
|
+
2. Mapped there to the SAME `templateId` as the template entry's
|
|
8
|
+
top-level `templateId` field
|
|
9
|
+
|
|
10
|
+
Exit non-zero with a useful diff if the two sides disagree.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python3 scripts/check_contracts.py
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Iterator
|
|
22
|
+
|
|
23
|
+
from template_paths import monorepo_registry_path, monorepo_root
|
|
24
|
+
|
|
25
|
+
_REGISTRY_PATH = monorepo_registry_path()
|
|
26
|
+
_MANIFEST_PATH = os.path.abspath(
|
|
27
|
+
os.path.join(
|
|
28
|
+
monorepo_root(),
|
|
29
|
+
"remotion-renderer",
|
|
30
|
+
"src",
|
|
31
|
+
"core",
|
|
32
|
+
"compositions",
|
|
33
|
+
"manifest.ts",
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Matches a single manifest row:
|
|
39
|
+
# SomeCompositionId: { component: Foo, templateId: "t-id", slot: "opening" },
|
|
40
|
+
_MANIFEST_ROW_RE = re.compile(
|
|
41
|
+
r'^\s*(?P<cid>[A-Za-z0-9_]+)\s*:\s*\{\s*'
|
|
42
|
+
r'component\s*:\s*[A-Za-z0-9_]+\s*,\s*'
|
|
43
|
+
r'templateId\s*:\s*"(?P<tid>[^"]+)"\s*,\s*'
|
|
44
|
+
r'slot\s*:\s*"(?P<slot>[^"]+)"\s*\}\s*,?\s*$'
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_manifest(path: str) -> dict[str, dict[str, str]]:
|
|
49
|
+
"""Parse manifest.ts and return {compositionId: {templateId, slot}}."""
|
|
50
|
+
if not os.path.isfile(path):
|
|
51
|
+
raise FileNotFoundError(f"manifest.ts not found at {path}")
|
|
52
|
+
|
|
53
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
54
|
+
text = f.read()
|
|
55
|
+
|
|
56
|
+
start_marker = "// MANIFEST_ENTRIES:START"
|
|
57
|
+
end_marker = "// MANIFEST_ENTRIES:END"
|
|
58
|
+
start = text.find(start_marker)
|
|
59
|
+
end = text.find(end_marker)
|
|
60
|
+
if start == -1 or end == -1 or end < start:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"manifest.ts is missing {start_marker} / {end_marker} markers"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
block = text[start:end]
|
|
66
|
+
entries: dict[str, dict[str, str]] = {}
|
|
67
|
+
for line in block.splitlines():
|
|
68
|
+
m = _MANIFEST_ROW_RE.match(line)
|
|
69
|
+
if not m:
|
|
70
|
+
continue
|
|
71
|
+
entries[m.group("cid")] = {
|
|
72
|
+
"templateId": m.group("tid"),
|
|
73
|
+
"slot": m.group("slot"),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if not entries:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"No manifest entries parsed from {path}. Did the format change?"
|
|
79
|
+
)
|
|
80
|
+
return entries
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _iter_composition_ids(node: object) -> Iterator[str]:
|
|
84
|
+
"""Walk an arbitrary JSON tree and yield every `compositionId` value."""
|
|
85
|
+
if isinstance(node, dict):
|
|
86
|
+
cid = node.get("compositionId")
|
|
87
|
+
if isinstance(cid, str):
|
|
88
|
+
yield cid
|
|
89
|
+
for v in node.values():
|
|
90
|
+
yield from _iter_composition_ids(v)
|
|
91
|
+
elif isinstance(node, list):
|
|
92
|
+
for v in node:
|
|
93
|
+
yield from _iter_composition_ids(v)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def collect_template_references(registry_path: str) -> list[dict[str, str]]:
|
|
97
|
+
"""Return a list of {templateId, compositionId, file} for every reference in the registry."""
|
|
98
|
+
refs: list[dict[str, str]] = []
|
|
99
|
+
if not os.path.isfile(registry_path):
|
|
100
|
+
print(f"WARN: registry.json not found at {registry_path}", file=sys.stderr)
|
|
101
|
+
return refs
|
|
102
|
+
|
|
103
|
+
with open(registry_path, "r", encoding="utf-8") as f:
|
|
104
|
+
data = json.load(f)
|
|
105
|
+
|
|
106
|
+
for tpl in data.get("templates", []):
|
|
107
|
+
tid = tpl.get("templateId")
|
|
108
|
+
if not tid:
|
|
109
|
+
continue
|
|
110
|
+
for cid in _iter_composition_ids(tpl):
|
|
111
|
+
refs.append({"templateId": tid, "compositionId": cid, "file": registry_path})
|
|
112
|
+
return refs
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main() -> int:
|
|
116
|
+
try:
|
|
117
|
+
manifest = parse_manifest(_MANIFEST_PATH)
|
|
118
|
+
except (FileNotFoundError, ValueError) as e:
|
|
119
|
+
print(f"FAIL: {e}", file=sys.stderr)
|
|
120
|
+
return 2
|
|
121
|
+
|
|
122
|
+
refs = collect_template_references(_REGISTRY_PATH)
|
|
123
|
+
|
|
124
|
+
errors: list[str] = []
|
|
125
|
+
seen: set[str] = set()
|
|
126
|
+
|
|
127
|
+
for ref in refs:
|
|
128
|
+
cid = ref["compositionId"]
|
|
129
|
+
tid = ref["templateId"]
|
|
130
|
+
rel = os.path.relpath(ref["file"], monorepo_root())
|
|
131
|
+
seen.add(cid)
|
|
132
|
+
|
|
133
|
+
entry = manifest.get(cid)
|
|
134
|
+
if entry is None:
|
|
135
|
+
errors.append(
|
|
136
|
+
f"{rel}: compositionId '{cid}' is referenced but not in manifest.ts"
|
|
137
|
+
)
|
|
138
|
+
continue
|
|
139
|
+
if entry["templateId"] != tid:
|
|
140
|
+
errors.append(
|
|
141
|
+
f"{rel}: compositionId '{cid}' is registered under templateId "
|
|
142
|
+
f"'{entry['templateId']}' in manifest.ts, but template.json declares "
|
|
143
|
+
f"templateId '{tid}'"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Warn (non-fatal) about manifest entries never referenced by any template.
|
|
147
|
+
unused = sorted(set(manifest.keys()) - seen)
|
|
148
|
+
for cid in unused:
|
|
149
|
+
print(
|
|
150
|
+
f"WARN: manifest.ts entry '{cid}' is not referenced by any "
|
|
151
|
+
f"template.json (templateId={manifest[cid]['templateId']})",
|
|
152
|
+
file=sys.stderr,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# P1.1 nudge (warning only): compositionId should be prefixed with
|
|
156
|
+
# `<templateId>/` so the 300-400 names we'll have at 100+ templates can't
|
|
157
|
+
# collide in the flat global namespace. The actual migration touches
|
|
158
|
+
# remotion-renderer/manifest.ts and every template.json, so we ship the
|
|
159
|
+
# warning first to surface the gap without breaking builds.
|
|
160
|
+
unprefixed: list[tuple[str, str]] = []
|
|
161
|
+
for ref in refs:
|
|
162
|
+
cid = ref["compositionId"]
|
|
163
|
+
tid = ref["templateId"]
|
|
164
|
+
expected_prefix = f"{tid}/"
|
|
165
|
+
# Allow either `<tid>/Cover` or `<tid>.Cover` (TS member access form).
|
|
166
|
+
if not (cid.startswith(expected_prefix) or cid.startswith(f"{tid}.")):
|
|
167
|
+
unprefixed.append((tid, cid))
|
|
168
|
+
if unprefixed:
|
|
169
|
+
print(
|
|
170
|
+
"WARN (P1.1): the following compositionIds are not prefixed with their "
|
|
171
|
+
"templateId — at 100+ templates this flat namespace will collide. "
|
|
172
|
+
"Migration plan: rename `<cid>` to `<templateId>/<cid>` in both "
|
|
173
|
+
"template.json and remotion-renderer/manifest.ts.",
|
|
174
|
+
file=sys.stderr,
|
|
175
|
+
)
|
|
176
|
+
for tid, cid in unprefixed[:10]:
|
|
177
|
+
print(f" - {tid}: '{cid}' → suggested '{tid}/{cid}'", file=sys.stderr)
|
|
178
|
+
if len(unprefixed) > 10:
|
|
179
|
+
print(f" ... ({len(unprefixed)} total)", file=sys.stderr)
|
|
180
|
+
|
|
181
|
+
if errors:
|
|
182
|
+
print("FAIL: contract check found inconsistencies:", file=sys.stderr)
|
|
183
|
+
for e in errors:
|
|
184
|
+
print(f" - {e}", file=sys.stderr)
|
|
185
|
+
return 1
|
|
186
|
+
|
|
187
|
+
print(
|
|
188
|
+
f"OK: {len(refs)} compositionId references across "
|
|
189
|
+
f"{len({r['templateId'] for r in refs})} templates match manifest.ts "
|
|
190
|
+
f"({len(manifest)} entries)."
|
|
191
|
+
)
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__":
|
|
196
|
+
sys.exit(main())
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""
|
|
2
|
+
template-bind 库模块 — DSL → TemplateBinding 的纯函数实现。
|
|
3
|
+
|
|
4
|
+
仅作为 Python library 被 render-video 的 render_video.py / gen-script 的
|
|
5
|
+
gen_script.py 通过 `sys.path.insert` 后 `import match_template` 引入;
|
|
6
|
+
对外不再暴露 CLI(template-bind skill 已迁移到 ab-skill CLI 的 builtin
|
|
7
|
+
handler,由 src/handlers/template-bind.ts 处理 --list-templates)。
|
|
8
|
+
|
|
9
|
+
提供的公共接口:
|
|
10
|
+
load_registry() → list[dict] 模板列表
|
|
11
|
+
load_full_template(template) → dict 模板完整定义(registry 自带,原样返回)
|
|
12
|
+
build_binding(template, dsl) → dict DSL → TemplateBinding
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from registry_loader import load_registry_data
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_registry() -> list:
|
|
19
|
+
"""Load template list via the unified registry loader."""
|
|
20
|
+
return load_registry_data().get("templates", [])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_full_template(template: dict) -> dict:
|
|
24
|
+
"""Load the full template definition.
|
|
25
|
+
|
|
26
|
+
Since the metadata registry.json now contains the complete template
|
|
27
|
+
definition (including slotMapping, compositions, etc.), this simply
|
|
28
|
+
returns the template as-is.
|
|
29
|
+
"""
|
|
30
|
+
return template
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve_slot_def(slot_mapping: dict, purpose: str) -> dict:
|
|
34
|
+
"""Resolve a slotMapping entry, following $ref chains (max depth 10)."""
|
|
35
|
+
slot_def = slot_mapping.get(purpose) or slot_mapping.get("default") or {}
|
|
36
|
+
visited: set = set()
|
|
37
|
+
while "$ref" in slot_def:
|
|
38
|
+
ref = slot_def["$ref"]
|
|
39
|
+
if ref in visited:
|
|
40
|
+
break
|
|
41
|
+
visited.add(ref)
|
|
42
|
+
slot_def = slot_mapping.get(ref) or {}
|
|
43
|
+
return slot_def
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _extract_prop(scene: dict, extractor: dict):
|
|
47
|
+
"""Extract a single prop value from a scene using an extractor definition.
|
|
48
|
+
|
|
49
|
+
Supports two extractor patterns:
|
|
50
|
+
{ "from": "textLayers", "role": "headline" } – search textLayers by role
|
|
51
|
+
{ "from": "visuals.background.assetRef" } – dotted-path traversal
|
|
52
|
+
"""
|
|
53
|
+
source = extractor.get("from", "")
|
|
54
|
+
|
|
55
|
+
# textLayers extractor
|
|
56
|
+
if source == "textLayers":
|
|
57
|
+
role = extractor.get("role", "")
|
|
58
|
+
for tl in scene.get("textLayers") or []:
|
|
59
|
+
if tl.get("role") == role:
|
|
60
|
+
return tl.get("content")
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
# Dotted-path extractor
|
|
64
|
+
val = scene
|
|
65
|
+
for part in source.split("."):
|
|
66
|
+
if not isinstance(val, dict):
|
|
67
|
+
return None
|
|
68
|
+
val = val.get(part)
|
|
69
|
+
return val
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_binding(template: dict, dsl: dict) -> dict:
|
|
73
|
+
"""Build a TemplateBinding from template and DSL."""
|
|
74
|
+
bindings = []
|
|
75
|
+
slot_mapping = template.get("slotMapping", {})
|
|
76
|
+
|
|
77
|
+
for idx, scene in enumerate(dsl.get("scenes", [])):
|
|
78
|
+
scene_id = scene.get("id") or scene.get("sceneId") or f"scene-{idx:03d}"
|
|
79
|
+
purpose = scene.get("purpose", "point")
|
|
80
|
+
|
|
81
|
+
slot_def = _resolve_slot_def(slot_mapping, purpose)
|
|
82
|
+
comp_id = slot_def.get("compositionId", "GenericScene")
|
|
83
|
+
slot_id = slot_def.get("slotId", f"{purpose}-slot")
|
|
84
|
+
|
|
85
|
+
# 用模板的 propExtractors 提取 props
|
|
86
|
+
props = {}
|
|
87
|
+
for prop_name, extractor in slot_def.get("propExtractors", {}).items():
|
|
88
|
+
val = _extract_prop(scene, extractor)
|
|
89
|
+
if val is not None:
|
|
90
|
+
props[prop_name] = val
|
|
91
|
+
|
|
92
|
+
# P1.3: props 提取仅保留两条路径——
|
|
93
|
+
# (1) 显式 propExtractors(上面的 _extract_prop 调用)
|
|
94
|
+
# (4) customPayload 全字段透传(顶层 + templateData)— 模板内部 schema
|
|
95
|
+
# 去掉了原来的"白名单同名隐式透传"两层 fallback,因为它们让"字段写在哪"
|
|
96
|
+
# 这个问题变得不可预测:同一字段写在 optionalProps + propExtractors
|
|
97
|
+
# 里时优先级会冲突,让 AI 反复出错。
|
|
98
|
+
#
|
|
99
|
+
# 顶层透传是必要的:DSL 示例(hermes-agent-purple / vibe-coding 等)以
|
|
100
|
+
# `customPayload.slideId` 顶层声明 slide id,而 html-slide 模板的
|
|
101
|
+
# `propExtractors.slideId` 只声明从 templateData.slideId 取值。如果不
|
|
102
|
+
# 同时透传顶层,slideId 就会缺失,html-slide 的 `point` slot 把
|
|
103
|
+
# slideId 设为 requiredProps,缺失会导致 React 端回退到通用「标题 +
|
|
104
|
+
# 字幕」兜底样式,所有场景看起来都是同一个布局。
|
|
105
|
+
custom = scene.get("customPayload") or {}
|
|
106
|
+
template_data = custom.get("templateData") or scene.get("templateData") or {}
|
|
107
|
+
# 先注入 templateData.* 再注入顶层 customPayload.*,让顶层覆盖同名值
|
|
108
|
+
for k, v in template_data.items():
|
|
109
|
+
if k not in props:
|
|
110
|
+
props[k] = v
|
|
111
|
+
for k, v in custom.items():
|
|
112
|
+
if k == "templateData":
|
|
113
|
+
continue
|
|
114
|
+
if k not in props:
|
|
115
|
+
props[k] = v
|
|
116
|
+
|
|
117
|
+
# animationHints 可能为 null
|
|
118
|
+
anim = scene.get("animationHints") or {}
|
|
119
|
+
entrance = anim.get("entrance", "fade")
|
|
120
|
+
transition_type = entrance if entrance != "none" else "cut"
|
|
121
|
+
|
|
122
|
+
bindings.append({
|
|
123
|
+
"sceneId": scene_id,
|
|
124
|
+
"slotId": slot_id,
|
|
125
|
+
"compositionId": comp_id,
|
|
126
|
+
"layoutVariant": scene.get("layout", "full-visual"),
|
|
127
|
+
"props": props,
|
|
128
|
+
"transitionIn": {"type": transition_type, "durationFrames": 15},
|
|
129
|
+
"transitionOut": {"type": "fade", "durationFrames": 15},
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
# P1.2: 不再做 screen-walkthrough 专属的截断 / 兜底。模板的 propExtractors
|
|
133
|
+
# 已声明 titleText ← textLayers[role=headline]、projectName ← textLayers[role=subheadline],
|
|
134
|
+
# gen_script 在每个场景都填入了 headline / subheadline,因此 props 由提取器
|
|
135
|
+
# 直接产出,不再需要后处理。
|
|
136
|
+
|
|
137
|
+
typography = template.get("defaultTypography", {})
|
|
138
|
+
motion = template.get("defaultMotionPreset", "smooth")
|
|
139
|
+
colors = template.get("defaultColorScheme", [])
|
|
140
|
+
|
|
141
|
+
# --- variant selection ---
|
|
142
|
+
# 优先级:
|
|
143
|
+
# 1) DSL 显式声明 — meta.templateVariant / renderHints.templateVariant
|
|
144
|
+
# 2) DSL meta.style / renderHints.brandColors 与 variant.styleTags 的子串/包含匹配
|
|
145
|
+
# 显式声明优先是因为:
|
|
146
|
+
# - 风格名(如 "紫色科技")在不同 variant 的 styleTags 里可能多义;
|
|
147
|
+
# - meta.style 经常是一整串短语而非单 tag,旧的"逐 tag 拼成空格 → 子串匹配"
|
|
148
|
+
# 会被 split 后的整段短语漏过(例如 "紫色科技" 不是 "紫色 科技 purple ..."
|
|
149
|
+
# 的子串)。显式 templateVariant 让作者可以一锤定音。
|
|
150
|
+
variant_id = None
|
|
151
|
+
meta = dsl.get("meta", {}) or {}
|
|
152
|
+
render_hints = dsl.get("renderHints", {}) or {}
|
|
153
|
+
explicit_variant = (
|
|
154
|
+
meta.get("templateVariant")
|
|
155
|
+
or meta.get("variant")
|
|
156
|
+
or render_hints.get("templateVariant")
|
|
157
|
+
)
|
|
158
|
+
variants = template.get("variants", {}) or {}
|
|
159
|
+
|
|
160
|
+
if explicit_variant and explicit_variant in variants:
|
|
161
|
+
variant_id = explicit_variant
|
|
162
|
+
else:
|
|
163
|
+
# Fallback: 把 meta.style 拆成更细的 token(按逗号 / 空白 / 中英标点 / 单字)
|
|
164
|
+
# 再到 variant.styleTags 里找任一包含或被包含关系。这样 "紫色科技" 既能命中
|
|
165
|
+
# ["紫色","科技"] 这种细粒度 tag,也能命中 ["紫色科技"] 这种整段 tag。
|
|
166
|
+
raw_style = (meta.get("style") or "").strip().lower()
|
|
167
|
+
if raw_style:
|
|
168
|
+
# 整段 + 逗号/分号/空白拆分 + 单中文字符级 token,统一去重
|
|
169
|
+
style_tokens = {raw_style}
|
|
170
|
+
for sep in [",", ",", ";", ";", " ", "/", "·"]:
|
|
171
|
+
raw_style = raw_style.replace(sep, ",")
|
|
172
|
+
for tok in raw_style.split(","):
|
|
173
|
+
tok = tok.strip()
|
|
174
|
+
if not tok:
|
|
175
|
+
continue
|
|
176
|
+
style_tokens.add(tok)
|
|
177
|
+
# 中文按字拆,命中如 "紫"/"色"/"科"/"技" 这种单字 tag 的场景
|
|
178
|
+
for ch in tok:
|
|
179
|
+
if "\u4e00" <= ch <= "\u9fff":
|
|
180
|
+
style_tokens.add(ch)
|
|
181
|
+
for vid, variant in variants.items():
|
|
182
|
+
vtags = [str(t).strip().lower() for t in variant.get("styleTags", [])]
|
|
183
|
+
# 任一 tag 与任一 style token 互为子串即视为匹配
|
|
184
|
+
if any(any(tok in tag or tag in tok for tag in vtags) for tok in style_tokens if tok):
|
|
185
|
+
variant_id = vid
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
if variant_id and variant_id in variants:
|
|
189
|
+
variant = variants[variant_id]
|
|
190
|
+
vtypo = variant.get("defaultTypography")
|
|
191
|
+
if vtypo:
|
|
192
|
+
typography = {**typography, **vtypo}
|
|
193
|
+
vmotion = variant.get("defaultMotionPreset")
|
|
194
|
+
if vmotion:
|
|
195
|
+
motion = vmotion
|
|
196
|
+
vcolors = variant.get("defaultColorScheme")
|
|
197
|
+
if vcolors:
|
|
198
|
+
colors = vcolors
|
|
199
|
+
|
|
200
|
+
result = {
|
|
201
|
+
"version": "v1alpha1",
|
|
202
|
+
"templateId": template.get("templateId", ""),
|
|
203
|
+
"templateName": template.get("name", ""),
|
|
204
|
+
"globalOverrides": {
|
|
205
|
+
"typography": typography,
|
|
206
|
+
"motionPreset": motion,
|
|
207
|
+
"colorScheme": colors,
|
|
208
|
+
},
|
|
209
|
+
"bindings": bindings,
|
|
210
|
+
}
|
|
211
|
+
if variant_id:
|
|
212
|
+
result["variantId"] = variant_id
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
|