@remixmate/cli 0.9.50 → 0.9.52

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 (32) hide show
  1. package/README.md +14 -26
  2. package/dist/manifest.json +27 -14
  3. package/package.json +10 -13
  4. package/skills/doc-parse/SKILL.md +6 -6
  5. package/skills/doc-parse/scripts/extract_via_render.py +3 -2
  6. package/skills/doc-parse/skill.json +1 -1
  7. package/skills/gen-script/SKILL.md +19 -18
  8. package/skills/gen-script/scripts/gen_script.py +170 -38
  9. package/skills/gen-script/skill.json +14 -4
  10. package/skills/gen-script/version.json +1 -1
  11. package/skills/prepare-video-assets/SKILL.md +3 -3
  12. package/skills/prepare-video-assets/skill.json +1 -1
  13. package/skills/prepare-video-assets/version.json +1 -1
  14. package/skills/render-video/scripts/render_video.py +53 -42
  15. package/skills/render-video/version.json +1 -1
  16. package/skills/template-registry/README.md +4 -15
  17. package/skills/template-registry/SKILL.md +20 -52
  18. package/skills/template-registry/scripts/countdown_spec.py +56 -6
  19. package/skills/template-registry/scripts/list_templates.py +133 -8
  20. package/skills/template-registry/scripts/registry_loader.py +3 -4
  21. package/skills/template-registry/skill.json +2 -2
  22. package/skills/template-registry/version.json +1 -1
  23. package/skills/template-registry/video_dsl/README.md +10 -53
  24. package/skills/template-registry/video_dsl/runtime/__init__.py +0 -1
  25. package/skills/template-registry/video_dsl/runtime/dsl_validator.py +108 -35
  26. package/skills/template-registry/video_dsl/runtime/narration_budget.py +129 -0
  27. package/skills/template-registry/video_dsl/schema/video-dsl-v1alpha1.json +1 -1
  28. package/skills/template-registry/scripts/check_contracts.py +0 -179
  29. package/skills/template-registry/scripts/sync_registry.py +0 -44
  30. package/skills/template-registry/scripts/template_paths.py +0 -72
  31. package/skills/template-registry/scripts/verify_props_contract.py +0 -273
  32. package/skills/template-registry/video_dsl/runtime/template_binder.py +0 -87
@@ -0,0 +1,129 @@
1
+ """
2
+ 旁白时长预算 —— 规划期(写脚本时)估算「这段旁白念完、这一场实际会有多长」。
3
+
4
+ 为什么单独成模块:以前这个数有三份且互不一致 —— gen_script 的 ``CHARS_PER_SECOND = 5.0``
5
+ (从没参与计算,只出现在注释里)、gen-script SKILL.md 的「3–4 字/秒」、timeline_compiler
6
+ 的 5.0(那份是没有 TTS 时间戳时估字幕时长的兜底,语义不同,不归这里管)。没有任何一环
7
+ 真的拿旁白字数去核对场景时长,于是 2026-09-27 线上一条会话:确认稿写 75s、每场 8–9s,
8
+ 每场却写了 45–58 字,成片 116.9s(+56%),用户确认的时长从一开始就不可能兑现。
9
+
10
+ 模型 = 语速 + 行间停顿 + 场景尾巴,三项都按那条会话的 9 条 TTS 实测标定:
11
+
12
+ * 语速:纯整段旁白的场景 1.1× 下 ≈ 4.8–5.2 计数字/秒(汉字 + 英文词×2)→ 1.0× 取
13
+ ``PLAN_CHARS_PER_SEC_1X = 4.5``(略偏保守);
14
+ * 行间停顿:分行旁白(``narration.items`` / intro / outro)每多一行,TTS 按段合成,
15
+ 段间约 0.9s 的静音 —— 逐项高亮版式同样字数要慢出 30–60%,主要就是它;
16
+ * 场景尾巴:``render_video.adjust_timeline_to_audio`` 在每场音频后补的留白
17
+ (模板 ``capabilities.defaultTailPadSec``,缺省 1.5s)。9 场就是 13.5s,写脚本时
18
+ 从没人算进去。
19
+
20
+ 样本只有一条会话,所以这些是**规划用**的估计,不是承诺;估不准的方向应当是偏保守
21
+ (宁可让模型少写几个字)。英文语速未标定,先按常见 TTS 语速给。
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import math
27
+ import re
28
+ from typing import Optional
29
+
30
+ # 语速(1.0× 下)。中文按「计数字」(见 count_speech_units),英文按词。
31
+ PLAN_CHARS_PER_SEC_1X = 4.5
32
+ PLAN_WORDS_PER_SEC_1X = 2.5 # 未标定
33
+
34
+ # 分行旁白每一处换行带来的静音。
35
+ LINE_PAUSE_SEC = 0.9
36
+
37
+ # 必须与 render_video.adjust_timeline_to_audio 的缺省尾巴一致(int(fps * 1.5))。
38
+ DEFAULT_TAIL_PAD_SEC = 1.5
39
+
40
+ # 估算总时长超出目标多少才告警。低于这个幅度属于估算误差本身的量级。
41
+ OVERRUN_WARN_RATIO = 0.3
42
+
43
+ _CJK = re.compile(r"[\u3400-\u9fff\uf900-\ufaff]")
44
+ # 中文旁白里夹的英文词 / 数字组("AI Agent"、"2026")。一个英文词 TTS 念出来大约
45
+ # 占两个汉字的时长。
46
+ _LATIN_WORD = re.compile(r"[A-Za-z0-9]+(?:['’.-][A-Za-z0-9]+)*")
47
+
48
+
49
+ def count_speech_units(text: str) -> tuple[float, str]:
50
+ """返回 (计数, 语言)。有汉字按中文计(汉字 + 英文词×2),否则按英文词计。"""
51
+ if not isinstance(text, str) or not text.strip():
52
+ return 0.0, "zh"
53
+ cjk = len(_CJK.findall(text))
54
+ words = len(_LATIN_WORD.findall(text))
55
+ if cjk:
56
+ return float(cjk + 2 * words), "zh"
57
+ return float(words), "en"
58
+
59
+
60
+ def narration_lines(narration: object) -> list[str]:
61
+ """旁白按 TTS 实际合成的段拆开:分行形态 = intro + items + outro,否则整段 text。
62
+
63
+ 与 render_video.extract_narration_lines 同口径:有 items 时 text 被整个忽略。
64
+ """
65
+ if not isinstance(narration, dict):
66
+ return []
67
+ items = narration.get("items")
68
+ lines: list[str] = []
69
+ if isinstance(items, list):
70
+ for x in items:
71
+ t = x.get("text", "") if isinstance(x, dict) else x
72
+ if isinstance(t, str) and t.strip():
73
+ lines.append(t.strip())
74
+ if lines:
75
+ for key, at_front in (("intro", True), ("outro", False)):
76
+ v = narration.get(key)
77
+ if isinstance(v, str) and v.strip():
78
+ if at_front:
79
+ lines.insert(0, v.strip())
80
+ else:
81
+ lines.append(v.strip())
82
+ return lines
83
+ text = narration.get("text")
84
+ return [text.strip()] if isinstance(text, str) and text.strip() else []
85
+
86
+
87
+ def speech_rate(language: str, speed: float) -> float:
88
+ base = PLAN_CHARS_PER_SEC_1X if language == "zh" else PLAN_WORDS_PER_SEC_1X
89
+ return base * (speed if speed and speed > 0 else 1.0)
90
+
91
+
92
+ def estimate_speech_sec(narration: object, speed: float = 1.0) -> float:
93
+ """念完这段旁白要多少秒(含分行停顿,不含场景尾巴)。"""
94
+ lines = narration_lines(narration)
95
+ if not lines:
96
+ return 0.0
97
+ units, language = count_speech_units("\n".join(lines))
98
+ return units / speech_rate(language, speed) + LINE_PAUSE_SEC * (len(lines) - 1)
99
+
100
+
101
+ def template_tail_pad_sec(template_config: Optional[dict]) -> float:
102
+ """场景尾巴:模板声明的 capabilities.defaultTailPadSec,缺省 1.5s。"""
103
+ caps = (template_config or {}).get("capabilities") or {}
104
+ v = caps.get("defaultTailPadSec") if isinstance(caps, dict) else None
105
+ if isinstance(v, bool) or not isinstance(v, (int, float)) or v < 0:
106
+ return DEFAULT_TAIL_PAD_SEC
107
+ return float(v)
108
+
109
+
110
+ def estimate_scene_sec(narration: object, speed: float = 1.0, tail_pad_sec: float = DEFAULT_TAIL_PAD_SEC) -> float:
111
+ """这一场按旁白估出来的时长。没有旁白返回 0(由调用方退回场景自己的 duration)。"""
112
+ speech = estimate_speech_sec(narration, speed)
113
+ return speech + tail_pad_sec if speech > 0 else 0.0
114
+
115
+
116
+ def speech_budget(
117
+ duration_sec: float,
118
+ speed: float = 1.0,
119
+ language: str = "zh",
120
+ lines: int = 1,
121
+ tail_pad_sec: float = DEFAULT_TAIL_PAD_SEC,
122
+ ) -> int:
123
+ """一场 ``duration_sec`` 秒的场景,旁白最多写多少字(中文)/ 词(英文)。
124
+
125
+ ``lines`` > 1 时扣掉分行停顿 —— 逐项高亮的版式同样秒数能写的字明显更少。
126
+ 至少返回 1:预算为 0 的场景说明秒数本身就不够,调用方应当提示加时长而不是写空旁白。
127
+ """
128
+ available = duration_sec - tail_pad_sec - LINE_PAUSE_SEC * max(lines - 1, 0)
129
+ return max(1, int(math.floor(available * speech_rate(language, speed))))
@@ -243,7 +243,7 @@
243
243
  "items": {
244
244
  "type": "array",
245
245
  "items": { "type": "string" },
246
- "description": "每行一个卡片的叙述句。长度必须等于 templateData 里对应数组(concepts/pillars/eras/items)的长度"
246
+ "description": "每行一个卡片的叙述句。长度必须等于 templateData 里对应数组的长度(数组键由模板的 lineSyncSlides[slideId] 声明)"
247
247
  },
248
248
  "outro": { "type": "string", "description": "可选:收尾句(此期间无卡片高亮)" },
249
249
  "assetRef": { "type": "string", "description": "旁白音频 assetId" },
@@ -1,179 +0,0 @@
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
- # 命名空间唯一性说明:compositionId 是否全库唯一,由 template-library 的
156
- # generateManifest 在生成 manifest.ts 时保障(跨模板撞名直接抛
157
- # DuplicateCompositionIdError、CI 失败)。manifest.ts 因此按构造即唯一,
158
- # 本脚本读取的已是去重结果,无需再校验唯一性。
159
- #
160
- # (已移除早期 P1.1 的 `<templateId>/` 前缀 warning:权威 schema 的
161
- # compositionId pattern 为 PascalCase `^[A-Z][A-Za-z0-9]*$`,本就禁止 `/`,
162
- # 前缀建议与 schema 矛盾且非必需——唯一性已由上游硬保障。)
163
-
164
- if errors:
165
- print("FAIL: contract check found inconsistencies:", file=sys.stderr)
166
- for e in errors:
167
- print(f" - {e}", file=sys.stderr)
168
- return 1
169
-
170
- print(
171
- f"OK: {len(refs)} compositionId references across "
172
- f"{len({r['templateId'] for r in refs})} templates match manifest.ts "
173
- f"({len(manifest)} entries)."
174
- )
175
- return 0
176
-
177
-
178
- if __name__ == "__main__":
179
- sys.exit(main())
@@ -1,44 +0,0 @@
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())
@@ -1,72 +0,0 @@
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-registry/scripts/registry_loader.py → 5 × ".."
10
- skills/template-registry/scripts/check_contracts.py → 3 × ".."
11
- skills/template-registry/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 (including the ``list_templates.py`` CLI entry, via
21
- ``registry_loader``) go through these helpers, so the monorepo-root
22
- computation lives in exactly one place.
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-registry/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