@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 +4 -0
- package/dist/manifest.json +15 -2
- package/package.json +2 -1
- package/skills/gen-script/SKILL.md +106 -0
- package/skills/gen-script/scripts/gen_script.py +153 -3
- package/skills/gen-script/skill.json +13 -0
- package/skills/gen-script/version.json +1 -1
- package/skills/render-video/scripts/render_video.py +32 -0
- package/skills/render-video/version.json +1 -1
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;
|
package/dist/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"generatedAt": "2026-09-
|
|
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.
|
|
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
|
+
 (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
|
+
 (1920×1080, desc: 终端里正在跑安装命令的截图)
|
|
275
|
+
ai: 赛博朋克风格的服务器机房
|
|
276
|
+
 (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":
|
|
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
|
|
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":
|
|
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": "
|
|
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": "
|
|
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
|
}
|