@remixmate/cli 0.9.24 → 0.9.26

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/README.md CHANGED
@@ -268,6 +268,10 @@ npm run regress:update
268
268
  # Run only the render-plan snapshot layer:
269
269
  npm run test:render-plan
270
270
 
271
+ # Narration speed wiring (gen_script → render plan → gen-voice --speed).
272
+ # Fully offline: no registry, no TTS call.
273
+ npm run test:narration-speed
274
+
271
275
  # L3 — selective, real Remotion render of chosen template(s). NOT full-library
272
276
  # by design (rendering is slow + needs asset generation). Catches crashes /
273
277
  # black frames the static layers can't. Needs PRIV_TOKEN for narration TTS;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.9.24",
4
- "generatedAt": "2026-09-07T14:49:54.095Z",
3
+ "version": "0.9.26",
4
+ "generatedAt": "2026-09-13T00:51:13.551Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "export-jianying",
@@ -382,6 +382,10 @@
382
382
  "type": "string",
383
383
  "description": "Narration voice id. Default depends on the bound template's outputLanguage; query gen_voice with list_voices=true to see available ids."
384
384
  },
385
+ "speed": {
386
+ "type": "number",
387
+ "description": "Narration speech rate, 0.5-2.0 (default 1.0). Stored at global.narration.speed and applied when prepare_video_assets synthesizes the voice-over. **Must** pass when the user picked a speech rate — a template's 语速 / speed parameter arrives as a multiplier like 1.1, and without this flag it is silently dropped and the narration comes back at 1.0. Above ~1.3 subtitle alignment drifts and the delivery sounds mechanical; suggest shortening the script instead of raising it further."
388
+ },
385
389
  "template_id": {
386
390
  "type": "string",
387
391
  "description": "Template id (e.g. html-slide). The template owns outputLanguage and may also declare a defaultVoiceId; both flow into the produced DSL."
@@ -401,6 +405,13 @@
401
405
  },
402
406
  "description": "Media URLs for the template's image/video carousel. When provided together with a template_id whose capabilities.payloadStyle=carousel-caption, these URLs are placed directly into customPayload.carousel.items as existing assets — NO AI image generation is triggered. **Must** pass when the user explicitly provides image/video URLs for such a template. Each element is a full URL string."
403
407
  },
408
+ "scene_images": {
409
+ "type": "array",
410
+ "items": {
411
+ "type": "string"
412
+ },
413
+ "description": "Per-scene visuals for a visual-overlay template (image-slide etc.) — the counterpart of carousel_items for templates that have real scenes. Each element is EITHER a full URL (use that existing asset for the scene, no gen-image call) OR the literal string 'ai' / 'ai:<prompt>' (leave that scene to gen-image; the text after the colon, when present, is the user's own image prompt for it). Mapping is POSITIONAL: element i is scene i, so an AI scene can sit anywhere in the order — pass ['url1','ai','url2'] verbatim when the user arranged it that way. Scenes past the last element still get an AI-generated image, so 2 images still produce a full video. When `scenes` is not set the scene count is raised (never lowered) to fit the elements, so nothing the user arranged is dropped and handing over fewer images does not shrink the video. **Must** pass when the user supplies images for such a template — otherwise their images are ignored entirely and every scene is AI-generated. Do NOT pass for carousel-caption templates; those take carousel_items."
414
+ },
404
415
  "caption_lines": {
405
416
  "type": "array",
406
417
  "items": {
@@ -437,10 +448,12 @@
437
448
  "ratio",
438
449
  "scenes",
439
450
  "voice_id",
451
+ "speed",
440
452
  "template_id",
441
453
  "headline",
442
454
  "subheadline",
443
455
  "carousel_items",
456
+ "scene_images",
444
457
  "caption_lines",
445
458
  "stub_image_url",
446
459
  "stub_video_url",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.24",
3
+ "version": "0.9.26",
4
4
  "description": "AI media generation skills for Claude Code / Codex — 12 skills covering image, video, voice, digital human, web screenshot, web recording, script, template registry, rendering, Jianying export, and video deconstruction.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -23,6 +23,7 @@
23
23
  "test:validators": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-validators.py",
24
24
  "test:template-pipeline": "PYTHONDONTWRITEBYTECODE=1 node dist/cli.js exec -- python3 scripts/test-template-pipeline.py",
25
25
  "test:props-contract": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-props-contract.py",
26
+ "test:narration-speed": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-narration-speed.py",
26
27
  "test:render-plan": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-plan-snapshot.py",
27
28
  "test:render-plan:update": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-plan-snapshot.py --update",
28
29
  "test:contracts": "PYTHONDONTWRITEBYTECODE=1 python3 skills/template-registry/scripts/check_contracts.py",
@@ -200,6 +200,110 @@ python3 <SkillDir>/scripts/gen_script.py \
200
200
  Every URL the user gave becomes one carousel item, in order. How long each item holds is the template's business — `fit-images` templates derive it from the item count, `fit-caption` templates from `capabilities.durationModel`; either way you pass the URLs and let the template decide.
201
201
 
202
202
 
203
+ ## Agent behavior: user-supplied images for scene-based templates (`--scene-images`)
204
+
205
+ The section above covers `carousel-caption` templates. Templates whose `payloadStyle` is
206
+ `visual-overlay` (image-slide and friends) have **real scenes**, each with its own background
207
+ image and its own narration — for those, user-supplied images go through `--scene-images`,
208
+ not `--carousel-items`.
209
+
210
+ Without this flag the user's images are **ignored entirely** and every scene gets an
211
+ AI-generated picture. That failure is quiet: the video renders fine, it just isn't made of
212
+ the material the user handed you.
213
+
214
+ ### The mapping rule (positional, then AI fills the rest)
215
+
216
+ Each entry is one scene's visual, and it is either a **URL** (use that existing asset) or the
217
+ literal **`ai`** / **`ai:<prompt>`** (leave that scene to gen-image). Entry i is scene i;
218
+ scenes past the last entry still get a generated image.
219
+
220
+ ```
221
+ --scene-images A --scene-images B → scene 1: A, scene 2: B, scene 3+: AI-generated
222
+ --scene-images A --scene-images ai:机房 … → scene 1: A, scene 2: AI with the user's prompt
223
+ ```
224
+
225
+ Three consequences worth internalising:
226
+
227
+ - **Two images still produce a full video.** Do not ask the user to "provide enough images"
228
+ or pad the list; partial input is the designed case.
229
+ - **Order is meaning, not layout.** Pass the entries in the order the user gave them — an AI
230
+ scene can sit *between* two of their images, and that placement is the point.
231
+ - **`ai:<prompt>` is the user's own image prompt for that one scene.** Pass it through
232
+ verbatim; do not rewrite it, and do not apply it to the other scenes. It replaces the
233
+ prompt that would have been derived from the template, but the template's negative prompt
234
+ still applies.
235
+
236
+ When `--scenes` is absent the scene count is **raised, never lowered**, to fit the images:
237
+ more images than the planned scene count grows the plan so none are dropped, but fewer images
238
+ does **not** shrink it — handing over 2 images should not turn a 30s five-scene video into a
239
+ three-scene one. If the template's scene count is fixed (`sceneStrategy: single` / `fixed`)
240
+ and there are still more images than scenes, `gen_script.py` prints a warning naming how many
241
+ went unused — surface that to the user instead of pretending everything was used.
242
+
243
+ ### Reading the image descriptions
244
+
245
+ Asset lines in the user's message carry a **`desc:` field** describing what is in the picture:
246
+
247
+ ```
248
+ ![4519.png](https://cdn.example.com/4519.png) (1920×1080, desc: 一个人背对镜头站在雾中的松林里)
249
+ ```
250
+
251
+ `desc:` is always the **last** item in the metadata parentheses, so everything from `desc:` to
252
+ the closing paren is the description — commas inside it are part of the text.
253
+
254
+ **Use it when writing narration.** This is the whole point of the field: for a
255
+ `visual-overlay` template you are writing the words that play over *that* picture, and the
256
+ description is the only thing telling you what the viewer will see. Narration that contradicts
257
+ the image is the most visible way this pipeline fails.
258
+
259
+ Two things `desc:` is **not**:
260
+
261
+ - It is **not an image-generation prompt.** The asset already exists; never route it through
262
+ `gen-image`, and never "improve" the picture to match the text.
263
+ - It is **not a headline.** Do not copy it onto the screen as `--headline` /
264
+ `--subheadline`; it is input for you, not on-screen copy.
265
+
266
+ A line with no `desc:` simply has no description — do not treat the file name in the alt text
267
+ (`4519.png`) as one.
268
+
269
+ ### Reading `ai:` lines
270
+
271
+ A line in the asset list may be an **AI scene** rather than an asset:
272
+
273
+ ```
274
+ ![a.png](https://cdn.example.com/a.png) (1920×1080, desc: 终端里正在跑安装命令的截图)
275
+ ai: 赛博朋克风格的服务器机房
276
+ ![b.png](https://cdn.example.com/b.png) (1920×1080)
277
+ ```
278
+
279
+ That is the user saying "scene 2 is AI-generated, and here is what I want in it". Pass it
280
+ straight through as the second `--scene-images` entry (`ai: 赛博朋克风格的服务器机房`),
281
+ keeping the position. A bare `ai:` with no text means "this scene is AI-generated, you decide
282
+ what it shows" — still pass it, as `ai`, so the position is preserved.
283
+
284
+ Note the two are different fields on purpose: `desc:` describes an image that **already
285
+ exists** (input for your narration), `ai:` prescribes an image that **does not exist yet**
286
+ (input for gen-image). Never feed a `desc:` into gen-image, and never write narration that
287
+ describes an `ai:` prompt as if it were a picture you have seen.
288
+
289
+ ### Command example
290
+
291
+ User: "用 image-slide 做一条讲 RAG 的视频,配图用这两张"
292
+ + two asset lines with `desc:`
293
+
294
+ ```bash
295
+ python3 <SkillDir>/scripts/gen_script.py \
296
+ --topic "三分钟看懂 RAG" \
297
+ --template-id image-slide \
298
+ --scene-images "https://cdn.example.com/a.png" \
299
+ --scene-images "ai: 赛博朋克风格的服务器机房" \
300
+ --scene-images "https://cdn.example.com/b.png"
301
+ ```
302
+
303
+ Then write each scene's narration against that scene's image description, and pass the
304
+ narration through `prepare_video_assets`'s `dsl_json` as usual.
305
+
306
+
203
307
  ## Test mode: skip asset generation (`--stub-image-url` / `--stub-video-url`)
204
308
 
205
309
  **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.
@@ -297,6 +401,7 @@ python3 <SkillDir>/scripts/gen_script.py \
297
401
  | `--ratio` | Aspect ratio. | `16:9` |
298
402
  | `--scenes` | Scene count. | auto-planned |
299
403
  | `--voice-id` | Narration voice id. | resolved from template + language fallback |
404
+ | `--speed` | Narration speech rate (0.5–2.0), written to `global.narration.speed` and applied when `prepare_video_assets` calls gen-voice. Pass it whenever the user picked a speed (the replicate form's 语速 field sends one). Above ~1.3 subtitle alignment drifts and the delivery turns mechanical — shorten the script instead. | `1.0` |
300
405
  | `--allow-digital-human` | Whether to allow digital-human assets. | off |
301
406
  | `--allow-ai-video` | Whether to allow AI-generated video assets. | off |
302
407
  | `--validate` | Validate-only mode: only validate the input DSL. | — |
@@ -305,6 +410,7 @@ python3 <SkillDir>/scripts/gen_script.py \
305
410
  | `--stub-image-url` | Test mode: every image AssetRef is written as existing + generated + this URL, no prompt (env: `STUB_IMAGE_URL`). | — |
306
411
  | `--stub-video-url` | Test mode: every video AssetRef is written as existing + generated + this URL, no prompt (env: `STUB_VIDEO_URL`). | — |
307
412
  | `--carousel-items` | Repeatable. Media URL placed directly into `customPayload.carousel.items` for `carousel-caption` templates. Bypasses gen-image. **Required** for `carousel-caption` templates when the user supplies images. | — |
413
+ | `--scene-images` | Repeatable. One scene's visual for a **`visual-overlay`** template (image-slide etc.), mapped **positionally**. Each entry is a URL (that existing asset) or `ai` / `ai:<prompt>` (that scene goes to gen-image, optionally with the user's own prompt) — so an AI scene can sit anywhere in the order. Scenes past the last entry still get AI images. Scene count is raised (never lowered) to fit the entries when `--scenes` is absent. **Required** whenever the user supplies images for such a template — omitting it silently ignores every image they gave. | — |
308
414
  | `--caption-lines` | Repeatable. On-screen caption line for `carousel-caption` templates → `customPayload.caption.lines`. Line limits and `**emphasis**` support are declared per template (see its `llmHint`). **Required** for `durationStrategy: fit-caption` templates — write the lines yourself if the user did not supply them. Optional only for `fit-images` templates. | — |
309
415
 
310
416
  ## DSL generation principles
@@ -80,6 +80,24 @@ FALLBACK_VOICE = {
80
80
  "en": "english-male-friendly-01",
81
81
  }
82
82
 
83
+ # ── Narration speed ───────────────────────────────────────────────────────────
84
+ # Written to global.narration.speed; render_video copies it down into the
85
+ # gen-voice asset payload (same route as narration text) and passes it to
86
+ # `gen-voice --speed`. The envelope mirrors gen-voice's own 0.5–2.0 contract;
87
+ # the real per-voice limits (capabilities speedMin/speedMax) are only known at
88
+ # synthesis time, which is several confirmation steps and a few paid image
89
+ # generations later — so an obviously-out-of-range value fails here instead.
90
+ DEFAULT_NARRATION_SPEED = 1.0
91
+ SPEED_MIN = 0.5
92
+ SPEED_MAX = 2.0
93
+
94
+
95
+ def _resolve_narration_speed(speed):
96
+ """CLI speed → the value written to global.narration.speed. Total function."""
97
+ if speed is None:
98
+ return DEFAULT_NARRATION_SPEED
99
+ return float(speed)
100
+
83
101
 
84
102
  def _resolve_template_language_meta(template_config):
85
103
  """Tolerant reader for outputLanguage / defaultVoiceId on a template dict.
@@ -313,6 +331,27 @@ def generate_image_prompt(
313
331
  return {"prompt": prompts.get(purpose, f"{topic}相关配图{style_suffix}")}
314
332
 
315
333
 
334
+ def _parse_ai_slot(slot: str) -> tuple[bool, str]:
335
+ """解析 --scene-images 的一项:是不是 AI 段,以及用户给的提示词。
336
+
337
+ 返回 ``(is_ai, prompt)``:
338
+
339
+ - ``"https://…/a.png"`` → ``(False, "")`` 用已有素材
340
+ - ``"ai"`` → ``(True, "")`` 这一段交给 gen-image,提示词由模板推
341
+ - ``"ai: 深色调机房"`` → ``(True, "深色调机房")``
342
+
343
+ 大小写不敏感。判定刻意收得很紧(整项等于 ``ai``,或以 ``ai:`` 开头):URL 才是这个
344
+ 参数的常态,把 ``ai`` 判宽了会让 ``https://ai.example.com/x.png`` 这种正常地址被
345
+ 误当成"生成一段",用户的图就此消失。
346
+ """
347
+ text = (slot or "").strip()
348
+ if text.lower() == "ai":
349
+ return True, ""
350
+ if text[:3].lower() == "ai:":
351
+ return True, text[3:].strip()
352
+ return False, ""
353
+
354
+
316
355
  def _load_template_config(template_id: str) -> dict | None:
317
356
  """Load template definition for the given template ID.
318
357
 
@@ -729,6 +768,7 @@ def _build_carousel_caption_dsl(
729
768
  resolution: str,
730
769
  output_language: str,
731
770
  resolved_voice_id: str,
771
+ narration_speed: float,
732
772
  font_id: str | None,
733
773
  font_name: str | None,
734
774
  narration_enabled: bool,
@@ -886,7 +926,7 @@ def _build_carousel_caption_dsl(
886
926
  # 无配音模板:关闭 CC 字幕(字幕段是 narration→TTS 的派生物),
887
927
  # 并省略 global.narration,避免下游误判存在旁白。
888
928
  "subtitle": {"enabled": narration_enabled, "style": "bottom"},
889
- **({"narration": {"voiceId": resolved_voice_id, "speed": 1.0}} if narration_enabled else {}),
929
+ **({"narration": {"voiceId": resolved_voice_id, "speed": narration_speed}} if narration_enabled else {}),
890
930
  **({"font": {"fontId": font_id, **({"fontName": font_name} if font_name else {})}} if font_id else {}),
891
931
  "bgm": {"enabled": True, "volume": 0.12},
892
932
  },
@@ -940,6 +980,7 @@ def build_dsl(
940
980
  scene_count: int | None,
941
981
  allow_digital_human: bool,
942
982
  allow_ai_video: bool,
983
+ speed: float | None = None,
943
984
  template_id: str | None = None,
944
985
  stub_image_url: str = "",
945
986
  stub_video_url: str = "",
@@ -947,6 +988,7 @@ def build_dsl(
947
988
  subheadline: str | None = None,
948
989
  carousel_items: list[str] | None = None,
949
990
  caption_lines: list[str] | None = None,
991
+ scene_images: list[str] | None = None,
950
992
  ) -> dict:
951
993
  """Build a complete Video DSL JSON.
952
994
 
@@ -965,6 +1007,15 @@ def build_dsl(
965
1007
  通过 role=headline / role=subheadline 抽出对应的 props(如 titleText / projectName)。
966
1008
  注意:subtitle(CC 字幕)由 global.subtitle 与 render-plan.subtitleSegments
967
1009
  单独承载,与 subheadline 完全是两个东西,命名上刻意分开避免歧义。
1010
+
1011
+ scene_images 是用户**自带的配图**(visual-overlay 模板专用),按顺序占位:第 i 张
1012
+ 图给第 i 个场景,没被占到的场景照常走 gen-image 补图。这条规则不是随便定的——
1013
+ 见 docs/asset-annotation-design.md §6:另外两种立场(模型自由挑选 / 严格一一对应)
1014
+ 都会让"只传两张图"要么失去可预期性,要么直接跑不通。
1015
+
1016
+ speed 是旁白语速倍率,落在 global.narration.speed(不给 = 1.0)。它**不写进
1017
+ gen-voice 资产的 payload**——与旁白文本同一条规矩:唯一来源在 DSL 上,
1018
+ render_video 在调 TTS 前按 assetRef 回查注入,免得两处存一个值各自漂移。
968
1019
  """
969
1020
  # Load template config(registry 单一数据源)→ 归一化 contract
970
1021
  template_config = _load_template_config(template_id) if template_id else None
@@ -983,6 +1034,7 @@ def build_dsl(
983
1034
  # values fall back to "zh" so old templates keep their current behavior.
984
1035
  output_language, template_default_voice_id = _resolve_template_language_meta(template_config)
985
1036
  resolved_voice_id = _resolve_voice_id(voice_id, template_default_voice_id, output_language)
1037
+ narration_speed = _resolve_narration_speed(speed)
986
1038
  if resolved_voice_id != voice_id:
987
1039
  print(
988
1040
  f"ℹ️ voice resolution: cli={voice_id!r} → resolved={resolved_voice_id!r} "
@@ -1060,6 +1112,7 @@ def build_dsl(
1060
1112
  resolution=resolution,
1061
1113
  output_language=output_language,
1062
1114
  resolved_voice_id=resolved_voice_id,
1115
+ narration_speed=narration_speed,
1063
1116
  font_id=font_id,
1064
1117
  font_name=font_name,
1065
1118
  narration_enabled=needs_narration,
@@ -1071,8 +1124,45 @@ def build_dsl(
1071
1124
  return carousel_dsl
1072
1125
 
1073
1126
  # ── 其余模板:统一场景规划(arc 叙事弧 / single / fixed)+ 统一装配循环 ──────
1127
+ #
1128
+ # scene_images 的每一项要么是一条 URL(用已有素材),要么是 `ai` / `ai:<提示词>`
1129
+ # (这一段留给 gen-image)。两者共用同一个位置序列 —— 用户在界面上排出来的顺序
1130
+ # 就是它,AI 段能插在任意位置,而不是只能挂在末尾。
1131
+ provided_slots = [s.strip() for s in (scene_images or []) if s and s.strip()]
1132
+
1074
1133
  scene_plans = _plan_contract_scenes(contract, topic, duration, scene_count, output_language)
1075
1134
 
1135
+ # 条目比场景多时**抬高**场景数,让每一条都有地方放。
1136
+ #
1137
+ # 只抬高、不压低:给 2 张图不该把一条 30s 的片子从 5 段压成 3 段。用户给图表达的是
1138
+ # "这几张都要用上",不是"整条片子改成这么多段"——按条目数直接改写场景数,会让
1139
+ # "多传了两张图"变成"视频结构被换掉了",而他根本没要求这个。
1140
+ #
1141
+ # 反过来条目多于场景时必须抬:不抬的话第 N+1 条之后会被静默丢掉,而用户在成片里是
1142
+ # 看不出"我的图去哪了"的。arc 之外的策略场景数由模板钉死,抬不动(下面警告兜底)。
1143
+ if (
1144
+ provided_slots
1145
+ and scene_count is None
1146
+ and contract["scene_strategy"] == "arc"
1147
+ and primary_visual_type == "image"
1148
+ and has_visual
1149
+ and len(provided_slots) > len(scene_plans)
1150
+ ):
1151
+ scene_plans = _plan_contract_scenes(
1152
+ contract, topic, duration, len(provided_slots), output_language
1153
+ )
1154
+
1155
+ # 排完之后仍然装不下的(single / fixed 模板,或用户显式指定了更少的场景数)就明说。
1156
+ # 静默丢弃用户自带的素材是这条链上最难被发现的一类错。
1157
+ if provided_slots and len(provided_slots) > len(scene_plans):
1158
+ print(
1159
+ f"\u26a0\ufe0f {len(provided_slots)} scene-image entries provided but the template "
1160
+ f"plans only {len(scene_plans)} scene(s); the extra "
1161
+ f"{len(provided_slots) - len(scene_plans)} will not be used. "
1162
+ f"Raise --scenes or pick a template with more scenes.",
1163
+ file=sys.stderr,
1164
+ )
1165
+
1076
1166
  assets = []
1077
1167
  scenes = []
1078
1168
 
@@ -1091,7 +1181,22 @@ def build_dsl(
1091
1181
 
1092
1182
  # 仅 visual-overlay 模板生成视觉素材(slide 无视觉素材,carousel-caption 已提前返回)
1093
1183
  if has_visual and primary_visual_type == "image":
1094
- if stub_image_url:
1184
+ slot = provided_slots[idx] if idx < len(provided_slots) else ""
1185
+ slot_is_ai, slot_prompt = _parse_ai_slot(slot)
1186
+ if slot and not slot_is_ai:
1187
+ # 用户自带的图排在 stub 之前:stub 是"别烧配额"的测试开关,而用户给的
1188
+ # 图同样一分钱不花,没有理由拿占位图把真素材盖掉。
1189
+ assets.append({
1190
+ "assetId": visual_asset_id,
1191
+ "type": "image",
1192
+ "source": "existing",
1193
+ "status": "generated",
1194
+ "url": slot,
1195
+ })
1196
+ # stub 盖得住 AI 段,盖不住用户自带的图。差别在于花不花钱:--stub-image-url
1197
+ # 的全部意义就是"这一趟别调 gen-image",而 AI 段恰恰是要调的那种;反过来
1198
+ # 用户自带的 URL 一分钱不花,拿占位图把真素材盖掉纯属损失。
1199
+ elif stub_image_url:
1095
1200
  assets.append({
1096
1201
  "assetId": visual_asset_id,
1097
1202
  "type": "image",
@@ -1102,6 +1207,13 @@ def build_dsl(
1102
1207
  else:
1103
1208
  image_result = generate_image_prompt(plan["purpose"], topic, style, narration_text=narration_text)
1104
1209
 
1210
+ # 用户在 AI 段里写了提示词就用他的。**只替换主体,不动 negativePrompt /
1211
+ # guidanceScale**:那两项是模板的护栏("不要文字水印、不要变形"),把它们
1212
+ # 一起丢掉会让用户随手写的一句话换来一张带乱码文字的图,而他并没有要求
1213
+ # 关掉护栏——他只是想说这一段画什么。
1214
+ if slot_prompt:
1215
+ image_result = {**image_result, "prompt": slot_prompt}
1216
+
1105
1217
  img_payload = {
1106
1218
  "prompt": image_result["prompt"],
1107
1219
  "model": os.environ.get("DEFAULT_IMAGE_MODEL", "doubao/doubao-seedream-5-0-260128"),
@@ -1291,7 +1403,7 @@ def build_dsl(
1291
1403
  # 无配音模板:关闭 CC 字幕(字幕段是 narration→TTS 的派生物),并省略
1292
1404
  # global.narration,避免下游误判存在旁白。
1293
1405
  "subtitle": {"enabled": needs_narration, "style": "bottom"},
1294
- **({"narration": {"voiceId": resolved_voice_id, "speed": 1.0}} if needs_narration else {}),
1406
+ **({"narration": {"voiceId": resolved_voice_id, "speed": narration_speed}} if needs_narration else {}),
1295
1407
  **({"font": {"fontId": font_id, **({"fontName": font_name} if font_name else {})}} if font_id else {}),
1296
1408
  "bgm": {"enabled": True, "volume": 0.12},
1297
1409
  },
@@ -1331,6 +1443,17 @@ Examples:
1331
1443
  parser.add_argument("--resolution", default="1080p", help="Resolution (default: 1080p)")
1332
1444
  parser.add_argument("--scenes", type=int, default=None, help="Scene count (default: auto-planned)")
1333
1445
  parser.add_argument("--voice-id", default=None, help="Narration voice id. When omitted, the resolver picks template.defaultVoiceId, then the language-keyed fallback (zh→Chinese (Mandarin)_Male_Announcer, en→TBD English voice). gen_voice --list-voices --local prints the language-tagged catalog.")
1446
+ parser.add_argument(
1447
+ "--speed",
1448
+ type=float,
1449
+ default=None,
1450
+ help=(
1451
+ f"Narration speech rate ({SPEED_MIN}-{SPEED_MAX}, default {DEFAULT_NARRATION_SPEED}). "
1452
+ "Stored at global.narration.speed and applied by render_video when it calls gen-voice. "
1453
+ "Above ~1.3 subtitle alignment starts to drift and the delivery turns mechanical — "
1454
+ "shorten the script instead."
1455
+ ),
1456
+ )
1334
1457
  parser.add_argument(
1335
1458
  "--font-id",
1336
1459
  default=None,
@@ -1383,6 +1506,20 @@ Examples:
1383
1506
  "these URLs are placed directly into "
1384
1507
  "customPayload.carousel.items as existing assets, bypassing AI image generation entirely.",
1385
1508
  )
1509
+ parser.add_argument(
1510
+ "--scene-images",
1511
+ action="append",
1512
+ default=None,
1513
+ help="One scene's visual for a visual-overlay template (image-slide etc.). "
1514
+ "Can be repeated: --scene-images url1 --scene-images ai --scene-images url2. "
1515
+ "Each entry is either a URL (use that existing asset) or the literal 'ai' / "
1516
+ "'ai:<prompt>' (leave this scene to gen-image, optionally with the user's prompt). "
1517
+ "Mapping is POSITIONAL: the i-th entry is the i-th scene's background, so an AI "
1518
+ "scene can sit anywhere in the order, not just at the end; scenes past the last "
1519
+ "entry still get an AI-generated image. When --scenes is not given the scene count "
1520
+ "is raised (never lowered) to fit the entries. "
1521
+ "Not for carousel-caption templates — those take --carousel-items instead.",
1522
+ )
1386
1523
  parser.add_argument(
1387
1524
  "--caption-lines",
1388
1525
  action="append",
@@ -1421,6 +1558,15 @@ Examples:
1421
1558
  parser.print_help()
1422
1559
  sys.exit(1)
1423
1560
 
1561
+ if args.speed is not None and not (SPEED_MIN <= args.speed <= SPEED_MAX):
1562
+ print(
1563
+ f"❌ --speed must be in the range {SPEED_MIN}~{SPEED_MAX} (got {args.speed}).\n"
1564
+ " Speech rate is a multiplier, not a percentage: 1.0 is the voice's own pace, "
1565
+ "1.1 is slightly brisker.",
1566
+ file=sys.stderr,
1567
+ )
1568
+ sys.exit(1)
1569
+
1424
1570
  # ── 模板感知的 ratio 自动推导 ────────────────────────────────────────────
1425
1571
  # 用户没显式传 --ratio 时:
1426
1572
  # 1. 若指定了 --template-id,取模板 supportedAspectRatios[0](避免横竖屏不匹配)
@@ -1452,6 +1598,8 @@ Examples:
1452
1598
  print(f" platform: {args.platform}", file=sys.stderr)
1453
1599
  print(f" duration: {args.duration}s", file=sys.stderr)
1454
1600
  print(f" ratio: {args.ratio}", file=sys.stderr)
1601
+ if args.speed is not None:
1602
+ print(f" narration speed: {args.speed}x", file=sys.stderr)
1455
1603
 
1456
1604
  # CLI flag takes precedence; env vars act as fallback with a visible warning
1457
1605
  # so silent cross-session leakage is always observable.
@@ -1492,6 +1640,7 @@ Examples:
1492
1640
  ratio=args.ratio,
1493
1641
  resolution=args.resolution,
1494
1642
  voice_id=args.voice_id,
1643
+ speed=args.speed,
1495
1644
  font_id=args.font_id,
1496
1645
  font_name=args.font_name,
1497
1646
  scene_count=args.scenes,
@@ -1504,6 +1653,7 @@ Examples:
1504
1653
  subheadline=args.subheadline,
1505
1654
  carousel_items=args.carousel_items,
1506
1655
  caption_lines=args.caption_lines,
1656
+ scene_images=args.scene_images,
1507
1657
  )
1508
1658
 
1509
1659
  # Post-process for --skip-asset-generation: rewrite all gen-voice / gen-digital-human
@@ -52,6 +52,10 @@
52
52
  "type": "string",
53
53
  "description": "Narration voice id. Default depends on the bound template's outputLanguage; query gen_voice with list_voices=true to see available ids."
54
54
  },
55
+ "speed": {
56
+ "type": "number",
57
+ "description": "Narration speech rate, 0.5-2.0 (default 1.0). Stored at global.narration.speed and applied when prepare_video_assets synthesizes the voice-over. **Must** pass when the user picked a speech rate — a template's 语速 / speed parameter arrives as a multiplier like 1.1, and without this flag it is silently dropped and the narration comes back at 1.0. Above ~1.3 subtitle alignment drifts and the delivery sounds mechanical; suggest shortening the script instead of raising it further."
58
+ },
55
59
  "template_id": {
56
60
  "type": "string",
57
61
  "description": "Template id (e.g. html-slide). The template owns outputLanguage and may also declare a defaultVoiceId; both flow into the produced DSL."
@@ -71,6 +75,13 @@
71
75
  },
72
76
  "description": "Media URLs for the template's image/video carousel. When provided together with a template_id whose capabilities.payloadStyle=carousel-caption, these URLs are placed directly into customPayload.carousel.items as existing assets — NO AI image generation is triggered. **Must** pass when the user explicitly provides image/video URLs for such a template. Each element is a full URL string."
73
77
  },
78
+ "scene_images": {
79
+ "type": "array",
80
+ "items": {
81
+ "type": "string"
82
+ },
83
+ "description": "Per-scene visuals for a visual-overlay template (image-slide etc.) — the counterpart of carousel_items for templates that have real scenes. Each element is EITHER a full URL (use that existing asset for the scene, no gen-image call) OR the literal string 'ai' / 'ai:<prompt>' (leave that scene to gen-image; the text after the colon, when present, is the user's own image prompt for it). Mapping is POSITIONAL: element i is scene i, so an AI scene can sit anywhere in the order — pass ['url1','ai','url2'] verbatim when the user arranged it that way. Scenes past the last element still get an AI-generated image, so 2 images still produce a full video. When `scenes` is not set the scene count is raised (never lowered) to fit the elements, so nothing the user arranged is dropped and handing over fewer images does not shrink the video. **Must** pass when the user supplies images for such a template — otherwise their images are ignored entirely and every scene is AI-generated. Do NOT pass for carousel-caption templates; those take carousel_items."
84
+ },
74
85
  "caption_lines": {
75
86
  "type": "array",
76
87
  "items": {
@@ -107,10 +118,12 @@
107
118
  "ratio",
108
119
  "scenes",
109
120
  "voice_id",
121
+ "speed",
110
122
  "template_id",
111
123
  "headline",
112
124
  "subheadline",
113
125
  "carousel_items",
126
+ "scene_images",
114
127
  "caption_lines",
115
128
  "stub_image_url",
116
129
  "stub_video_url",
@@ -2,6 +2,6 @@
2
2
  "skillName": "gen-script",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "474",
5
- "version": "V11",
5
+ "version": "V12",
6
6
  "skillDescription": "Video-script generation skill. Turns a user-supplied topic into a structured Video DSL (JSON) that describes the full video — scene structure, asset requirements, and narrative flow.\n\nUse this skill as soon as the user mentions any of these intents:\n- Write a video script, generate a video script, plan a video, write storyboards\n- Create a short video, plan video content, generate a Video DSL\n- Turn a topic into a video structure / video content plan\n\nEven when the user does not say \"generate the DSL\", use this skill whenever they want to turn a topic into a structured video plan.\n\n⚠️ Stop-and-confirm gate: after this skill returns a DSL, show the full script and wait for the user's explicit confirmation. Never call `prepare_video_assets` in the same turn."
7
7
  }
@@ -159,6 +159,22 @@ RESOLUTION_MAP = {
159
159
  }
160
160
 
161
161
 
162
+ def _narration_speed(narration: dict) -> Optional[float]:
163
+ """Read a narration block's speech-rate multiplier, or None when unset.
164
+
165
+ Tolerant on purpose: the DSL is hand-editable, and a malformed speed must
166
+ not take down a render that would otherwise be fine — it falls back to the
167
+ voice's own pace. 0 and negatives are treated as unset for the same reason
168
+ ab-api does (`speed: 0` there means "follow the global setting").
169
+ """
170
+ if not isinstance(narration, dict):
171
+ return None
172
+ raw = narration.get("speed")
173
+ if isinstance(raw, bool) or not isinstance(raw, (int, float)):
174
+ return None
175
+ return float(raw) if raw > 0 else None
176
+
177
+
162
178
  def extract_narration_lines(narration: dict) -> Optional[tuple[list[str], int, list[Optional[float]]]]:
163
179
  """If narration uses the structured {intro, items, outro} form, return
164
180
  (lines, intro_line_count, at_sec_list). `at_sec_list` is parallel to
@@ -621,6 +637,8 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
621
637
  # so adjust_timeline_to_audio can later auto-derive highlightMap from
622
638
  # per-line TTS timestamps.
623
639
  dsl_assets_by_id = {a["assetId"]: a for a in dsl.get("assets", [])}
640
+ global_narration = (dsl.get("global") or {}).get("narration") or {}
641
+ global_speed = _narration_speed(global_narration)
624
642
  for scene in dsl.get("scenes", []):
625
643
  narration = (scene.get("audio") or {}).get("narration") or {}
626
644
  ref = narration.get("assetRef")
@@ -628,6 +646,15 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
628
646
  if not asset:
629
647
  continue
630
648
  payload = asset.setdefault("payload", {})
649
+ # Speech rate follows the same route as the narration text: single source
650
+ # of truth on the DSL, copied down here because resolve_asset_audio only
651
+ # ever sees the asset. Scene-level overrides global (mirrors the narration
652
+ # editor's "行级覆盖 > 全局"); neither set = gen-voice's own 1.0 default.
653
+ speed = _narration_speed(narration)
654
+ if speed is None:
655
+ speed = global_speed
656
+ if speed is not None:
657
+ payload["speed"] = speed
631
658
  extracted = extract_narration_lines(narration)
632
659
  if extracted:
633
660
  lines, intro_lines, _ = extracted
@@ -862,6 +889,11 @@ def resolve_asset_audio(asset: dict, private_token: str, timeout: int) -> dict:
862
889
  cmd.extend(["--text", tts_text, "--json-output"])
863
890
  if payload.get("voiceId"):
864
891
  cmd.extend(["--voice-id", payload["voiceId"]])
892
+ # Injected by build_render_plan from the DSL (scene narration > global).
893
+ # Absent = let gen-voice apply its own default rather than pinning 1.0 here.
894
+ speed = _narration_speed(payload)
895
+ if speed is not None:
896
+ cmd.extend(["--speed", str(speed)])
865
897
  if private_token:
866
898
  cmd.extend(["--priv-token", private_token])
867
899
 
@@ -2,6 +2,6 @@
2
2
  "skillName": "render-video",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "473",
5
- "version": "V19",
5
+ "version": "V20",
6
6
  "skillDescription": "Final-render skill: loads a persisted RenderPlan by `job_id` and drives the Remotion engine to produce the final video.\n\nUse this skill as soon as the user mentions any of these intents (after assets are already prepared):\n- Render the video, composite the video, export the video\n- Turn the prepared assets into the final clip\n- Render with Remotion\n\nPrerequisite: assets must already be generated via `prepare_video_assets`. This skill never resolves or regenerates assets — pass it a `job_id` from a previous `prepare_video_assets` call.\n\n⚠️ Stop-and-confirm gate: never call this skill until the user has explicitly confirmed the assets prepared by `prepare_video_assets`. If those assets were prepared in the current turn and the user has not replied since, stop and ask instead of rendering."
7
7
  }