@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,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
+ }