@remixmate/cli 0.9.25 → 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.25",
4
- "generatedAt": "2026-09-08T15:32:17.519Z",
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."
@@ -444,6 +448,7 @@
444
448
  "ratio",
445
449
  "scenes",
446
450
  "voice_id",
451
+ "speed",
447
452
  "template_id",
448
453
  "headline",
449
454
  "subheadline",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.25",
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",
@@ -401,6 +401,7 @@ python3 <SkillDir>/scripts/gen_script.py \
401
401
  | `--ratio` | Aspect ratio. | `16:9` |
402
402
  | `--scenes` | Scene count. | auto-planned |
403
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` |
404
405
  | `--allow-digital-human` | Whether to allow digital-human assets. | off |
405
406
  | `--allow-ai-video` | Whether to allow AI-generated video assets. | off |
406
407
  | `--validate` | Validate-only mode: only validate the input DSL. | — |
@@ -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.
@@ -750,6 +768,7 @@ def _build_carousel_caption_dsl(
750
768
  resolution: str,
751
769
  output_language: str,
752
770
  resolved_voice_id: str,
771
+ narration_speed: float,
753
772
  font_id: str | None,
754
773
  font_name: str | None,
755
774
  narration_enabled: bool,
@@ -907,7 +926,7 @@ def _build_carousel_caption_dsl(
907
926
  # 无配音模板:关闭 CC 字幕(字幕段是 narration→TTS 的派生物),
908
927
  # 并省略 global.narration,避免下游误判存在旁白。
909
928
  "subtitle": {"enabled": narration_enabled, "style": "bottom"},
910
- **({"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 {}),
911
930
  **({"font": {"fontId": font_id, **({"fontName": font_name} if font_name else {})}} if font_id else {}),
912
931
  "bgm": {"enabled": True, "volume": 0.12},
913
932
  },
@@ -961,6 +980,7 @@ def build_dsl(
961
980
  scene_count: int | None,
962
981
  allow_digital_human: bool,
963
982
  allow_ai_video: bool,
983
+ speed: float | None = None,
964
984
  template_id: str | None = None,
965
985
  stub_image_url: str = "",
966
986
  stub_video_url: str = "",
@@ -992,6 +1012,10 @@ def build_dsl(
992
1012
  图给第 i 个场景,没被占到的场景照常走 gen-image 补图。这条规则不是随便定的——
993
1013
  见 docs/asset-annotation-design.md §6:另外两种立场(模型自由挑选 / 严格一一对应)
994
1014
  都会让"只传两张图"要么失去可预期性,要么直接跑不通。
1015
+
1016
+ speed 是旁白语速倍率,落在 global.narration.speed(不给 = 1.0)。它**不写进
1017
+ gen-voice 资产的 payload**——与旁白文本同一条规矩:唯一来源在 DSL 上,
1018
+ render_video 在调 TTS 前按 assetRef 回查注入,免得两处存一个值各自漂移。
995
1019
  """
996
1020
  # Load template config(registry 单一数据源)→ 归一化 contract
997
1021
  template_config = _load_template_config(template_id) if template_id else None
@@ -1010,6 +1034,7 @@ def build_dsl(
1010
1034
  # values fall back to "zh" so old templates keep their current behavior.
1011
1035
  output_language, template_default_voice_id = _resolve_template_language_meta(template_config)
1012
1036
  resolved_voice_id = _resolve_voice_id(voice_id, template_default_voice_id, output_language)
1037
+ narration_speed = _resolve_narration_speed(speed)
1013
1038
  if resolved_voice_id != voice_id:
1014
1039
  print(
1015
1040
  f"ℹ️ voice resolution: cli={voice_id!r} → resolved={resolved_voice_id!r} "
@@ -1087,6 +1112,7 @@ def build_dsl(
1087
1112
  resolution=resolution,
1088
1113
  output_language=output_language,
1089
1114
  resolved_voice_id=resolved_voice_id,
1115
+ narration_speed=narration_speed,
1090
1116
  font_id=font_id,
1091
1117
  font_name=font_name,
1092
1118
  narration_enabled=needs_narration,
@@ -1377,7 +1403,7 @@ def build_dsl(
1377
1403
  # 无配音模板:关闭 CC 字幕(字幕段是 narration→TTS 的派生物),并省略
1378
1404
  # global.narration,避免下游误判存在旁白。
1379
1405
  "subtitle": {"enabled": needs_narration, "style": "bottom"},
1380
- **({"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 {}),
1381
1407
  **({"font": {"fontId": font_id, **({"fontName": font_name} if font_name else {})}} if font_id else {}),
1382
1408
  "bgm": {"enabled": True, "volume": 0.12},
1383
1409
  },
@@ -1417,6 +1443,17 @@ Examples:
1417
1443
  parser.add_argument("--resolution", default="1080p", help="Resolution (default: 1080p)")
1418
1444
  parser.add_argument("--scenes", type=int, default=None, help="Scene count (default: auto-planned)")
1419
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
+ )
1420
1457
  parser.add_argument(
1421
1458
  "--font-id",
1422
1459
  default=None,
@@ -1521,6 +1558,15 @@ Examples:
1521
1558
  parser.print_help()
1522
1559
  sys.exit(1)
1523
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
+
1524
1570
  # ── 模板感知的 ratio 自动推导 ────────────────────────────────────────────
1525
1571
  # 用户没显式传 --ratio 时:
1526
1572
  # 1. 若指定了 --template-id,取模板 supportedAspectRatios[0](避免横竖屏不匹配)
@@ -1552,6 +1598,8 @@ Examples:
1552
1598
  print(f" platform: {args.platform}", file=sys.stderr)
1553
1599
  print(f" duration: {args.duration}s", file=sys.stderr)
1554
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)
1555
1603
 
1556
1604
  # CLI flag takes precedence; env vars act as fallback with a visible warning
1557
1605
  # so silent cross-session leakage is always observable.
@@ -1592,6 +1640,7 @@ Examples:
1592
1640
  ratio=args.ratio,
1593
1641
  resolution=args.resolution,
1594
1642
  voice_id=args.voice_id,
1643
+ speed=args.speed,
1595
1644
  font_id=args.font_id,
1596
1645
  font_name=args.font_name,
1597
1646
  scene_count=args.scenes,
@@ -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."
@@ -114,6 +118,7 @@
114
118
  "ratio",
115
119
  "scenes",
116
120
  "voice_id",
121
+ "speed",
117
122
  "template_id",
118
123
  "headline",
119
124
  "subheadline",
@@ -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
  }