@remixmate/cli 0.9.5 → 0.9.7

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 (42) hide show
  1. package/README.md +23 -0
  2. package/dist/capabilities.d.ts +56 -0
  3. package/dist/capabilities.js +75 -0
  4. package/dist/handlers/gen-digital-human.js +7 -7
  5. package/dist/handlers/gen-image.d.ts +12 -6
  6. package/dist/handlers/gen-image.js +35 -47
  7. package/dist/handlers/gen-video.d.ts +7 -8
  8. package/dist/handlers/gen-video.js +41 -65
  9. package/dist/handlers/gen-voice.d.ts +1 -1
  10. package/dist/handlers/gen-voice.js +9 -7
  11. package/dist/http.d.ts +2 -2
  12. package/dist/http.js +3 -3
  13. package/dist/manifest.json +2 -2
  14. package/package.json +7 -1
  15. package/skills/export-jianying/SKILL.md +1 -1
  16. package/skills/export-jianying/scripts/gen_jianying_draft.py +2 -2
  17. package/skills/gen-digital-human/SKILL.md +1 -1
  18. package/skills/gen-image/SKILL.md +1 -1
  19. package/skills/gen-script/SKILL.md +33 -0
  20. package/skills/gen-script/scripts/gen_script.py +19 -0
  21. package/skills/gen-video/SKILL.md +1 -1
  22. package/skills/gen-voice/SKILL.md +3 -3
  23. package/skills/gen-voice/version.json +1 -1
  24. package/skills/prepare-video-assets/SKILL.md +2 -2
  25. package/skills/render-video/SKILL.md +4 -4
  26. package/skills/render-video/scripts/_video_probe.py +4 -1
  27. package/skills/render-video/scripts/_vod_polling.py +1 -1
  28. package/skills/render-video/scripts/remote_renderer_client.py +3 -3
  29. package/skills/render-video/scripts/render_video.py +6 -1
  30. package/skills/render-video/scripts/upload_video.py +3 -3
  31. package/skills/template-registry/README.md +1 -1
  32. package/skills/template-registry/SKILL.md +3 -3
  33. package/skills/template-registry/scripts/check_contracts.py +8 -25
  34. package/skills/template-registry/scripts/match_template.py +100 -28
  35. package/skills/template-registry/scripts/registry_loader.py +3 -3
  36. package/skills/template-registry/scripts/render_job_client.py +2 -2
  37. package/skills/template-registry/scripts/verify_props_contract.py +273 -0
  38. package/skills/template-registry/video_dsl/README.md +0 -1
  39. package/skills/video-parser/SKILL.md +1 -1
  40. package/skills/video-parser/scripts/deconstruct_video.py +2 -2
  41. package/skills/video-parser/scripts/parse_via_render.py +2 -2
  42. package/skills/template-registry/video_dsl/schema/template-definition-v1alpha1.json +0 -247
@@ -37,7 +37,7 @@ There is no dedicated env file; the **process environment** variables of the cal
37
37
  | Env var | Description | Default |
38
38
  |---------|-------------|---------|
39
39
  | `PRIV_TOKEN` | Tianyan token; overridable with `--priv-token`. | (none) |
40
- | `MM_API_BASE_URL` | API root URL. | `http://localhost:3001/api` |
40
+ | `MM_API_BASE_URL` | API root URL. | `https://api-agent.remixmate.com/api` |
41
41
  | `AGENT_NAME` | Optional, sent as the `x-invoke-agent` request header. | (none) |
42
42
 
43
43
  ## Steps
@@ -13,7 +13,7 @@
13
13
  python gen_jianying_draft.py --title "测试" --scenes scenes.json --download -o draft.zip
14
14
 
15
15
  环境变量:
16
- MM_API_BASE_URL - 后端 API 地址(默认: http://localhost:3001/api)
16
+ MM_API_BASE_URL - 后端 API 地址(默认: https://api-agent.remixmate.com/api)
17
17
  PRIV_TOKEN - PrivToken(优先读取;未配置时提示手动输入)
18
18
  """
19
19
 
@@ -30,7 +30,7 @@ import urllib.request
30
30
  import uuid
31
31
  import zipfile
32
32
 
33
- API_BASE_URL = os.environ.get("MM_API_BASE_URL", "http://localhost:3001/api")
33
+ API_BASE_URL = os.environ.get("MM_API_BASE_URL", "https://api-agent.remixmate.com/api")
34
34
  PRIVATE_TOKEN = "" # 在 main() 中通过 resolve_token() 初始化
35
35
  SKILL_NAME = "export-jianying"
36
36
  AGENT_NAME = os.environ.get("AGENT_NAME", "")
@@ -44,7 +44,7 @@ No skill-local env file — the executing process inherits the system environmen
44
44
  | Env var | Description | Default |
45
45
  |---------|-------------|---------|
46
46
  | `PRIV_TOKEN` | Tianyan token; `--priv-token` overrides | none |
47
- | `MM_API_BASE_URL` | API root; `--api-base-url` overrides | `http://localhost:2999/api` |
47
+ | `MM_API_BASE_URL` | API root; `--api-base-url` overrides | `https://api.remixmate.com/api` |
48
48
  | `AGENT_NAME` | Optional `x-invoke-agent` header | none |
49
49
 
50
50
  ## Operations
@@ -46,7 +46,7 @@ No skill-local env file — the executing process inherits the system environmen
46
46
  |---------|-------------|---------|
47
47
  | `PRIV_TOKEN` | Tianyan token; `--priv-token` overrides | none |
48
48
  | `MM_IMAGE_MODEL` | Default model id | `doubao/doubao-seedream-4-5-251128` |
49
- | `MM_API_BASE_URL` | API root; `--api-base-url` overrides | `http://localhost:2999/api` |
49
+ | `MM_API_BASE_URL` | API root; `--api-base-url` overrides | `https://api.remixmate.com/api` |
50
50
  | `AGENT_NAME` | Optional `x-invoke-agent` header | none |
51
51
 
52
52
  ## Operations
@@ -126,6 +126,37 @@ The agent should show the following in clear Markdown:
126
126
  > Reply "continue" to proceed to template matching, or tell me what to change.
127
127
  ```
128
128
 
129
+ ## Agent behavior: user-supplied media for carousel/image-driven templates (`--carousel-items` / `--caption-lines`)
130
+
131
+ Some templates are **media-driven, not prompt-driven**: their on-screen content comes entirely from media URLs the user already has, and the skill does **not** generate any image. These are the templates whose `capabilities.payloadStyle` is `carousel-caption` (e.g. `adaptive-image-video`, `spotlight-card`).
132
+
133
+ For these templates the picture comes from `customPayload.carousel.items`, which is filled **only** from the `--carousel-items` flag. If the user gives you images but you do not pass `--carousel-items`, the carousel is empty and the result is a **black, 2-second clip** (with `durationStrategy: fit-caption`, an empty carousel + empty caption degrades to the 1s headline-intro + 1s tail minimum). `gen_script.py` now hard-fails in this case instead of producing the degenerate video.
134
+
135
+ ### Mandatory behavior
136
+
137
+ When the user selects a `carousel-caption` template (or any template whose `assetRequirements` is image/video-only and whose `payloadStyle` is `carousel-caption`):
138
+
139
+ 1. **Extract every media URL the user provided** (image or video links in the prompt) and pass each one as a separate `--carousel-items <url>` flag — preserve the user's order, and pass the URLs **verbatim** (do not rewrite host/path/query).
140
+ 2. If the user wants on-screen text, pass each caption line as `--caption-lines '<text>'`. For purely visual templates like `adaptive-image-video` (no text, `needsNarration: false`), captions are optional.
141
+ 3. **Never call gen_script for a `carousel-caption` template without `--carousel-items`.** If the user picked such a template but provided no media, ask them for the image/video URLs first — do not generate an empty carousel.
142
+ 4. Do **not** route these user-provided images through `gen-image`; they are existing assets and go straight into the carousel.
143
+
144
+ ### Command example
145
+
146
+ User: "用模版 adaptive-image-video 生成视频,图片链接为:https://cdn.example.com/a.jpg,https://cdn.example.com/b.jpg,https://cdn.example.com/c.jpg"
147
+
148
+ ```bash
149
+ python3 <SkillDir>/scripts/gen_script.py \
150
+ --topic "图片轮播视频" \
151
+ --template-id adaptive-image-video \
152
+ --carousel-items "https://cdn.example.com/a.jpg" \
153
+ --carousel-items "https://cdn.example.com/b.jpg" \
154
+ --carousel-items "https://cdn.example.com/c.jpg"
155
+ ```
156
+
157
+ This template auto-adapts per-image hold time to the image count (1 image = 8s, 2 = 4s each, 3+ = 3s each), so 3 images yields a ~9s video instead of the 2s black clip.
158
+
159
+
129
160
  ## Test mode: skip asset generation (`--stub-image-url` / `--stub-video-url`)
130
161
 
131
162
  **Purpose**: during dev / debug the user wants to exercise the whole pipeline without burning gen-image / gen-video quota. In the DSL this becomes: image / video AssetRefs are written as `source:"existing"` + `status:"generated"` + `url:<stub>`, no `payload.prompt`; the downstream `prepare-video-assets` resolver skips the matching atomic skill.
@@ -228,6 +259,8 @@ python3 <SkillDir>/scripts/gen_script.py \
228
259
  | `-o` / `--output` | Output DSL file path. | stdout |
229
260
  | `--stub-image-url` | Test mode: every image AssetRef is written as existing + generated + this URL, no prompt (env: `STUB_IMAGE_URL`). | — |
230
261
  | `--stub-video-url` | Test mode: every video AssetRef is written as existing + generated + this URL, no prompt (env: `STUB_VIDEO_URL`). | — |
262
+ | `--carousel-items` | Repeatable. Media URL placed directly into `customPayload.carousel.items` for `carousel-caption` templates (e.g. `adaptive-image-video`, `spotlight-card`). Bypasses gen-image. **Required** for `carousel-caption` templates when the user supplies images. | — |
263
+ | `--caption-lines` | Repeatable. On-screen typewriter caption line for `carousel-caption` templates → `customPayload.caption.lines`. Supports `**emphasis**`. Optional for purely visual templates. | — |
231
264
 
232
265
  ## DSL generation principles
233
266
 
@@ -842,6 +842,25 @@ def build_dsl(
842
842
  f"ℹ️ contract: payloadStyle=carousel-caption for template {template_id}",
843
843
  file=sys.stderr,
844
844
  )
845
+ # 兜底校验:carousel-caption 模板的画面由 carousel_items(图片/视频 URL)驱动,
846
+ # 文字由 caption_lines 驱动。两者皆空时会生成一个空轮播 + 空字幕的退化场景——
847
+ # 渲染出来就是「黑屏 + fit-caption 退化成最短 2s」。这是调用方(agent)忘了
848
+ # 把用户提供的图片塞进 --carousel-items 的典型表现,必须显式报错而不是静默产出。
849
+ if not (carousel_items or []) and not (caption_lines or []):
850
+ print(
851
+ "❌ carousel-caption template "
852
+ f"'{template_id}' needs visual or text content, but received neither "
853
+ "--carousel-items nor --caption-lines.\n"
854
+ " This template renders a carousel of user-supplied media; with no items "
855
+ "it produces a black, minimum-length (2s) clip.\n"
856
+ " Fix: pass the user's image/video URLs via --carousel-items "
857
+ "(repeat the flag per item), e.g.\n"
858
+ " gen_script.py --topic <topic> --template-id "
859
+ f"{template_id} --carousel-items <url1> --carousel-items <url2> ...\n"
860
+ " Optionally add --caption-lines '<text>' for on-screen typewriter captions.",
861
+ file=sys.stderr,
862
+ )
863
+ sys.exit(1)
845
864
  return _build_carousel_caption_dsl(
846
865
  template_id=template_id,
847
866
  topic=topic,
@@ -60,7 +60,7 @@ No skill-local env file — the executing process inherits the system environmen
60
60
  |---------|-------------|---------|
61
61
  | `PRIV_TOKEN` | Tianyan token; `--priv-token` overrides | none |
62
62
  | `MM_VIDEO_MODEL` | Default model id | `doubao-seedance-1-5-pro-251215` |
63
- | `MM_API_BASE_URL` | API root; `--api-base-url` overrides | `http://localhost:2999/api` |
63
+ | `MM_API_BASE_URL` | API root; `--api-base-url` overrides | `https://api.remixmate.com/api` |
64
64
  | `AGENT_NAME` | Optional `x-invoke-agent` header | none |
65
65
 
66
66
  ## Operations
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: gen-voice
3
3
  description: |
4
- Text-to-speech (TTS) skill: synthesize narration audio from text via ab-api's `/tool/minimaxT2aV2` endpoint (Minimax TTS).
4
+ Text-to-speech (TTS) skill: synthesize narration audio from text via ab-api's unified `/tool/tts` endpoint with `provider: "minimax"` (Minimax TTS).
5
5
 
6
6
  Use this skill immediately whenever the user asks for any of:
7
7
  - AI voice-over, TTS, text-to-speech, generate narration audio
@@ -17,7 +17,7 @@ triggers:
17
17
 
18
18
  # Text-to-Speech (TTS) Skill
19
19
 
20
- Wraps ab-api's `POST /tool/minimaxT2aV2` (the same endpoint the web studio uses), authenticated with the **Tianyan privateToken**, backed by the **Minimax TTS** model.
20
+ Wraps ab-api's unified `POST /tool/tts` with `provider: "minimax"` (the same endpoint the web studio uses), authenticated with the **Tianyan privateToken**, backed by the **Minimax TTS** model.
21
21
 
22
22
  ## Auth & environment
23
23
 
@@ -29,7 +29,7 @@ There is no skill-local env file — the executing process inherits the system e
29
29
  | Env var | Description | Default |
30
30
  |---------|-------------|---------|
31
31
  | `PRIV_TOKEN` | Tianyan token; `--priv-token` overrides | none |
32
- | `MM_API_BASE_URL` | API root; `--api-base-url` overrides | `http://localhost:2999/api` |
32
+ | `MM_API_BASE_URL` | API root; `--api-base-url` overrides | `https://api.remixmate.com/api` |
33
33
  | `AGENT_NAME` | Optional `x-invoke-agent` header | none |
34
34
 
35
35
  ## Operations
@@ -3,5 +3,5 @@
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "338",
5
5
  "version": "V7",
6
- "skillDescription": "语音合成(TTS)技能,将文字转为语音音频(调用 ab-api /tool/minimaxT2aV2,基于 Minimax TTS)。\n\n当用户提到以下任何需求时,立即使用本 skill:\n- AI 配音、语音合成、文字转语音、TTS、文本转音频、生成语音\n- 使用 minimax 语音合成\n- 用户想要将一段文字朗读出来、生成音频\n\n即使用户没有明确说「使用 AI」,只要他们想要将文字转为语音,也要使用本 skill。"
6
+ "skillDescription": "语音合成(TTS)技能,将文字转为语音音频(调用 ab-api 统一 /tool/tts,provider=minimax,基于 Minimax TTS)。\n\n当用户提到以下任何需求时,立即使用本 skill:\n- AI 配音、语音合成、文字转语音、TTS、文本转音频、生成语音\n- 使用 minimax 语音合成\n- 用户想要将一段文字朗读出来、生成音频\n\n即使用户没有明确说「使用 AI」,只要他们想要将文字转为语音,也要使用本 skill。"
7
7
  }
@@ -54,8 +54,8 @@ The asset resolver invokes the following skills based on `AssetRef` declarations
54
54
  | Env var | Description | Default |
55
55
  |---------|-------------|---------|
56
56
  | `PRIV_TOKEN` | Tianyan token (needed for asset generation + DB persist). | (none) |
57
- | `MM_API_BASE_URL` | Asset-generation API root (ab-agent proxy). | `http://localhost:3001/api` |
58
- | `MM_BACKEND_API_URL` | Backend API root (RenderPlan persistence). | `http://localhost:2999/api` |
57
+ | `MM_API_BASE_URL` | Asset-generation API root (ab-agent proxy). | `https://api-agent.remixmate.com/api` |
58
+ | `MM_BACKEND_API_URL` | Backend API root (RenderPlan persistence). | `https://api.remixmate.com/api` |
59
59
  | `ASSET_CACHE_DIR` | Asset cache directory. | `./.asset-cache/` |
60
60
 
61
61
  ## Canonical usage (inline DSL JSON, no temp files)
@@ -49,8 +49,8 @@ Rendering supports two modes:
49
49
  | Env var | Description | Default |
50
50
  |---------|-------------|---------|
51
51
  | `PRIV_TOKEN` | Tianyan token (needed to load the RenderPlan from the DB and to upload). | (none) |
52
- | `MM_BACKEND_API_URL` | Backend API root URL (RenderPlan / VOD lookups). | `http://localhost:2999/api` |
53
- | `REMOTION_RENDER_API_URL` | Remote renderer base URL (ab-render). | `http://localhost:3000` |
52
+ | `MM_BACKEND_API_URL` | Backend API root URL (RenderPlan / VOD lookups). | `https://api.remixmate.com/api` |
53
+ | `REMOTION_RENDER_API_URL` | Remote renderer base URL (ab-render). | `https://api-render.remixmate.com` |
54
54
  | `REMOTION_RENDER_MODE` | Default render mode (`remote` / `local`); CLI `--renderer` overrides it. | `remote` |
55
55
  | `REMOTION_OUTPUT_DIR` | Render output directory. | `./output/` |
56
56
  | `REMOTION_CONCURRENCY` | Local Remotion render concurrency. | `2` |
@@ -194,8 +194,8 @@ If the remote service is unreachable or auth fails, the script exits with a deta
194
194
  The Remotion render project lives at the monorepo root under `<monorepo-root>/remotion-renderer/` and is deployed as a **standalone HTTP service**. The client script in this skill only sends POST requests and does not participate in server-side code sync.
195
195
 
196
196
  - Server code + deployment docs: `<monorepo-root>/remotion-renderer/README.md`.
197
- - Local debugging: in the renderer directory run `npm install && npm run server:dev`; the client defaults to `http://localhost:3000`.
198
- - Production: set `REMOTION_RENDER_API_URL` to the production base URL.
197
+ - Production (default): the client uses `https://api-render.remixmate.com` when `REMOTION_RENDER_API_URL` is unset no config needed.
198
+ - Local debugging: in the renderer directory run `npm install && npm run server:dev`, then point the client at it with `REMOTION_RENDER_API_URL=http://localhost:3000`.
199
199
 
200
200
  ## Error handling
201
201
 
@@ -59,7 +59,10 @@ def _query_duration_from_api(url: str) -> Optional[float]:
59
59
  if not media_id:
60
60
  return None
61
61
 
62
- api_base = os.environ.get("MM_API_BASE_URL", os.environ.get("MM_BACKEND_API_URL", "")).rstrip("/")
62
+ api_base = os.environ.get(
63
+ "MM_API_BASE_URL",
64
+ os.environ.get("MM_BACKEND_API_URL", "https://api.remixmate.com/api"),
65
+ ).rstrip("/")
63
66
  if not api_base:
64
67
  return None
65
68
 
@@ -40,7 +40,7 @@ def poll_vod_playback_url(
40
40
  - 看到阿里云 OSS 直链时记为 fallback,继续等 CDN
41
41
  - 超时仍未拿到 CDN 时退回到 OSS 原始地址
42
42
  """
43
- api_base = os.environ.get("MM_BACKEND_API_URL", "http://localhost:2999/api").rstrip("/")
43
+ api_base = os.environ.get("MM_BACKEND_API_URL", "https://api.remixmate.com/api").rstrip("/")
44
44
  url = f"{api_base}/file/get"
45
45
  headers = {
46
46
  "Content-Type": "application/json",
@@ -18,8 +18,8 @@
18
18
 
19
19
  Base URL 优先级:
20
20
  1. 调用方显式传入 base_url
21
- 2. 环境变量 REMOTION_RENDER_API_URL
22
- 3. 默认 http://localhost:3000(与 server/config.ts 的 PORT 默认值一致,方便本地调试)
21
+ 2. 环境变量 REMOTION_RENDER_API_URL(指向本地/staging 时在此覆盖)
22
+ 3. 默认 https://api-render.remixmate.com(生产,零配置可用)
23
23
 
24
24
  本模块只负责 HTTP 层;调用方(render_video.py)负责把结果写回 render_plan。
25
25
  """
@@ -35,7 +35,7 @@ import urllib.parse
35
35
  import urllib.request
36
36
  from typing import Callable, Optional
37
37
 
38
- DEFAULT_API_BASE_URL = "http://localhost:3000"
38
+ DEFAULT_API_BASE_URL = "https://api-render.remixmate.com"
39
39
  API_BASE_URL = (os.environ.get("REMOTION_RENDER_API_URL") or DEFAULT_API_BASE_URL).strip()
40
40
  SKILL_NAME = "render-video"
41
41
  AGENT_NAME = os.environ.get("AGENT_NAME", "")
@@ -1646,6 +1646,11 @@ def render_with_remote_api(
1646
1646
  "upload": True,
1647
1647
  "uploadTitle": effective_title,
1648
1648
  }
1649
+ # 私有模板封面:传 coverCompositionId 让 /renderDraft 渲模板自带 cover 组件
1650
+ # (ab-render 注册了 DraftCover),与内置 /render 的 MainCover 对等;
1651
+ # 不传则 ab-render 退回截首帧 / 不渲。
1652
+ if cover_composition_id:
1653
+ payload["coverCompositionId"] = cover_composition_id
1649
1654
  render_path = "/renderDraft"
1650
1655
  status_path = "/renderDraftStatus"
1651
1656
  else:
@@ -1666,7 +1671,7 @@ def render_with_remote_api(
1666
1671
  LogPrint(f" Private template (OSS dynamic bundle): {template_id}", file=sys.stderr)
1667
1672
  else:
1668
1673
  LogPrint(f" Composition: {composition_id}", file=sys.stderr)
1669
- if cover_composition_id and not source_oss_key:
1674
+ if cover_composition_id:
1670
1675
  LogPrint(f" Cover: {cover_composition_id}", file=sys.stderr)
1671
1676
  LogPrint(f" size: {config.get('width')}x{config.get('height')}", file=sys.stderr)
1672
1677
  LogPrint(f" total frames: {total_frames}", file=sys.stderr)
@@ -13,7 +13,7 @@
13
13
  python3 upload_video.py --file output/video.mp4 --json-output
14
14
 
15
15
  环境变量:
16
- MM_API_BASE_URL - API 根地址(默认 MM_API_BASE_URL 环境变量或 http://localhost:3001/api)
16
+ MM_API_BASE_URL - API 根地址(默认 MM_API_BASE_URL 环境变量或 https://api-agent.remixmate.com/api)
17
17
 
18
18
  注意:
19
19
  - `uploadMediaFile` 接口视频大小限制 ≤ 50MB,音频 ≤ 15MB
@@ -31,7 +31,7 @@ import urllib.parse
31
31
  import urllib.request
32
32
  import uuid
33
33
 
34
- # DEFAULT_API_BASE_URL = "http://localhost:3001/api"
34
+ DEFAULT_API_BASE_URL = "https://api-agent.remixmate.com/api"
35
35
 
36
36
 
37
37
  def _guess_content_type(file_path: str) -> str:
@@ -164,7 +164,7 @@ def main():
164
164
  parser.add_argument(
165
165
  "--api-base-url",
166
166
  default=None,
167
- help="API root URL (defaults to MM_API_BASE_URL env var or http://localhost:3001/api)",
167
+ help="API root URL (defaults to MM_API_BASE_URL env var or https://api-agent.remixmate.com/api)",
168
168
  )
169
169
  parser.add_argument("--timeout", type=int, default=600, help="Upload timeout in seconds (default 600)")
170
170
  parser.add_argument("--json-output", action="store_true", help="Emit the full response as JSON on stdout")
@@ -9,7 +9,7 @@ CLI 暴露面是「列出可用模板」(`--list-templates`)。把 Video DSL 场
9
9
  | 来源 | 触发条件 | 适用场景 |
10
10
  |---|---|---|
11
11
  | 显式 `VIDEO_TEMPLATE_REGISTRY_URL`(ab-api 接口) | 环境变量非空 + `PRIV_TOKEN` 有效 | 指定后端 |
12
- | 派生默认 URL `<MM_API_BASE_URL>/remotionTemplate/registry` | `VIDEO_TEMPLATE_REGISTRY_URL` 未设置 | 默认(含独立安装 codex / `npm i -g`,默认 `http://localhost:2999/api`) |
12
+ | 派生默认 URL `<MM_API_BASE_URL>/remotionTemplate/registry` | `VIDEO_TEMPLATE_REGISTRY_URL` 未设置 | 默认(含独立安装 codex / `npm i -g`,默认 `https://api.remixmate.com/api`) |
13
13
 
14
14
  只从 ab-api 取数,是为了避免「本地文件 / monorepo 源码 / 数据库」多源并存导致的不一致——私有 / 多租户模板只存在于 ab-api,本地源永远不全。默认 URL **内置在 remixmate-cli 自身**(按 CLI 后端约定 `MM_API_BASE_URL` 推导),不再依赖 ab-agent 等宿主在 spawn 时注入,独立运行即可找到 registry。指向你自己的 ab-api 只需设 `MM_API_BASE_URL`(或直接设 `VIDEO_TEMPLATE_REGISTRY_URL`)并配置 `PRIV_TOKEN`。
15
15
 
@@ -63,12 +63,12 @@ The skill itself only reads the registry; whether a token is needed depends on t
63
63
  | Env var | Description | Default |
64
64
  |---------|-------------|---------|
65
65
  | `VIDEO_TEMPLATE_REGISTRY_URL` | ab-api endpoint returning the registry (the single source of truth). | derived from `MM_API_BASE_URL` |
66
- | `MM_API_BASE_URL` | ab-api base URL the CLI talks to. When `VIDEO_TEMPLATE_REGISTRY_URL` is unset, the registry endpoint is derived as `<base>/remotionTemplate/registry`. | `http://localhost:2999/api` |
66
+ | `MM_API_BASE_URL` | ab-api base URL the CLI talks to. When `VIDEO_TEMPLATE_REGISTRY_URL` is unset, the registry endpoint is derived as `<base>/remotionTemplate/registry`. | `https://api.remixmate.com/api` |
67
67
  | `PRIV_TOKEN` | Sent as `X-Priv-Token` when hitting the registry endpoint. Falls back to `~/.config/remixmate/credentials.json`. | unset |
68
68
  | `VIDEO_TEMPLATE_REGISTRY_HTTP_METHOD` | `POST` (default) or `GET`. POST shape matches ab-api `{code,msg,data}`. | `POST` |
69
69
 
70
70
  Resolution (single source — see `scripts/registry_loader.py` for the canonical implementation):
71
- the registry is loaded **only** from the ab-api HTTP endpoint — explicit `VIDEO_TEMPLATE_REGISTRY_URL`, otherwise `<MM_API_BASE_URL>/remotionTemplate/registry` (default `http://localhost:2999/api/...`). There is no local-file / monorepo / PREFER_LOCAL fallback: those multi-source paths were removed to avoid registry skew (private/multi-tenant templates only exist on ab-api). On a transient HTTP failure the loader degrades to the on-disk cache of the same URL; with no cache it fails loud. A standalone install (Codex / `npm i -g`) just needs `MM_API_BASE_URL` (or `VIDEO_TEMPLATE_REGISTRY_URL`) pointed at your ab-api plus a valid `PRIV_TOKEN`.
71
+ the registry is loaded **only** from the ab-api HTTP endpoint — explicit `VIDEO_TEMPLATE_REGISTRY_URL`, otherwise `<MM_API_BASE_URL>/remotionTemplate/registry` (default `https://api.remixmate.com/api/...`). There is no local-file / monorepo / PREFER_LOCAL fallback: those multi-source paths were removed to avoid registry skew (private/multi-tenant templates only exist on ab-api). On a transient HTTP failure the loader degrades to the on-disk cache of the same URL; with no cache it fails loud. A standalone install (Codex / `npm i -g`) just needs `MM_API_BASE_URL` (or `VIDEO_TEMPLATE_REGISTRY_URL`) pointed at your ab-api plus a valid `PRIV_TOKEN`.
72
72
 
73
73
  ## Steps
74
74
 
@@ -149,7 +149,7 @@ print(json.dumps(binding, ensure_ascii=False, indent=2))
149
149
  | Comes from a scene-level custom field whose name matches a prop directly | put it in `customPayload.<propName>` | `customPayload.slideId`, `customPayload.background`, `customPayload.bullets` |
150
150
  | Structured data internal to the template (arrays, sub-objects, etc.) | use `customPayload.templateData.*` | `templateData.concepts`, `templateData.items` (shared by feature-grid / timeline / column-compare) |
151
151
 
152
- **Common pitfall**: writing the same field in both `propExtractors` and `customPayload` — when they collide, `propExtractors` wins. Keep each field in exactly one place.
152
+ **Common pitfall**: writing the same field in both `propExtractors` and `customPayload` — when they collide, `propExtractors` wins. Keep each field in exactly one place. `build_binding` now **detects this**: any prop name that is both declared in `propExtractors` and hit by `customPayload` pass-through (top-level, or `templateData` when the extractor reads from elsewhere) is recorded on the binding row as `dualChannelProps` and printed as a stderr warning; `test-template-pipeline.py` surfaces it as a non-fatal authoring warning. A field placed in its extractor's own declared source (e.g. extractor `from:"templateData.slideId"` + value in `templateData.slideId`) is **not** flagged — that is the legitimate source, not a double-write.
153
153
 
154
154
  > Historical issue: an early html-slide `point` slot declared `slideId` as `propExtractors.slideId={from:"templateData.slideId"}` and also marked it as `requiredProps`. But every existing DSL wrote `slideId` at `customPayload.slideId` (top-level, not inside templateData), so the propExtractor returned nothing and rendering fell back to the generic layout. The convention now is unified: **slideId flows through customPayload top-level pass-through**, propExtractors no longer declares it. New templates must follow the same convention.
155
155
 
@@ -152,31 +152,14 @@ def main() -> int:
152
152
  file=sys.stderr,
153
153
  )
154
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)
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 矛盾且非必需——唯一性已由上游硬保障。)
180
163
 
181
164
  if errors:
182
165
  print("FAIL: contract check found inconsistencies:", file=sys.stderr)
@@ -14,6 +14,7 @@ sibling 的 `list_templates.py` 实现(它复用 registry_loader 的统一加
14
14
  """
15
15
 
16
16
  from registry_loader import load_registry_data
17
+ import sys
17
18
 
18
19
 
19
20
  def load_registry() -> list:
@@ -105,6 +106,30 @@ def build_binding(template: dict, dsl: dict) -> dict:
105
106
  # 字幕」兜底样式,所有场景看起来都是同一个布局。
106
107
  custom = scene.get("customPayload") or {}
107
108
  template_data = custom.get("templateData") or scene.get("templateData") or {}
109
+
110
+ # 脆弱点 3:双通道冲突检测。同一字段名既声明在 slot 的 propExtractors,
111
+ # 又被 customPayload 的全字段透传命中时,来源二义:propExtractors 永远赢
112
+ # (下面 `k not in props` 透传),但如果 extractor 返回 None、值又恰好在
113
+ # payload 里,作者会误以为"是 payload 生效",反之亦然。html-slide 的
114
+ # slideId 事故正是此类——extractor 读 `templateData.slideId`,DSL 却把值
115
+ # 写在 `customPayload.slideId` 顶层。
116
+ #
117
+ # 仅标记**真正的位置冲突**,避开误报:当 extractor 的 `from` 恰好指向
118
+ # `templateData.<同名>` 时,templateData 里出现该字段是它的**法定来源**,
119
+ # 不算"写错位置",跳过。其余命中(顶层透传、或 templateData 透传但 extractor
120
+ # 从别处取值)才算冲突。只记录 + warning,不改透传行为;CI 的
121
+ # test-template-pipeline.py 据此浮出去重提示。
122
+ extractors = slot_def.get("propExtractors", {}) or {}
123
+ top_keys = {k for k in custom.keys() if k != "templateData"}
124
+ dual_channel = []
125
+ for k in extractors:
126
+ collides_top = k in top_keys
127
+ extractor_from = (extractors.get(k) or {}).get("from", "")
128
+ collides_td = k in template_data and extractor_from != f"templateData.{k}"
129
+ if collides_top or collides_td:
130
+ dual_channel.append(k)
131
+ dual_channel = sorted(dual_channel)
132
+
108
133
  # 先注入 templateData.* 再注入顶层 customPayload.*,让顶层覆盖同名值
109
134
  for k, v in template_data.items():
110
135
  if k not in props:
@@ -120,7 +145,15 @@ def build_binding(template: dict, dsl: dict) -> dict:
120
145
  entrance = anim.get("entrance", "fade")
121
146
  transition_type = entrance if entrance != "none" else "cut"
122
147
 
123
- bindings.append({
148
+ # requiredProps 完整性:slotMapping 声明的 requiredProps 必须都已进 props。
149
+ # 这里只**记录**缺失(不抛错,保持渲染行为不变);CI 的
150
+ # test-template-pipeline.py 据此断言「requiredProps 齐全」并 fail loud,
151
+ # 避免历史上「声明了 requiredProps 却静默缺失 → React 端回退通用布局」的事故
152
+ # (见 template-json-design.md 脆弱点 2)。是否升级为渲染前硬失败属后续决策。
153
+ required_props = slot_def.get("requiredProps") or []
154
+ missing_required = [p for p in required_props if p not in props]
155
+
156
+ binding_row = {
124
157
  "sceneId": scene_id,
125
158
  "slotId": slot_id,
126
159
  "compositionId": comp_id,
@@ -128,7 +161,18 @@ def build_binding(template: dict, dsl: dict) -> dict:
128
161
  "props": props,
129
162
  "transitionIn": {"type": transition_type, "durationFrames": 15},
130
163
  "transitionOut": {"type": "fade", "durationFrames": 15},
131
- })
164
+ }
165
+ if missing_required:
166
+ binding_row["missingRequiredProps"] = missing_required
167
+ if dual_channel:
168
+ binding_row["dualChannelProps"] = dual_channel
169
+ print(
170
+ f"⚠️ 双通道字段:scene={scene_id!r} slot={slot_id!r} 的 "
171
+ f"{dual_channel} 同时声明在 propExtractors 与 customPayload,"
172
+ f"propExtractors 优先。建议二选一,避免写错位置时静默取错值。",
173
+ file=sys.stderr,
174
+ )
175
+ bindings.append(binding_row)
132
176
 
133
177
  # P1.2: 不再做 screen-walkthrough 专属的截断 / 兜底。模板的 propExtractors
134
178
  # 已声明 titleText ← textLayers[role=headline]、projectName ← textLayers[role=subheadline],
@@ -139,16 +183,16 @@ def build_binding(template: dict, dsl: dict) -> dict:
139
183
  motion = template.get("defaultMotionPreset", "smooth")
140
184
  colors = template.get("defaultColorScheme", [])
141
185
 
142
- # --- variant selection ---
186
+ # --- variant selection(确定性)---
143
187
  # 优先级:
144
- # 1) DSL 显式声明 — meta.templateVariant / renderHints.templateVariant
145
- # 2) DSL meta.style / renderHints.brandColors 与 variant.styleTags 的子串/包含匹配
146
- # 显式声明优先是因为:
147
- # - 风格名(如 "紫色科技")在不同 variant styleTags 里可能多义;
148
- # - meta.style 经常是一整串短语而非单 tag,旧的"逐 tag 拼成空格 → 子串匹配"
149
- # 会被 split 后的整段短语漏过(例如 "紫色科技" 不是 "紫色 科技 purple ..."
150
- # 的子串)。显式 templateVariant 让作者可以一锤定音。
188
+ # 1) DSL 显式声明 — meta.templateVariant / meta.variant / renderHints.templateVariant
189
+ # 2) meta.style 与 variant.styleTags 的**分级**匹配(精确 > 整段包含 > token 子串)
190
+ # 旧实现按 dict 遍历顺序「命中第一个」,多义时结果不稳定;现改为:
191
+ # - variant id 排序遍历(消除顺序依赖);
192
+ # - 取匹配等级最高(tier 数最小)的 variant;
193
+ # - 同一最高等级有多个候选 记歧义、取排序首个,并把理由写进 variantReason。
151
194
  variant_id = None
195
+ variant_reason = None
152
196
  meta = dsl.get("meta", {}) or {}
153
197
  render_hints = dsl.get("renderHints", {}) or {}
154
198
  explicit_variant = (
@@ -160,31 +204,57 @@ def build_binding(template: dict, dsl: dict) -> dict:
160
204
 
161
205
  if explicit_variant and explicit_variant in variants:
162
206
  variant_id = explicit_variant
163
- else:
164
- # Fallback: 把 meta.style 拆成更细的 token(按逗号 / 空白 / 中英标点 / 单字)
165
- # 再到 variant.styleTags 里找任一包含或被包含关系。这样 "紫色科技" 既能命中
166
- # ["紫色","科技"] 这种细粒度 tag,也能命中 ["紫色科技"] 这种整段 tag。
207
+ variant_reason = "explicit"
208
+ elif variants:
167
209
  raw_style = (meta.get("style") or "").strip().lower()
168
210
  if raw_style:
169
- # 整段 + 逗号/分号/空白拆分 + 单中文字符级 token,统一去重
170
- style_tokens = {raw_style}
171
- for sep in [",", ",", ";", ";", " ", "/", "·"]:
172
- raw_style = raw_style.replace(sep, ",")
173
- for tok in raw_style.split(","):
211
+ # token 化:整段 + 分隔符拆分 + 单中文字符级
212
+ s = raw_style
213
+ for sep in [",", ",", ";", ";", " ", "/", "·", "、"]:
214
+ s = s.replace(sep, ",")
215
+ tokens = set()
216
+ for tok in s.split(","):
174
217
  tok = tok.strip()
175
218
  if not tok:
176
219
  continue
177
- style_tokens.add(tok)
178
- # 中文按字拆,命中如 "紫"/"色"/"科"/"技" 这种单字 tag 的场景
220
+ tokens.add(tok)
179
221
  for ch in tok:
180
222
  if "\u4e00" <= ch <= "\u9fff":
181
- style_tokens.add(ch)
182
- for vid, variant in variants.items():
183
- vtags = [str(t).strip().lower() for t in variant.get("styleTags", [])]
184
- # 任一 tag 与任一 style token 互为子串即视为匹配
185
- if any(any(tok in tag or tag in tok for tag in vtags) for tok in style_tokens if tok):
186
- variant_id = vid
187
- break
223
+ tokens.add(ch)
224
+
225
+ # 为每个 variant 算最佳匹配等级(1 最强)
226
+ # tier 1: style token 与某 tag 完全相等
227
+ # tier 2: 整段 raw_style 与某 tag 互为子串(整体短语级)
228
+ # tier 3: 某 token 与某 tag 互为子串(细粒度,最宽松)
229
+ by_tier: dict = {}
230
+ for vid in sorted(variants.keys()):
231
+ vtags = [str(t).strip().lower() for t in (variants[vid].get("styleTags") or []) if str(t).strip()]
232
+ tier = None
233
+ if any(tok == tag for tok in tokens for tag in vtags):
234
+ tier = 1
235
+ elif any((raw_style in tag) or (tag in raw_style) for tag in vtags):
236
+ tier = 2
237
+ elif any((tok in tag) or (tag in tok) for tok in tokens for tag in vtags):
238
+ tier = 3
239
+ if tier is not None:
240
+ by_tier.setdefault(tier, []).append(vid)
241
+
242
+ if by_tier:
243
+ best_tier = min(by_tier.keys())
244
+ winners = by_tier[best_tier] # 已按 vid 排序
245
+ variant_id = winners[0]
246
+ if len(winners) > 1:
247
+ variant_reason = (
248
+ f"style-match tier{best_tier} (ambiguous among {winners}; "
249
+ f"picked '{variant_id}' by id order — 建议作者改用显式 meta.templateVariant)"
250
+ )
251
+ print(
252
+ f"⚠️ variant 选择歧义:style={meta.get('style')!r} 在 tier{best_tier} "
253
+ f"命中多个变体 {winners},确定性取 '{variant_id}'。建议显式声明 meta.templateVariant。",
254
+ file=sys.stderr,
255
+ )
256
+ else:
257
+ variant_reason = f"style-match tier{best_tier}"
188
258
 
189
259
  if variant_id and variant_id in variants:
190
260
  variant = variants[variant_id]
@@ -211,6 +281,8 @@ def build_binding(template: dict, dsl: dict) -> dict:
211
281
  }
212
282
  if variant_id:
213
283
  result["variantId"] = variant_id
284
+ if variant_reason:
285
+ result["variantReason"] = variant_reason
214
286
  return result
215
287
 
216
288
 
@@ -15,7 +15,7 @@
15
15
  1. 显式 VIDEO_TEMPLATE_REGISTRY_URL(若设置则直接用)
16
16
  2. 否则按 CLI 后端约定推导:
17
17
  <MM_API_BASE_URL>/remotionTemplate/registry
18
- MM_API_BASE_URL 默认 http://localhost:2999/api(与 src/http.ts 一致),
18
+ MM_API_BASE_URL 默认 https://api.remixmate.com/api(与 src/http.ts 一致),
19
19
  兼容 ab-agent 注入的 MM_BACKEND_API_URL。默认值内置在 CLI 自身,独立安装
20
20
  (codex / npm i -g)开箱即用,无需宿主在 spawn 时注入。
21
21
 
@@ -71,7 +71,7 @@ _CACHE_DIR = os.environ.get(
71
71
 
72
72
 
73
73
  # CLI 默认后端 base,与 remixmate-cli/src/http.ts 的 DEFAULT_API_BASE_URL 保持一致。
74
- _DEFAULT_API_BASE_URL = "http://localhost:2999/api"
74
+ _DEFAULT_API_BASE_URL = "https://api.remixmate.com/api"
75
75
 
76
76
 
77
77
  def _default_registry_url() -> str:
@@ -83,7 +83,7 @@ def _default_registry_url() -> str:
83
83
  base 解析顺序与 CLI / ab-agent 对齐:
84
84
  MM_API_BASE_URL(CLI ``src/http.ts`` 约定)
85
85
  → MM_BACKEND_API_URL(ab-agent 约定,向后兼容)
86
- http://localhost:2999/api(本地 dev 兜底)
86
+ https://api.remixmate.com/api(生产默认,零配置)
87
87
  路径段固定为 ``/remotionTemplate/registry``(POST,返回 ``{code,msg,data}``)。
88
88
  """
89
89
  base = (
@@ -6,7 +6,7 @@ render_job_client.py — ab-api renderJob 接口 Python 客户端
6
6
  gen_jianying_draft.py 使用。
7
7
 
8
8
  认证方式: X-Priv-Token header(与其他 skill 脚本一致)
9
- 接口地址: 由环境变量 MM_BACKEND_API_URL 控制(默认 http://localhost:2999/api)
9
+ 接口地址: 由环境变量 MM_BACKEND_API_URL 控制(默认 https://api.remixmate.com/api)
10
10
  """
11
11
 
12
12
  import json
@@ -17,7 +17,7 @@ import urllib.request
17
17
 
18
18
 
19
19
  def _api_base() -> str:
20
- return os.environ.get("MM_BACKEND_API_URL", "http://localhost:2999/api").rstrip("/")
20
+ return os.environ.get("MM_BACKEND_API_URL", "https://api.remixmate.com/api").rstrip("/")
21
21
 
22
22
 
23
23
  def _make_headers(priv_token: str) -> dict: