@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,379 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
模板 registry 加载器 - 统一抽象本地文件 / HTTP API 两种数据源。
|
|
4
|
+
|
|
5
|
+
为什么独立成一个模块:
|
|
6
|
+
template-bind、render-video 等多个 skill 都要读 registry,集中在一处便于:
|
|
7
|
+
- 缓存策略一致(HTTP 拉取 5 分钟 TTL + ETag 复用)
|
|
8
|
+
- 解析顺序一致(环境变量 → 本地文件 → monorepo 回退)
|
|
9
|
+
- 错误降级一致(HTTP 故障时复用上一次缓存)
|
|
10
|
+
|
|
11
|
+
环境变量解析优先级(高 → 低):
|
|
12
|
+
1. VIDEO_TEMPLATE_REGISTRY 显式本地文件路径(CI / 调试)
|
|
13
|
+
2. VIDEO_TEMPLATE_REGISTRY_URL HTTP API(生产,多租户由 ab-api 鉴权过滤)
|
|
14
|
+
3. monorepo 默认回退 <repo>/template-library/packages/metadata/registry.json
|
|
15
|
+
|
|
16
|
+
P1.2 — list/detail split (待后端就绪):
|
|
17
|
+
100+ 模板时 ``GET /api/template/list`` 应该只返摘要(id / name / aspects /
|
|
18
|
+
styleTags / status / llmHint),``GET /api/template/{id}`` 按需返完整定义。
|
|
19
|
+
目前本模块一次拉整个 registry。后端 endpoint 就绪后只需在 ``_fetch_http``
|
|
20
|
+
下新增 ``_fetch_summary`` / ``_fetch_detail`` 两个函数,``load_registry_data``
|
|
21
|
+
改成「先拉摘要,触发 ``get_template`` 时再补全」,公共 API 不变。
|
|
22
|
+
调用方今天用 ``get_template(id)`` / ``list_templates()`` 已经是按需消费的形态,
|
|
23
|
+
迁移时不需要改任何 caller。
|
|
24
|
+
|
|
25
|
+
本地 dev 调试开关:
|
|
26
|
+
VIDEO_TEMPLATE_REGISTRY_PREFER_LOCAL=1
|
|
27
|
+
将 monorepo registry.json 提到 #2 之前 —— 即"软优先"语义:
|
|
28
|
+
- monorepo 文件存在 → 用本地源码(与 template-library 改动严格一致)
|
|
29
|
+
- monorepo 文件不存在 → 自动落到 HTTP API,行为与未开启此开关时一致
|
|
30
|
+
设计意图:让本地开发避开"数据库 template_json 与 template-library 源码不一致"
|
|
31
|
+
导致的渲染问题,同时不破坏在容器/CI 中错误启用此开关时的鲁棒性。
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import hashlib
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import sys
|
|
40
|
+
import tempfile
|
|
41
|
+
import time
|
|
42
|
+
import urllib.error
|
|
43
|
+
import urllib.request
|
|
44
|
+
from functools import lru_cache
|
|
45
|
+
from typing import Optional
|
|
46
|
+
|
|
47
|
+
from template_paths import monorepo_registry_path
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"load_registry_data",
|
|
51
|
+
"load_registry_indexed",
|
|
52
|
+
"get_template",
|
|
53
|
+
"list_templates",
|
|
54
|
+
"invalidate_cache",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
# 默认缓存 TTL(秒)。HTTP 拉取后该时间内不再重新请求。
|
|
58
|
+
_DEFAULT_TTL_SECONDS = 300
|
|
59
|
+
|
|
60
|
+
# HTTP 请求超时(秒)。registry 体积小,超时设短一些避免阻塞。
|
|
61
|
+
_DEFAULT_HTTP_TIMEOUT = 5
|
|
62
|
+
|
|
63
|
+
# 缓存目录:默认 /tmp,CI/容器中也能写。
|
|
64
|
+
_CACHE_DIR = os.environ.get(
|
|
65
|
+
"VIDEO_TEMPLATE_REGISTRY_CACHE_DIR",
|
|
66
|
+
os.path.join(tempfile.gettempdir(), "ab-template-registry"),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _monorepo_fallback_path() -> str:
|
|
71
|
+
"""monorepo 内 registry.json 的路径(仅 dev / 本地单仓部署用)。
|
|
72
|
+
|
|
73
|
+
路径解析现已统一到 ``template_paths.monorepo_registry_path``;
|
|
74
|
+
本函数保留只是为了让模块内其它函数(``_load_from_url`` 的兜底分支)
|
|
75
|
+
继续按既有名字调用。
|
|
76
|
+
"""
|
|
77
|
+
return monorepo_registry_path()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _cache_path_for(url: str) -> str:
|
|
81
|
+
"""根据 URL 生成本地缓存文件路径,URL 不同则缓存隔离。"""
|
|
82
|
+
digest = hashlib.sha256(url.encode("utf-8")).hexdigest()[:16]
|
|
83
|
+
return os.path.join(_CACHE_DIR, f"registry-{digest}.json")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _read_cached(cache_file: str) -> Optional[dict]:
|
|
87
|
+
"""读取缓存元数据 + 内容;任何异常都返回 None。"""
|
|
88
|
+
if not os.path.exists(cache_file):
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
with open(cache_file, "r", encoding="utf-8") as f:
|
|
92
|
+
return json.load(f)
|
|
93
|
+
except (OSError, json.JSONDecodeError):
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _write_cached(cache_file: str, payload: dict) -> None:
|
|
98
|
+
"""原子化写入缓存(先写临时文件再 rename,避免半写文件)。"""
|
|
99
|
+
os.makedirs(os.path.dirname(cache_file), exist_ok=True)
|
|
100
|
+
tmp_fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(cache_file))
|
|
101
|
+
try:
|
|
102
|
+
with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
|
|
103
|
+
json.dump(payload, f, ensure_ascii=False)
|
|
104
|
+
os.replace(tmp_path, cache_file)
|
|
105
|
+
except Exception:
|
|
106
|
+
try:
|
|
107
|
+
os.unlink(tmp_path)
|
|
108
|
+
except OSError:
|
|
109
|
+
pass
|
|
110
|
+
raise
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _fetch_http(url: str, timeout: int) -> dict:
|
|
114
|
+
"""执行 HTTP 请求,自动加 X-Priv-Token / If-None-Match 头。
|
|
115
|
+
|
|
116
|
+
支持两种 endpoint 形态:
|
|
117
|
+
- 老形态(@ab-templates/metadata 静态文件):GET 返回纯 registry JSON
|
|
118
|
+
- ab-api 形态:POST 返回 {code, msg, data: <registry>} 包装
|
|
119
|
+
|
|
120
|
+
通过环境变量 VIDEO_TEMPLATE_REGISTRY_HTTP_METHOD 切换,默认 POST 适配 ab-api。
|
|
121
|
+
"""
|
|
122
|
+
cache_file = _cache_path_for(url)
|
|
123
|
+
cached = _read_cached(cache_file)
|
|
124
|
+
|
|
125
|
+
method = os.environ.get("VIDEO_TEMPLATE_REGISTRY_HTTP_METHOD", "POST").upper()
|
|
126
|
+
|
|
127
|
+
headers = {"Accept": "application/json"}
|
|
128
|
+
if method == "POST":
|
|
129
|
+
headers["Content-Type"] = "application/json"
|
|
130
|
+
if token := os.environ.get("PRIV_TOKEN", "").strip():
|
|
131
|
+
headers["X-Priv-Token"] = token
|
|
132
|
+
if cached and (etag := cached.get("etag")):
|
|
133
|
+
headers["If-None-Match"] = etag
|
|
134
|
+
|
|
135
|
+
body = b"{}" if method == "POST" else None
|
|
136
|
+
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
140
|
+
raw = resp.read().decode("utf-8")
|
|
141
|
+
etag = resp.headers.get("ETag", "")
|
|
142
|
+
parsed = json.loads(raw)
|
|
143
|
+
|
|
144
|
+
# ab-api 风格:{code, msg, data} → 取 data;其他场景视作直接 registry
|
|
145
|
+
if isinstance(parsed, dict) and "data" in parsed and "code" in parsed:
|
|
146
|
+
if parsed.get("code") not in (0, "0"):
|
|
147
|
+
raise RuntimeError(
|
|
148
|
+
f"registry HTTP 业务失败: code={parsed.get('code')} msg={parsed.get('msg')}"
|
|
149
|
+
)
|
|
150
|
+
data = parsed["data"]
|
|
151
|
+
else:
|
|
152
|
+
data = parsed
|
|
153
|
+
|
|
154
|
+
_write_cached(
|
|
155
|
+
cache_file,
|
|
156
|
+
{"fetchedAt": time.time(), "etag": etag, "data": data},
|
|
157
|
+
)
|
|
158
|
+
return data
|
|
159
|
+
except urllib.error.HTTPError as e:
|
|
160
|
+
# 304 Not Modified — 复用缓存内容
|
|
161
|
+
if e.code == 304 and cached:
|
|
162
|
+
cached["fetchedAt"] = time.time()
|
|
163
|
+
_write_cached(cache_file, cached)
|
|
164
|
+
return cached["data"]
|
|
165
|
+
raise
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _load_from_url(url: str, ttl: int, timeout: int) -> dict:
|
|
169
|
+
"""先看缓存是否在 TTL 内;超期则远程拉取,失败时按以下顺序降级:
|
|
170
|
+
1. HTTP 缓存(fetchedAt 之后的最后一份成功响应)
|
|
171
|
+
2. monorepo 本地 registry.json(仅本地 dev 时存在)
|
|
172
|
+
3. 都没有则抛错
|
|
173
|
+
"""
|
|
174
|
+
cache_file = _cache_path_for(url)
|
|
175
|
+
cached = _read_cached(cache_file)
|
|
176
|
+
now = time.time()
|
|
177
|
+
|
|
178
|
+
if cached and (now - cached.get("fetchedAt", 0)) < ttl:
|
|
179
|
+
return cached["data"]
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
return _fetch_http(url, timeout)
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
# 1. HTTP 故障时降级:有缓存就继续用,写日志告警
|
|
185
|
+
if cached:
|
|
186
|
+
print(
|
|
187
|
+
f"⚠️ registry HTTP 拉取失败,降级使用本地缓存: {exc}",
|
|
188
|
+
file=sys.stderr,
|
|
189
|
+
)
|
|
190
|
+
return cached["data"]
|
|
191
|
+
# 2. 没缓存再尝试 monorepo fallback(本地 dev / 单仓库部署时这条路一定通)
|
|
192
|
+
fallback = _monorepo_fallback_path()
|
|
193
|
+
if os.path.exists(fallback):
|
|
194
|
+
print(
|
|
195
|
+
f"⚠️ registry HTTP 拉取失败且无 HTTP 缓存,降级使用 monorepo registry: {exc}",
|
|
196
|
+
file=sys.stderr,
|
|
197
|
+
)
|
|
198
|
+
return _load_from_file(fallback)
|
|
199
|
+
# 3. 都没有,向上抛
|
|
200
|
+
raise RuntimeError(
|
|
201
|
+
f"registry HTTP 拉取失败且无可用缓存 / 本地 fallback: {url} ({exc})"
|
|
202
|
+
) from exc
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _load_from_file(path: str) -> dict:
|
|
206
|
+
"""从本地文件加载并解析 registry.json。"""
|
|
207
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
208
|
+
return json.load(f)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _truthy(value: str) -> bool:
|
|
212
|
+
"""统一解析布尔型环境变量:1 / true / yes / on(大小写不敏感)。"""
|
|
213
|
+
return value.strip().lower() in ("1", "true", "yes", "on")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def load_registry_data(
|
|
217
|
+
*,
|
|
218
|
+
ttl_seconds: int = _DEFAULT_TTL_SECONDS,
|
|
219
|
+
http_timeout: int = _DEFAULT_HTTP_TIMEOUT,
|
|
220
|
+
) -> dict:
|
|
221
|
+
"""加载 registry 完整文档(含 version / generatedAt / templates)。
|
|
222
|
+
|
|
223
|
+
解析顺序:
|
|
224
|
+
1. 环境变量 VIDEO_TEMPLATE_REGISTRY 指定的本地文件
|
|
225
|
+
2. 若 VIDEO_TEMPLATE_REGISTRY_PREFER_LOCAL 开启且 monorepo registry 存在,先走本地
|
|
226
|
+
3. 环境变量 VIDEO_TEMPLATE_REGISTRY_URL 指定的 HTTP API(带缓存)
|
|
227
|
+
4. monorepo 默认回退(仅本地 dev 有效)
|
|
228
|
+
|
|
229
|
+
任何来源都返回相同形状的 dict,调用方按 `data.get("templates", [])` 取列表。
|
|
230
|
+
"""
|
|
231
|
+
if explicit_path := os.environ.get("VIDEO_TEMPLATE_REGISTRY", "").strip():
|
|
232
|
+
print(f"[registry_loader] using explicit file: {explicit_path}", file=sys.stderr)
|
|
233
|
+
return _load_from_file(explicit_path)
|
|
234
|
+
|
|
235
|
+
prefer_local = _truthy(os.environ.get("VIDEO_TEMPLATE_REGISTRY_PREFER_LOCAL", ""))
|
|
236
|
+
fallback = _monorepo_fallback_path()
|
|
237
|
+
|
|
238
|
+
if prefer_local and os.path.exists(fallback):
|
|
239
|
+
print(
|
|
240
|
+
f"[registry_loader] PREFER_LOCAL=1, using monorepo registry: {fallback}",
|
|
241
|
+
file=sys.stderr,
|
|
242
|
+
)
|
|
243
|
+
return _load_from_file(fallback)
|
|
244
|
+
|
|
245
|
+
if url := os.environ.get("VIDEO_TEMPLATE_REGISTRY_URL", "").strip():
|
|
246
|
+
if prefer_local:
|
|
247
|
+
# 走到这里说明开启了 PREFER_LOCAL 但 monorepo 文件不存在 —— 例如容器里
|
|
248
|
+
# 误启用此开关。给出明确告警,避免使用者以为还在用本地源码。
|
|
249
|
+
print(
|
|
250
|
+
f"[registry_loader] PREFER_LOCAL=1 but monorepo registry not found"
|
|
251
|
+
f" ({fallback}), falling back to HTTP: {url}",
|
|
252
|
+
file=sys.stderr,
|
|
253
|
+
)
|
|
254
|
+
else:
|
|
255
|
+
print(f"[registry_loader] using HTTP registry: {url}", file=sys.stderr)
|
|
256
|
+
return _load_from_url(url, ttl=ttl_seconds, timeout=http_timeout)
|
|
257
|
+
|
|
258
|
+
# 第 ③ 级:monorepo 相对路径兜底。仅在该文件【实际存在】时生效
|
|
259
|
+
# (私有 monorepo 本地源码可见的场景)。独立开源仓库 clone / npm 安装
|
|
260
|
+
# 后该路径不存在,这里静默跳过,不抛错、不中断,落到下方的可理解失败。
|
|
261
|
+
if os.path.exists(fallback):
|
|
262
|
+
print(
|
|
263
|
+
f"[registry_loader] using monorepo registry (default fallback): {fallback}",
|
|
264
|
+
file=sys.stderr,
|
|
265
|
+
)
|
|
266
|
+
return _load_from_file(fallback)
|
|
267
|
+
|
|
268
|
+
raise RuntimeError(
|
|
269
|
+
"找不到模板 registry。请设置 VIDEO_TEMPLATE_REGISTRY_URL(ab-api HTTP 接口)"
|
|
270
|
+
" 并配置有效的 PRIV_TOKEN,或用 VIDEO_TEMPLATE_REGISTRY 指向一份本地"
|
|
271
|
+
" registry.json。(monorepo 内本地源码可见时会自动走 template-library 兜底。)"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
276
|
+
# Indexed views — O(1) template-id lookup + status / beta gating
|
|
277
|
+
#
|
|
278
|
+
# `load_registry_data` returns the raw registry document; consumers that
|
|
279
|
+
# repeatedly look up templates by id (validator, render_video, gen-script)
|
|
280
|
+
# used to do `for tpl in registry.get("templates", []): if tpl["templateId"]
|
|
281
|
+
# == X: ...` — O(N) per call. With 100+ templates that becomes 100 scans for
|
|
282
|
+
# every validate() invocation. The helpers below materialize the list into a
|
|
283
|
+
# dict[templateId → tpl] once per process and serve everyone from there.
|
|
284
|
+
#
|
|
285
|
+
# `status` / `version` are P2.2 fields on template.json. When templates
|
|
286
|
+
# don't declare them yet, we treat them as stable + version "0.0.0" — pure
|
|
287
|
+
# additive behavior, no regressions on legacy templates.
|
|
288
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
289
|
+
|
|
290
|
+
_DEFAULT_STATUS = "stable"
|
|
291
|
+
_BETA_GATE_ENV = "ENABLE_BETA_TEMPLATES"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _truthy_env(name: str) -> bool:
|
|
295
|
+
return _truthy(os.environ.get(name, ""))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _resolve_status(tpl: dict) -> str:
|
|
299
|
+
raw = tpl.get("status")
|
|
300
|
+
if isinstance(raw, str) and raw.strip():
|
|
301
|
+
return raw.strip().lower()
|
|
302
|
+
return _DEFAULT_STATUS
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _allowed_statuses() -> set[str]:
|
|
306
|
+
"""Which template statuses the current process is allowed to see.
|
|
307
|
+
|
|
308
|
+
Default: only `stable`. Set `ENABLE_BETA_TEMPLATES=1` (or any truthy
|
|
309
|
+
value) to also include `beta`. `deprecated` is always hidden — callers
|
|
310
|
+
that explicitly need it can call ``load_registry_data`` directly.
|
|
311
|
+
"""
|
|
312
|
+
allowed = {"stable"}
|
|
313
|
+
if _truthy_env(_BETA_GATE_ENV):
|
|
314
|
+
allowed.add("beta")
|
|
315
|
+
return allowed
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@lru_cache(maxsize=4)
|
|
319
|
+
def _indexed_view(allowed_statuses_key: Optional[tuple[str, ...]]) -> dict[str, dict]:
|
|
320
|
+
"""Build (and memoize) ``{templateId: tpl}`` keyed by the allowed-status set.
|
|
321
|
+
|
|
322
|
+
The key is a sorted tuple so equal sets map to the same cache entry. The
|
|
323
|
+
HTTP/file load underneath has its own TTL — this layer only avoids
|
|
324
|
+
re-iterating the list across the typical 10-100 lookups per render run.
|
|
325
|
+
"""
|
|
326
|
+
raw = load_registry_data()
|
|
327
|
+
allow_filter = allowed_statuses_key is not None
|
|
328
|
+
allowed = set(allowed_statuses_key) if allowed_statuses_key else set()
|
|
329
|
+
out: dict[str, dict] = {}
|
|
330
|
+
for tpl in raw.get("templates", []) or []:
|
|
331
|
+
tid = tpl.get("templateId")
|
|
332
|
+
if not isinstance(tid, str) or not tid:
|
|
333
|
+
continue
|
|
334
|
+
if allow_filter and _resolve_status(tpl) not in allowed:
|
|
335
|
+
continue
|
|
336
|
+
out[tid] = tpl
|
|
337
|
+
return out
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def load_registry_indexed(
|
|
341
|
+
*,
|
|
342
|
+
ttl_seconds: int = _DEFAULT_TTL_SECONDS,
|
|
343
|
+
http_timeout: int = _DEFAULT_HTTP_TIMEOUT,
|
|
344
|
+
include_all_statuses: bool = False,
|
|
345
|
+
) -> dict[str, dict]:
|
|
346
|
+
"""Return registry as ``{templateId: template_dict}``.
|
|
347
|
+
|
|
348
|
+
Filters by the process-wide status gate unless ``include_all_statuses``
|
|
349
|
+
is set (used by maintenance tooling like ``check_contracts``). Templates
|
|
350
|
+
missing ``status`` are treated as ``stable`` for backwards compatibility.
|
|
351
|
+
|
|
352
|
+
``ttl_seconds`` / ``http_timeout`` are accepted for API symmetry with
|
|
353
|
+
``load_registry_data`` but the memoization layer below is per-process
|
|
354
|
+
rather than time-based; call ``invalidate_cache()`` to force a reread.
|
|
355
|
+
"""
|
|
356
|
+
if include_all_statuses:
|
|
357
|
+
return _indexed_view(None)
|
|
358
|
+
return _indexed_view(tuple(sorted(_allowed_statuses())))
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def get_template(template_id: str, *, include_all_statuses: bool = False) -> Optional[dict]:
|
|
362
|
+
"""Return a single template dict by id, or None when missing/filtered out."""
|
|
363
|
+
if not template_id:
|
|
364
|
+
return None
|
|
365
|
+
return load_registry_indexed(include_all_statuses=include_all_statuses).get(template_id)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def list_templates(*, include_all_statuses: bool = False) -> list[dict]:
|
|
369
|
+
"""Return the visible templates as a list (status-filtered)."""
|
|
370
|
+
return list(load_registry_indexed(include_all_statuses=include_all_statuses).values())
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def invalidate_cache() -> None:
|
|
374
|
+
"""Drop the indexed-view memoization. Mostly for tests / hot reload.
|
|
375
|
+
|
|
376
|
+
Note: the on-disk HTTP cache is independent and respects its own TTL;
|
|
377
|
+
this only resets the in-process memoization built by ``lru_cache``.
|
|
378
|
+
"""
|
|
379
|
+
_indexed_view.cache_clear()
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
render_job_client.py — ab-api renderJob 接口 Python 客户端
|
|
4
|
+
|
|
5
|
+
封装对 ab-api /api/renderJob/* 的调用,供 render_video.py 和
|
|
6
|
+
gen_jianying_draft.py 使用。
|
|
7
|
+
|
|
8
|
+
认证方式: X-Priv-Token header(与其他 skill 脚本一致)
|
|
9
|
+
接口地址: 由环境变量 MM_BACKEND_API_URL 控制(默认 http://localhost:2999/api)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _api_base() -> str:
|
|
20
|
+
return os.environ.get("MM_BACKEND_API_URL", "http://localhost:2999/api").rstrip("/")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _make_headers(priv_token: str) -> dict:
|
|
24
|
+
headers = {
|
|
25
|
+
"Content-Type": "application/json",
|
|
26
|
+
"X-Priv-Token": priv_token,
|
|
27
|
+
"x-invoke-skill": "render-video",
|
|
28
|
+
}
|
|
29
|
+
agent_name = os.environ.get("AGENT_NAME", "")
|
|
30
|
+
if agent_name:
|
|
31
|
+
headers["x-invoke-agent"] = agent_name
|
|
32
|
+
return headers
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _post(path: str, payload: dict, priv_token: str, timeout: int = 30) -> dict:
|
|
36
|
+
"""发送 POST 请求并返回响应 data 字段;失败时抛出 RuntimeError。"""
|
|
37
|
+
url = f"{_api_base()}{path}"
|
|
38
|
+
body = json.dumps(payload).encode("utf-8")
|
|
39
|
+
req = urllib.request.Request(url, data=body, headers=_make_headers(priv_token), method="POST")
|
|
40
|
+
try:
|
|
41
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
42
|
+
result = json.loads(resp.read().decode("utf-8"))
|
|
43
|
+
except urllib.error.HTTPError as e:
|
|
44
|
+
raise RuntimeError(f"HTTP {e.code} request failed: {path}") from e
|
|
45
|
+
except urllib.error.URLError as e:
|
|
46
|
+
raise RuntimeError(f"network request failed ({path}): {e.reason}") from e
|
|
47
|
+
|
|
48
|
+
if result.get("code") != 0:
|
|
49
|
+
msg = result.get("msg") or result.get("message") or "unknown error"
|
|
50
|
+
raise RuntimeError(f"API returned an error [{path}]: {msg}")
|
|
51
|
+
|
|
52
|
+
return result.get("data") or {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def create_job(priv_token: str, dsl: str = "", dsl_meta: dict = None) -> int:
|
|
59
|
+
"""
|
|
60
|
+
创建渲染任务。
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
priv_token: PrivToken
|
|
64
|
+
dsl: 完整 DSL JSON 字符串(可选)
|
|
65
|
+
dsl_meta: DSL 摘要字典(可选,将被序列化为 JSON)
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
jobId (int)
|
|
69
|
+
"""
|
|
70
|
+
payload: dict = {}
|
|
71
|
+
if dsl:
|
|
72
|
+
payload["dsl"] = dsl
|
|
73
|
+
if dsl_meta:
|
|
74
|
+
payload["dslMeta"] = dsl_meta
|
|
75
|
+
|
|
76
|
+
data = _post("/renderJob/create", payload, priv_token)
|
|
77
|
+
job_id = data.get("jobId")
|
|
78
|
+
if not job_id:
|
|
79
|
+
raise RuntimeError("create render job failed: response has no jobId")
|
|
80
|
+
return int(job_id)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def save_plan(job_id: int, render_plan: str, priv_token: str) -> None:
|
|
84
|
+
"""
|
|
85
|
+
保存 RenderPlan 到数据库(Phase 1 完成后调用)。
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
job_id: 渲染任务 ID
|
|
89
|
+
render_plan: 完整 RenderPlan JSON 字符串
|
|
90
|
+
priv_token: PrivToken
|
|
91
|
+
"""
|
|
92
|
+
_post("/renderJob/savePlan", {"jobId": job_id, "renderPlan": render_plan}, priv_token)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_plan(job_id: int, priv_token: str) -> str:
|
|
96
|
+
"""
|
|
97
|
+
从数据库加载 RenderPlan(Phase 2 开始前调用)。
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
job_id: 渲染任务 ID
|
|
101
|
+
priv_token: PrivToken
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
RenderPlan JSON 字符串
|
|
105
|
+
"""
|
|
106
|
+
data = _post("/renderJob/getPlan", {"jobId": job_id}, priv_token)
|
|
107
|
+
render_plan = data.get("renderPlan")
|
|
108
|
+
if not render_plan:
|
|
109
|
+
raise RuntimeError(f"job {job_id} has no saved RenderPlan; run --resolve-only --save-job first")
|
|
110
|
+
return render_plan
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def save_manifest(job_id: int, manifest: dict, priv_token: str) -> None:
|
|
114
|
+
"""
|
|
115
|
+
保存渲染结果到数据库(渲染完成后调用)。
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
job_id: 渲染任务 ID
|
|
119
|
+
manifest: 渲染结果字典
|
|
120
|
+
priv_token: PrivToken
|
|
121
|
+
"""
|
|
122
|
+
_post("/renderJob/saveManifest", {"jobId": job_id, "manifest": manifest}, priv_token)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_job(job_id: int, priv_token: str) -> dict:
|
|
126
|
+
"""
|
|
127
|
+
获取渲染任务完整详情。
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
job_id: 渲染任务 ID
|
|
131
|
+
priv_token: PrivToken
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
任务详情字典(含 status / renderPlan / manifest 等)
|
|
135
|
+
"""
|
|
136
|
+
return _post("/renderJob/get", {"jobId": job_id}, priv_token)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ─── CLI self-test ────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
import argparse
|
|
143
|
+
|
|
144
|
+
parser = argparse.ArgumentParser(description="render_job_client self-test tool")
|
|
145
|
+
parser.add_argument("--priv-token", default=os.environ.get("PRIV_TOKEN", ""), help="PrivToken")
|
|
146
|
+
parser.add_argument("--get", type=int, metavar="JOB_ID", help="Fetch job details")
|
|
147
|
+
parser.add_argument("--get-plan", type=int, metavar="JOB_ID", help="Fetch RenderPlan")
|
|
148
|
+
args = parser.parse_args()
|
|
149
|
+
|
|
150
|
+
token = args.priv_token
|
|
151
|
+
if not token:
|
|
152
|
+
print("❌ PRIV_TOKEN not configured (or pass --priv-token)", file=sys.stderr)
|
|
153
|
+
sys.exit(1)
|
|
154
|
+
|
|
155
|
+
if args.get:
|
|
156
|
+
result = get_job(args.get, token)
|
|
157
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
158
|
+
elif args.get_plan:
|
|
159
|
+
plan = get_plan(args.get_plan, token)
|
|
160
|
+
print(plan)
|
|
161
|
+
else:
|
|
162
|
+
parser.print_help()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Write a local registry-cache.json from @ab-templates/metadata.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python3 scripts/sync_registry.py # write cache
|
|
6
|
+
python3 scripts/sync_registry.py --registry <path> # use custom registry source
|
|
7
|
+
|
|
8
|
+
The cache is a lightweight summary for LLM prompts and offline use.
|
|
9
|
+
The authoritative source is template-library/packages/metadata/registry.json.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
# Make video_dsl.runtime importable regardless of CWD.
|
|
18
|
+
_SKILL_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
19
|
+
sys.path.insert(0, _SKILL_DIR)
|
|
20
|
+
|
|
21
|
+
from video_dsl.runtime.template_binder import sync_registry # noqa: E402
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main() -> int:
|
|
25
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--registry",
|
|
28
|
+
default=None,
|
|
29
|
+
help="Path to @ab-templates/metadata registry.json (auto-detected if omitted)",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--output",
|
|
33
|
+
default=None,
|
|
34
|
+
help="Output path for the cache file (default: video_dsl/registry-cache.json)",
|
|
35
|
+
)
|
|
36
|
+
args = parser.parse_args()
|
|
37
|
+
|
|
38
|
+
out = sync_registry(output_path=args.output, registry_path=args.registry)
|
|
39
|
+
print(f"wrote {out}")
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
sys.exit(main())
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Centralized monorepo path resolution for template-library artifacts.
|
|
2
|
+
|
|
3
|
+
Why this module exists
|
|
4
|
+
----------------------
|
|
5
|
+
Before this file landed, every caller that needed to reach into the
|
|
6
|
+
monorepo's ``template-library/`` from inside ``apps/ab-skill/`` had to count
|
|
7
|
+
``..`` segments by hand. The depth differs per call site:
|
|
8
|
+
|
|
9
|
+
skills/template-bind/scripts/registry_loader.py → 5 × ".."
|
|
10
|
+
skills/template-bind/scripts/check_contracts.py → 3 × ".."
|
|
11
|
+
skills/template-bind/video_dsl/runtime/template_binder → 6 × ".."
|
|
12
|
+
skills/gen-script/scripts/gen_script.py → 5 × ".."
|
|
13
|
+
skills/render-video/scripts/render_video.py → 4 × ".."
|
|
14
|
+
|
|
15
|
+
Five copies, five chances for an off-by-one bug, and any future directory
|
|
16
|
+
move silently breaks one site without touching the others. This module
|
|
17
|
+
computes the monorepo root **once** from its own location and exposes
|
|
18
|
+
helpers so callers no longer count.
|
|
19
|
+
|
|
20
|
+
All sibling skills now go through these helpers; the TS handler in
|
|
21
|
+
``src/handlers/template-bind.ts`` keeps its own one-off computation
|
|
22
|
+
because it cannot import this Python module.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
__all__ = ["monorepo_root", "monorepo_registry_path", "monorepo_template_src_dir"]
|
|
30
|
+
|
|
31
|
+
# This file lives at:
|
|
32
|
+
# <repo>/apps/ab-skill/skills/template-bind/scripts/template_paths.py
|
|
33
|
+
# So the monorepo root is 5 levels up.
|
|
34
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
35
|
+
_REPO_ROOT = os.path.normpath(os.path.join(_HERE, "..", "..", "..", "..", ".."))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def monorepo_root() -> str:
|
|
39
|
+
"""Absolute path to the ab-platform monorepo root.
|
|
40
|
+
|
|
41
|
+
Always returns a string; the caller is responsible for checking
|
|
42
|
+
``os.path.isdir(...)`` if the root may genuinely be absent (e.g. when
|
|
43
|
+
the skill is consumed standalone via the npm package).
|
|
44
|
+
"""
|
|
45
|
+
return _REPO_ROOT
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def monorepo_registry_path() -> str:
|
|
49
|
+
"""Path to ``template-library/packages/metadata/registry.json``.
|
|
50
|
+
|
|
51
|
+
Always returns the computed path even when the file does not exist —
|
|
52
|
+
so callers can keep their existing ``os.path.exists(...)`` / try-load
|
|
53
|
+
branches unchanged. Mirrors the historical behavior of the 4 in-tree
|
|
54
|
+
helpers it replaces.
|
|
55
|
+
"""
|
|
56
|
+
return os.path.join(_REPO_ROOT, "template-library", "packages", "metadata", "registry.json")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def monorepo_template_src_dir(template_id: str) -> Optional[str]:
|
|
60
|
+
"""Locate a template's source directory under ``template-library``.
|
|
61
|
+
|
|
62
|
+
Returns the absolute path **only when the directory exists**; returns
|
|
63
|
+
``None`` for the standalone / npm-consumer case where there is no
|
|
64
|
+
monorepo to reach into. Matches ``gen_script._locate_template_dir``'s
|
|
65
|
+
historical contract (callers test the result for None).
|
|
66
|
+
"""
|
|
67
|
+
if not template_id:
|
|
68
|
+
return None
|
|
69
|
+
tpl_dir = os.path.join(
|
|
70
|
+
_REPO_ROOT, "template-library", "packages", "templates", "src", template_id
|
|
71
|
+
)
|
|
72
|
+
return tpl_dir if os.path.isdir(tpl_dir) else None
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "template-bind",
|
|
3
|
+
"toolName": "template_bind",
|
|
4
|
+
"tier": "orchestration",
|
|
5
|
+
"title": "Template Registry (List-only)",
|
|
6
|
+
"description": "List all available video templates (templateId / name / aspect ratio / style tags). Template-to-DSL binding is no longer exposed as a separate step — once prepare_video_assets receives a template_id it runs the full DSL→RenderPlan pipeline internally.",
|
|
7
|
+
"envVars": ["PRIV_TOKEN", "VIDEO_TEMPLATE_REGISTRY", "VIDEO_TEMPLATE_REGISTRY_URL", "VIDEO_TEMPLATE_REGISTRY_HTTP_METHOD"],
|
|
8
|
+
"entry": { "type": "builtin", "handler": "template-bind" },
|
|
9
|
+
"parameters": {
|
|
10
|
+
"type": "object",
|
|
11
|
+
"properties": {
|
|
12
|
+
"list_templates": { "type": "boolean", "description": "List available templates (default behavior; also implied when other flags are passed)." },
|
|
13
|
+
"filter_tag": { "type": "string", "description": "Show only templates whose styleTags contain (or are contained in) this string. Case-insensitive. E.g. 'tech' / '科普' / 'walkthrough'." },
|
|
14
|
+
"filter_aspect": { "type": "string", "description": "Show only templates that declare this aspect ratio. E.g. '9:16' / '16:9' / '1:1'." },
|
|
15
|
+
"filter_language": { "type": "string", "description": "Show only templates whose contentLanguage includes this code ('zh' or 'en'). Templates with no declared language are always shown (treated as language-agnostic)." },
|
|
16
|
+
"include_beta": { "type": "boolean", "description": "Also list templates with status='beta'. Default off (only 'stable' shows). The ENABLE_BETA_TEMPLATES env var has the same effect process-wide." }
|
|
17
|
+
},
|
|
18
|
+
"required": []
|
|
19
|
+
}
|
|
20
|
+
}
|