@remixmate/cli 0.9.36 → 0.9.38
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/dist/handlers/gen-voice.js +14 -1
- package/dist/manifest.json +2 -15
- package/package.json +2 -1
- package/skills/gen-script/SKILL.md +16 -8
- package/skills/gen-script/scripts/gen_script.py +2 -16
- package/skills/gen-script/skill.json +0 -13
- package/skills/gen-voice/SKILL.md +1 -0
- package/skills/render-video/scripts/render_video.py +29 -0
- package/skills/template-registry/SKILL.md +1 -0
- package/skills/template-registry/video_dsl/runtime/dsl_validator.py +47 -15
- package/skills/template-registry/video_dsl/runtime/timeline_compiler.py +96 -17
- package/skills/template-registry/video_dsl/schema/video-dsl-v1alpha1.json +4 -7
|
@@ -88,13 +88,26 @@ export async function genVoice(input, ctxIn) {
|
|
|
88
88
|
if (speed < voice.speedMin || speed > voice.speedMax) {
|
|
89
89
|
throw new SkillError(`❌ speed must be in the range ${voice.speedMin}~${voice.speedMax}`);
|
|
90
90
|
}
|
|
91
|
+
// Language hint for mixed-script narration. Like `local`, this is deliberately
|
|
92
|
+
// absent from skill.json's `parameters`: the service already applies a sane
|
|
93
|
+
// default for every caller, so handing the model a 40-value enum only invites
|
|
94
|
+
// it to guess. Reachable from the DSL (global/scene `narration.languageBoost`,
|
|
95
|
+
// forwarded by render_video) and by typing the flag yourself. Omitted =
|
|
96
|
+
// the service default.
|
|
97
|
+
const languageBoost = input.language_boost?.trim() || undefined;
|
|
91
98
|
const ctx = await resolveHttpContext(ctxIn.skillName, {
|
|
92
99
|
apiBaseUrl,
|
|
93
100
|
privateToken: input.priv_token,
|
|
94
101
|
preflightToken: ctxIn.auth?.token,
|
|
95
102
|
});
|
|
96
103
|
emitProgress({ phase: 'gen-voice:request', voiceId, speed });
|
|
97
|
-
const data = await mmPost(ctx, '/tool/tts', {
|
|
104
|
+
const data = await mmPost(ctx, '/tool/tts', {
|
|
105
|
+
provider: voice.provider,
|
|
106
|
+
text,
|
|
107
|
+
voiceSetting: { voiceId, speed },
|
|
108
|
+
outputFormat: 'url',
|
|
109
|
+
...(languageBoost ? { languageBoost } : {}),
|
|
110
|
+
}, { timeoutMs: 120_000 });
|
|
98
111
|
const audioUrl = data.audio;
|
|
99
112
|
if (!audioUrl) {
|
|
100
113
|
throw new SkillError(`❌ API response did not contain an audio URL: ${JSON.stringify(data)}`);
|
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.38",
|
|
4
|
+
"generatedAt": "2026-09-21T11:34:27.703Z",
|
|
5
5
|
"skills": [
|
|
6
6
|
{
|
|
7
7
|
"id": "export-jianying",
|
|
@@ -350,18 +350,6 @@
|
|
|
350
350
|
"type": "string",
|
|
351
351
|
"description": "Video topic (required)"
|
|
352
352
|
},
|
|
353
|
-
"platform": {
|
|
354
|
-
"type": "string",
|
|
355
|
-
"enum": [
|
|
356
|
-
"douyin",
|
|
357
|
-
"xiaohongshu",
|
|
358
|
-
"bilibili",
|
|
359
|
-
"wechat",
|
|
360
|
-
"youtube",
|
|
361
|
-
"generic"
|
|
362
|
-
],
|
|
363
|
-
"description": "Target platform"
|
|
364
|
-
},
|
|
365
353
|
"duration": {
|
|
366
354
|
"type": "number",
|
|
367
355
|
"description": "Target duration in seconds"
|
|
@@ -442,7 +430,6 @@
|
|
|
442
430
|
],
|
|
443
431
|
"advanced": [],
|
|
444
432
|
"hidden": [
|
|
445
|
-
"platform",
|
|
446
433
|
"duration",
|
|
447
434
|
"style",
|
|
448
435
|
"ratio",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remixmate/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.38",
|
|
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",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"test:props-contract": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-props-contract.py",
|
|
27
27
|
"test:card-array-keys": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-card-array-keys.py",
|
|
28
28
|
"test:narration-speed": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-narration-speed.py",
|
|
29
|
+
"test:narration-tts": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-narration-tts.py",
|
|
29
30
|
"test:render-resume": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-resume.py",
|
|
30
31
|
"test:render-plan": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-plan-snapshot.py",
|
|
31
32
|
"test:render-plan:update": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-plan-snapshot.py --update",
|
|
@@ -171,7 +171,7 @@ The agent should show the following in clear Markdown:
|
|
|
171
171
|
|
|
172
172
|
### Video info
|
|
173
173
|
- Topic: 3 AI study hacks
|
|
174
|
-
-
|
|
174
|
+
- Aspect ratio: 9:16 | Estimated duration: 45s
|
|
175
175
|
|
|
176
176
|
### Scene structure
|
|
177
177
|
| # | Purpose | Duration | Narration |
|
|
@@ -374,7 +374,7 @@ Env vars `STUB_IMAGE_URL` / `STUB_VIDEO_URL` also work — their priority is low
|
|
|
374
374
|
## Steps
|
|
375
375
|
|
|
376
376
|
1. **Script path**: read the system-injected `Base directory for this skill: <path>` as `<SkillDir>`. Every command below uses `<SkillDir>/scripts/gen_script.py`; never hard-code paths.
|
|
377
|
-
2. **Understand the request**: extract topic,
|
|
377
|
+
2. **Understand the request**: extract topic, duration, style, audience, etc.
|
|
378
378
|
3. **If the user specifies a template, read it first**: load the matching `template.json` via `template-registry` and confirm its template-specific fields and asset requirements. Do not skip this step.
|
|
379
379
|
4. **Generate the DSL**: pick the right command or DSL shape for the target template (see the per-template commands below).
|
|
380
380
|
5. **Schema validation**: make sure the output DSL conforms to v1alpha1.
|
|
@@ -385,7 +385,6 @@ Env vars `STUB_IMAGE_URL` / `STUB_VIDEO_URL` also work — their priority is low
|
|
|
385
385
|
```bash
|
|
386
386
|
python3 <SkillDir>/scripts/gen_script.py \
|
|
387
387
|
--topic "3 AI hacks that double your study efficiency" \
|
|
388
|
-
--platform douyin \
|
|
389
388
|
--duration 30 \
|
|
390
389
|
--style "tech" \
|
|
391
390
|
--ratio "9:16"
|
|
@@ -416,7 +415,6 @@ python3 <SkillDir>/scripts/gen_script.py \
|
|
|
416
415
|
| Flag | Description | Default |
|
|
417
416
|
|------|-------------|---------|
|
|
418
417
|
| `--topic` | Video topic (required unless `--validate`). | — |
|
|
419
|
-
| `--platform` | Target platform: `douyin` / `xiaohongshu` / `bilibili` / `wechat` / `youtube` / `generic`. | `generic` |
|
|
420
418
|
| `--headline` | On-screen main title (4–12 chars / ~3 words). Written to `meta.headline` + `textLayers[role=headline]`. **Pass it whenever the user gave a title** — otherwise headline falls back to the long-form topic and overflows the top text layer. | falls back to `--topic` |
|
|
421
419
|
| `--subheadline` | On-screen subtitle / slogan. Written to `meta.subheadline` + `textLayers[role=subheadline]`. Multi-line via `\n`; how many lines survive and whether `**emphasis**` renders is declared per template — see its `llmHint`. Not the same thing as CC subtitles (`global.subtitle`). | `""` |
|
|
422
420
|
| `--duration` | Target duration (seconds). Templates whose `durationStrategy` is `fit-caption` / `fit-narration` recompute the real duration and ignore this value. | `30` |
|
|
@@ -443,11 +441,22 @@ Apply the following principles when producing the DSL:
|
|
|
443
441
|
1. **Narrative-first**: opening hook → point expansion → CTA close.
|
|
444
442
|
2. **Assets are declared, not executed**: every asset starts as `AssetRef` with `status: planned`; no generation API call from this skill.
|
|
445
443
|
3. **Narration drives duration**: each scene's duration should match its narration reading time (≈ 3–4 zh chars/sec, or ~2 words/sec for English).
|
|
446
|
-
4. **
|
|
444
|
+
4. **One complete thought per line — do NOT pre-split at subtitle width.** Separate lines inside `audio.narration.text` with `\n`, and put **one full clause or sentence** on each line (roughly 15–30 Chinese characters, or an equivalent number of English words). A `\n` is a hard cut in the *audio*: TTS treats each line as its own segment and returns a timestamp for it, so lines should fall where a human would actually pause for breath.
|
|
445
|
+
|
|
446
|
+
**Do not cut mid-phrase to make lines "subtitle-sized".** The renderer splits each line into on-screen subtitles itself, and it does that better than you can from the text alone: it measures the real glyph widths for the actual font size and frame (`HNSW` is not as wide as four Chinese characters), knows how many characters fit on one line of *this* template and aspect ratio, and picks cut points by punctuation strength (sentence end → colon → comma → enumeration comma → space). It also guarantees no wrapping. Pre-splitting takes all of that away and just makes the captions choppy.
|
|
447
|
+
|
|
448
|
+
Measured on a shipped video (2026-09-21): narration written as 6–8 character lines produced 57 subtitle screens in 121.5s — a median of 2.10s each, 33% of them under 1.5s, with `AI Agent` alone on a screen. The renderer's own splitter never even fired.
|
|
449
|
+
|
|
450
|
+
Write:
|
|
447
451
|
```
|
|
448
|
-
"text": "
|
|
452
|
+
"text": "最近刷爆科技圈的 AI Agent 到底厉害在哪\n真正的分水岭不是文案更精准,而是自主执行能力"
|
|
449
453
|
```
|
|
450
|
-
|
|
454
|
+
not:
|
|
455
|
+
```
|
|
456
|
+
"text": "最近刷爆科技圈的\nAI Agent\n到底厉害在哪\n不是文案更精准\n也不是对话更流畅"
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
**Use full-width punctuation in Chinese narration** (`,。:;?!`), not half-width (`,.:;?!`). Half-width punctuation in Chinese text renders with no trailing space — `第一步,目标感知:只给最终目标` reads cramped on screen.
|
|
451
460
|
5. **Moderate scene count**: 30-second videos work well with 4–6 scenes, 60-second videos with 6–10.
|
|
452
461
|
6. **Leave room for templates**: pick generic layouts; do not assume a specific template implementation.
|
|
453
462
|
7. **Image model allowlist**: every `type: image` + `source: gen-image` `AssetRef`'s `payload.model` **must** be one of the values in the table below. **Never** use display names, short forms, or made-up ids (e.g. `seedream`, `seedream-5`, etc.).
|
|
@@ -474,7 +483,6 @@ prefix and version — not a short alias:
|
|
|
474
483
|
|
|
475
484
|
- **Schema validation failed**: check the DSL JSON shape and required fields against the schema.
|
|
476
485
|
- **Scene duration mismatch**: adjust the narration length or the scene duration.
|
|
477
|
-
- **Invalid platform**: look at the supported-platform list.
|
|
478
486
|
- **`carousel-caption template ... needs visual or text content`**: you passed neither `--carousel-items` nor `--caption-lines`. Pass the user's media URLs (and caption lines where the template requires them).
|
|
479
487
|
- **`template ... is typewriter-driven ... but --caption-lines is empty`**: a `fit-caption` template got no caption. Write the copy yourself if the user did not supply it, then pass one `--caption-lines` per line.
|
|
480
488
|
- **`duration Ns is below/above template ... supportedDurations`**: the assembled DSL falls outside the range the template declares it was designed for. Below the minimum usually means the content is too thin (add caption lines / narration / scenes); above the maximum means trimming content or lowering `--duration`. This is enforced at generation time on purpose — a degenerate video still costs full render credits.
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
Video script generator — turns a topic into Video DSL v1alpha1 JSON.
|
|
4
4
|
|
|
5
5
|
Default behavior:
|
|
6
|
-
Produces a structured Video DSL JSON to stdout based on the topic,
|
|
7
|
-
|
|
6
|
+
Produces a structured Video DSL JSON to stdout based on the topic, duration,
|
|
7
|
+
and other constraints.
|
|
8
8
|
|
|
9
9
|
Usage:
|
|
10
10
|
python gen_script.py --topic "3 AI hacks that double study efficiency" --duration 30 --ratio "9:16"
|
|
@@ -56,7 +56,6 @@ try:
|
|
|
56
56
|
except ImportError:
|
|
57
57
|
_HAS_REGISTRY_LOADER = False
|
|
58
58
|
|
|
59
|
-
VALID_PLATFORMS = ["douyin", "xiaohongshu", "bilibili", "wechat", "youtube", "generic"]
|
|
60
59
|
VALID_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]
|
|
61
60
|
VALID_PURPOSES = ["opening", "point", "example", "explanation", "highlight", "cta", "ending"]
|
|
62
61
|
|
|
@@ -761,7 +760,6 @@ def _build_carousel_caption_dsl(
|
|
|
761
760
|
carousel_items: list[str],
|
|
762
761
|
caption_lines: list[str],
|
|
763
762
|
duration: int,
|
|
764
|
-
platform: str,
|
|
765
763
|
style: str,
|
|
766
764
|
ratio: str,
|
|
767
765
|
resolution: str,
|
|
@@ -910,7 +908,6 @@ def _build_carousel_caption_dsl(
|
|
|
910
908
|
"topic": topic,
|
|
911
909
|
"headline": effective_headline,
|
|
912
910
|
"subheadline": effective_subheadline,
|
|
913
|
-
"platform": platform,
|
|
914
911
|
"targetDuration": effective_duration,
|
|
915
912
|
"language": LANGUAGE_TO_META.get(output_language, "zh-CN"),
|
|
916
913
|
"outputLanguage": output_language,
|
|
@@ -987,7 +984,6 @@ def _build_custom_payload(template_config: dict | None) -> dict:
|
|
|
987
984
|
|
|
988
985
|
def build_dsl(
|
|
989
986
|
topic: str,
|
|
990
|
-
platform: str,
|
|
991
987
|
duration: int,
|
|
992
988
|
style: str,
|
|
993
989
|
ratio: str,
|
|
@@ -1124,7 +1120,6 @@ def build_dsl(
|
|
|
1124
1120
|
carousel_items=carousel_items or [],
|
|
1125
1121
|
caption_lines=caption_lines or [],
|
|
1126
1122
|
duration=duration,
|
|
1127
|
-
platform=platform,
|
|
1128
1123
|
style=style,
|
|
1129
1124
|
ratio=ratio,
|
|
1130
1125
|
resolution=resolution,
|
|
@@ -1419,7 +1414,6 @@ def build_dsl(
|
|
|
1419
1414
|
"topic": topic,
|
|
1420
1415
|
"headline": effective_headline,
|
|
1421
1416
|
"subheadline": effective_subheadline,
|
|
1422
|
-
"platform": platform,
|
|
1423
1417
|
"targetDuration": duration,
|
|
1424
1418
|
"language": LANGUAGE_TO_META[output_language],
|
|
1425
1419
|
"outputLanguage": output_language,
|
|
@@ -1461,12 +1455,6 @@ Examples:
|
|
|
1461
1455
|
""",
|
|
1462
1456
|
)
|
|
1463
1457
|
parser.add_argument("--topic", help="Video topic (required for generation mode)")
|
|
1464
|
-
parser.add_argument(
|
|
1465
|
-
"--platform",
|
|
1466
|
-
default="generic",
|
|
1467
|
-
choices=VALID_PLATFORMS,
|
|
1468
|
-
help="Target platform (default: generic)",
|
|
1469
|
-
)
|
|
1470
1458
|
parser.add_argument("--duration", type=int, default=30, help="Target duration in seconds (default: 30)")
|
|
1471
1459
|
parser.add_argument("--style", default="", help="Style tag")
|
|
1472
1460
|
parser.add_argument("--ratio", default=None, choices=VALID_RATIOS, help="Aspect ratio. When omitted: if --template-id is set, use the template's supportedAspectRatios[0]; otherwise default 16:9.")
|
|
@@ -1625,7 +1613,6 @@ Examples:
|
|
|
1625
1613
|
|
|
1626
1614
|
print(f"📝 generating video script...", file=sys.stderr)
|
|
1627
1615
|
print(f" topic: {args.topic}", file=sys.stderr)
|
|
1628
|
-
print(f" platform: {args.platform}", file=sys.stderr)
|
|
1629
1616
|
print(f" duration: {args.duration}s", file=sys.stderr)
|
|
1630
1617
|
print(f" ratio: {args.ratio}", file=sys.stderr)
|
|
1631
1618
|
if args.speed is not None:
|
|
@@ -1664,7 +1651,6 @@ Examples:
|
|
|
1664
1651
|
|
|
1665
1652
|
dsl = build_dsl(
|
|
1666
1653
|
topic=args.topic,
|
|
1667
|
-
platform=args.platform,
|
|
1668
1654
|
duration=args.duration,
|
|
1669
1655
|
style=args.style,
|
|
1670
1656
|
ratio=args.ratio,
|
|
@@ -20,18 +20,6 @@
|
|
|
20
20
|
"type": "string",
|
|
21
21
|
"description": "Video topic (required)"
|
|
22
22
|
},
|
|
23
|
-
"platform": {
|
|
24
|
-
"type": "string",
|
|
25
|
-
"enum": [
|
|
26
|
-
"douyin",
|
|
27
|
-
"xiaohongshu",
|
|
28
|
-
"bilibili",
|
|
29
|
-
"wechat",
|
|
30
|
-
"youtube",
|
|
31
|
-
"generic"
|
|
32
|
-
],
|
|
33
|
-
"description": "Target platform"
|
|
34
|
-
},
|
|
35
23
|
"duration": {
|
|
36
24
|
"type": "number",
|
|
37
25
|
"description": "Target duration in seconds"
|
|
@@ -112,7 +100,6 @@
|
|
|
112
100
|
],
|
|
113
101
|
"advanced": [],
|
|
114
102
|
"hidden": [
|
|
115
|
-
"platform",
|
|
116
103
|
"duration",
|
|
117
104
|
"style",
|
|
118
105
|
"ratio",
|
|
@@ -84,6 +84,7 @@ remixmate gen-voice --text "<text-to-synthesize>" --json-output
|
|
|
84
84
|
| `--text` | Text to synthesize (required, except with `--list-voices`) | — |
|
|
85
85
|
| `--voice-id` | Voice id (use `--list-voices` to discover) | `Chinese (Mandarin)_Male_Announcer` |
|
|
86
86
|
| `--speed` | Speech rate, 0.5–2.0 | `1.0` |
|
|
87
|
+
| `--language-boost` | CLI-only. Language hint for MiniMax (`auto` / `Chinese` / `English` / …). Mixed-script narration ("只有四个字:Agent Loop") mispronounces the Latin terms without one, so the service already applies its own default — pass this only to pin a specific language. Usually set from the DSL (`global.narration.languageBoost`, scene-level overrides) rather than by hand. Not exposed to the agent — see the handler comment. | service default |
|
|
87
88
|
| `--list-voices` | List available voices and exit | — |
|
|
88
89
|
| `--local` | CLI-only. With `--list-voices`: print the 2-entry hardcoded fallback list instead of querying the service (offline dev). Not exposed to the agent — see the handler comment. | off |
|
|
89
90
|
| `--json-output` | Emit JSON (url, audio_length_ms, subtitles) | off |
|
|
@@ -175,6 +175,23 @@ def _narration_speed(narration: dict) -> Optional[float]:
|
|
|
175
175
|
return float(raw) if raw > 0 else None
|
|
176
176
|
|
|
177
177
|
|
|
178
|
+
def _narration_language_boost(narration: dict) -> Optional[str]:
|
|
179
|
+
"""Read a narration block's language hint, or None when unset.
|
|
180
|
+
|
|
181
|
+
Same tolerance as _narration_speed: a malformed value must not take down an
|
|
182
|
+
otherwise fine render. The service applies its own default when we send
|
|
183
|
+
nothing, so "unset" is always a safe outcome — this flag exists to pin a
|
|
184
|
+
language when auto-detection is not good enough (e.g. an English-only
|
|
185
|
+
template whose narration still carries a Chinese brand name).
|
|
186
|
+
"""
|
|
187
|
+
if not isinstance(narration, dict):
|
|
188
|
+
return None
|
|
189
|
+
raw = narration.get("languageBoost")
|
|
190
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
191
|
+
return None
|
|
192
|
+
return raw.strip()
|
|
193
|
+
|
|
194
|
+
|
|
178
195
|
def extract_narration_lines(narration: dict) -> Optional[tuple[list[str], int, list[Optional[float]]]]:
|
|
179
196
|
"""If narration uses the structured {intro, items, outro} form, return
|
|
180
197
|
(lines, intro_line_count, at_sec_list). `at_sec_list` is parallel to
|
|
@@ -650,6 +667,7 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
|
|
|
650
667
|
dsl_assets_by_id = {a["assetId"]: a for a in dsl.get("assets", [])}
|
|
651
668
|
global_narration = (dsl.get("global") or {}).get("narration") or {}
|
|
652
669
|
global_speed = _narration_speed(global_narration)
|
|
670
|
+
global_language_boost = _narration_language_boost(global_narration)
|
|
653
671
|
for scene in dsl.get("scenes", []):
|
|
654
672
|
narration = (scene.get("audio") or {}).get("narration") or {}
|
|
655
673
|
ref = narration.get("assetRef")
|
|
@@ -666,6 +684,13 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
|
|
|
666
684
|
speed = global_speed
|
|
667
685
|
if speed is not None:
|
|
668
686
|
payload["speed"] = speed
|
|
687
|
+
# 语种提示走与语速完全相同的路线(场景覆盖全局,都不写就交给服务端默认):
|
|
688
|
+
# resolve_asset_audio 只看得见 asset,看不见 scene。
|
|
689
|
+
language_boost = _narration_language_boost(narration)
|
|
690
|
+
if language_boost is None:
|
|
691
|
+
language_boost = global_language_boost
|
|
692
|
+
if language_boost is not None:
|
|
693
|
+
payload["languageBoost"] = language_boost
|
|
669
694
|
extracted = extract_narration_lines(narration)
|
|
670
695
|
if extracted:
|
|
671
696
|
lines, intro_lines, _ = extracted
|
|
@@ -905,6 +930,10 @@ def resolve_asset_audio(asset: dict, private_token: str, timeout: int) -> dict:
|
|
|
905
930
|
speed = _narration_speed(payload)
|
|
906
931
|
if speed is not None:
|
|
907
932
|
cmd.extend(["--speed", str(speed)])
|
|
933
|
+
# 同 speed:不带就**不传**这个 flag,默认值归服务端一处持有。
|
|
934
|
+
language_boost = _narration_language_boost(payload)
|
|
935
|
+
if language_boost is not None:
|
|
936
|
+
cmd.extend(["--language-boost", language_boost])
|
|
908
937
|
if private_token:
|
|
909
938
|
cmd.extend(["--priv-token", private_token])
|
|
910
939
|
|
|
@@ -193,6 +193,7 @@ Rules:
|
|
|
193
193
|
- Do not write `templateData.highlightMap`; `render_video.py` builds it automatically from the per-line timestamps that TTS returns.
|
|
194
194
|
- If the author explicitly provides a `highlightMap`, the system keeps it as-is.
|
|
195
195
|
- The legacy flat `narration.text` form still works but the author then has to keep `highlightMap` in sync with the subtitle segmentation (error-prone, not recommended).
|
|
196
|
+
- `narration.languageBoost` (optional, also accepted at `global.narration`) pins the TTS language hint (`auto` / `Chinese` / `English` / …). Leave it unset unless auto-detection gets it wrong — the service applies its own default, and a scene-level value overrides the global one, exactly like `speed`.
|
|
196
197
|
|
|
197
198
|
## Image sources (picture-book-en and every other template)
|
|
198
199
|
|
|
@@ -25,7 +25,6 @@ import re
|
|
|
25
25
|
from typing import Iterable, Optional
|
|
26
26
|
|
|
27
27
|
VALID_VERSIONS = {"v1alpha1"}
|
|
28
|
-
VALID_PLATFORMS = {"douyin", "xiaohongshu", "bilibili", "wechat", "youtube", "generic"}
|
|
29
28
|
VALID_RATIOS = {"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"}
|
|
30
29
|
VALID_RESOLUTIONS = {"480p", "720p", "1080p", "4k"}
|
|
31
30
|
VALID_PURPOSES = {"opening", "intro", "point", "example", "explanation", "transition", "highlight", "cta", "ending"}
|
|
@@ -175,19 +174,6 @@ def _check_meta_presence(dsl: dict) -> list[ValidationError]:
|
|
|
175
174
|
return errors
|
|
176
175
|
|
|
177
176
|
|
|
178
|
-
def _check_meta_enums(dsl: dict) -> list[ValidationError]:
|
|
179
|
-
"""完整版额外:meta.platform 必须是已知枚举。"""
|
|
180
|
-
errors: list[ValidationError] = []
|
|
181
|
-
meta = dsl.get("meta")
|
|
182
|
-
if isinstance(meta, dict):
|
|
183
|
-
platform = meta.get("platform", "generic")
|
|
184
|
-
if platform not in VALID_PLATFORMS:
|
|
185
|
-
errors.append(ValidationError(
|
|
186
|
-
"meta.platform", f"must be one of {sorted(VALID_PLATFORMS)}",
|
|
187
|
-
))
|
|
188
|
-
return errors
|
|
189
|
-
|
|
190
|
-
|
|
191
177
|
def _check_global_presence(dsl: dict) -> list[ValidationError]:
|
|
192
178
|
"""gen-script 历史:global 必须存在(只检查存在性)。"""
|
|
193
179
|
if not isinstance(dsl.get("global"), dict):
|
|
@@ -358,6 +344,51 @@ def _check_assetbindings_deprecation(dsl: dict) -> list[ValidationError]:
|
|
|
358
344
|
return errors
|
|
359
345
|
|
|
360
346
|
|
|
347
|
+
def _check_narration_items_text_coexist(dsl: dict) -> list[ValidationError]:
|
|
348
|
+
"""分行旁白与整段 ``narration.text`` 并存 → warning(text 会被整个忽略)。
|
|
349
|
+
|
|
350
|
+
渲染侧是 **items 优先、text 完全忽略**:``extract_narration_lines`` 一旦拿到
|
|
351
|
+
items,TTS 文本就是 ``"\\n".join(items)``,字幕也改走 ``split_subtitle_from_lines``,
|
|
352
|
+
``narration.text`` 既不喂 TTS 也不切字幕。
|
|
353
|
+
|
|
354
|
+
为什么值得报:gen_script 的骨架**总是**写一条 ``narration.text``,agent 之后为
|
|
355
|
+
逐行高亮补 items 时不会删它,于是两者并存几乎是这类模板的默认结局。留着的那
|
|
356
|
+
一份是死数据 —— 它曾经在旁白编辑面板里被当成"可以单独改、单独重新生成"的一
|
|
357
|
+
行摆出来(实测一个场景因此显示三行,其中一行是另外两行拼起来的),改它不影响
|
|
358
|
+
成片,生成它照样扣积分。
|
|
359
|
+
|
|
360
|
+
只报 warning 不拒绝:两者并存不会让画面出错,它只是让人误以为 text 还有用。
|
|
361
|
+
"""
|
|
362
|
+
errors: list[ValidationError] = []
|
|
363
|
+
for idx, scene in enumerate(dsl.get("scenes", []) or []):
|
|
364
|
+
sid = scene.get("id", f"scenes[{idx}]")
|
|
365
|
+
narration = (scene.get("audio") or {}).get("narration") or {}
|
|
366
|
+
if not isinstance(narration, dict):
|
|
367
|
+
continue
|
|
368
|
+
items = narration.get("items")
|
|
369
|
+
has_items = isinstance(items, list) and any(
|
|
370
|
+
(isinstance(x, str) and x.strip()) or isinstance(x, dict) for x in items
|
|
371
|
+
)
|
|
372
|
+
has_intro_outro = any(
|
|
373
|
+
isinstance(narration.get(k), str) and narration[k].strip()
|
|
374
|
+
for k in ("intro", "outro")
|
|
375
|
+
)
|
|
376
|
+
text = narration.get("text")
|
|
377
|
+
if not (has_items or has_intro_outro):
|
|
378
|
+
continue
|
|
379
|
+
if not (isinstance(text, str) and text.strip()):
|
|
380
|
+
continue
|
|
381
|
+
errors.append(ValidationError(
|
|
382
|
+
f"scenes[{idx}].audio.narration.text",
|
|
383
|
+
f"scene '{sid}': this scene uses per-line narration (intro/items/outro), so "
|
|
384
|
+
"narration.text is ignored end to end — it is neither sent to TTS nor split "
|
|
385
|
+
"into subtitles. Leaving it here makes it look like editable copy that no "
|
|
386
|
+
"longer affects the video. Drop the text field and keep the lines.",
|
|
387
|
+
severity="warning",
|
|
388
|
+
))
|
|
389
|
+
return errors
|
|
390
|
+
|
|
391
|
+
|
|
361
392
|
def _check_narration_items_count(dsl: dict) -> list[ValidationError]:
|
|
362
393
|
"""render-video / 完整版:结构化 narration items 数与 templateData 的对应数组等长。
|
|
363
394
|
|
|
@@ -978,6 +1009,7 @@ def validate_integrity(dsl: dict) -> list[ValidationError]:
|
|
|
978
1009
|
errors += _check_asset_duplicates(dsl)
|
|
979
1010
|
errors += _check_scene_asset_refs(dsl)
|
|
980
1011
|
errors += _check_narration_items_count(dsl)
|
|
1012
|
+
errors += _check_narration_items_text_coexist(dsl)
|
|
981
1013
|
# 新增(非历史规则):多版式模板必须逐场景选定 slideId。放进 integrity 集合是因为
|
|
982
1014
|
# render-video 与 prepare_video_assets 都走这条 —— 而"没选版式"正是要在烧掉渲染
|
|
983
1015
|
# 积分之前拦住的东西。单版式模板不受影响,见 _check_slide_id_chosen。
|
|
@@ -1001,7 +1033,6 @@ def validate_dsl(dsl: dict) -> list[ValidationError]:
|
|
|
1001
1033
|
errors: list[ValidationError] = []
|
|
1002
1034
|
errors += _check_version(dsl)
|
|
1003
1035
|
errors += _check_meta_presence(dsl)
|
|
1004
|
-
errors += _check_meta_enums(dsl)
|
|
1005
1036
|
errors += _check_global_presence(dsl)
|
|
1006
1037
|
errors += _check_global_enums(dsl)
|
|
1007
1038
|
errors += _check_scenes_min(dsl)
|
|
@@ -1014,6 +1045,7 @@ def validate_dsl(dsl: dict) -> list[ValidationError]:
|
|
|
1014
1045
|
errors += _check_scene_asset_refs(dsl)
|
|
1015
1046
|
errors += _check_assetbindings_deprecation(dsl)
|
|
1016
1047
|
errors += _check_narration_items_count(dsl)
|
|
1048
|
+
errors += _check_narration_items_text_coexist(dsl)
|
|
1017
1049
|
errors += _check_narration_language_strict(dsl)
|
|
1018
1050
|
errors += _check_slide_id_chosen(dsl)
|
|
1019
1051
|
errors += _check_slide_payload_fields(dsl)
|
|
@@ -20,10 +20,35 @@ from typing import Optional
|
|
|
20
20
|
CHARS_PER_SECOND = 5.0
|
|
21
21
|
|
|
22
22
|
_PRIMARY_BREAK = re.compile(r"(?<=[。!?;\n])")
|
|
23
|
-
|
|
23
|
+
# 次级断点**不含顿号 `、`**。顿号是并列项之间的分隔,在它上面下刀就是把一串并列
|
|
24
|
+
# 拦腰切开(`调研、策划、` / `开发、分析,…`)。这一层只数字符、分不出标点的语义
|
|
25
|
+
# 强度,所以干脆不碰它 —— 真需要再切时交给渲染端,那边是分级的(句末 > 冒号 >
|
|
26
|
+
# 逗号 > 顿号 > 空格),只有前面几级都切不动才会降到顿号。
|
|
27
|
+
_SECONDARY_BREAK = re.compile(r"(?<=[,,::])")
|
|
24
28
|
_PRIMARY_TAIL = re.compile(r"[。!?;\n]\s*$")
|
|
25
29
|
|
|
26
|
-
|
|
30
|
+
# ─── 这两个上限管的是「喂给 TTS 的行」,不再是「一屏字幕」 ────────────────
|
|
31
|
+
#
|
|
32
|
+
# 曾经是 16 / 6,那是渲染端还不会分屏时的产物:谁都不会再切,所以必须在这里
|
|
33
|
+
# 就把旁白预切成一屏放得下的大小。现在渲染端(core 的 `chunkSubtitleSegments`)
|
|
34
|
+
# 会按**实际字号与实际可用宽度**算容量、按**标点语义强度分级**切分,这件事它
|
|
35
|
+
# 做得比这里好得多:
|
|
36
|
+
# · 这里只能数字符数,数不出 `HNSW` 比 4 个中文字窄一半;
|
|
37
|
+
# · 这里的 `_SECONDARY_BREAK` 是平的,冒号和顿号同权,会把并列项拦腰切开;
|
|
38
|
+
# · 这里一个全局 16 要同时服务 16:9(一行放 44 字)和 9:16(一行放 22 字)。
|
|
39
|
+
#
|
|
40
|
+
# 于是 16 的唯一效果变成了**抢在渲染端之前把句子剁碎**。2026-09-21 线上成片实测:
|
|
41
|
+
# 121.5s 里 57 屏、中位 2.10s 一屏、33% 短于 1.5s,「AI Agent」独占一屏 —— 全都是
|
|
42
|
+
# 这个 16 的产物,渲染端的分屏阈值(20)一次都没被触发。
|
|
43
|
+
#
|
|
44
|
+
# 现在这里只负责一件事:**在真实语音停顿处断行**,让 Minimax 按停顿返回时间戳。
|
|
45
|
+
# 句末(`_PRIMARY_BREAK`)永远断;逗号那一级只在句子长到一行 TTS 太长时才用。
|
|
46
|
+
# 30 是「一个中文长句」的量级 —— 越过它说明这句本来就该有个呼吸点。
|
|
47
|
+
#
|
|
48
|
+
# ⚠️ 别再把它当字幕宽度调:想改一屏显示多少字,改 core 的
|
|
49
|
+
# `DEFAULT_SPLIT_MAX_UNITS` / `LINE_FILL_LIMIT`,那边才看得见字号和画幅。
|
|
50
|
+
MAX_SEGMENT_CHARS = 30
|
|
51
|
+
# 合并下限保持 6:TTS 行太短会让合成的语气断掉(和字幕闪烁是两回事)。
|
|
27
52
|
MIN_SEGMENT_CHARS = 6
|
|
28
53
|
|
|
29
54
|
# 字幕显示时需要去掉的末尾标点(中英文句末 + 逗号等)
|
|
@@ -50,21 +75,70 @@ def resolve_dimensions(ratio: str, resolution: str) -> tuple[int, int]:
|
|
|
50
75
|
return dims.get(resolution, dims.get("1080p", (1920, 1080)))
|
|
51
76
|
|
|
52
77
|
|
|
78
|
+
def _is_latin_word_char(ch: str) -> bool:
|
|
79
|
+
return ch.isascii() and ch.isalnum()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _breakable(prev_token: str, next_token: str) -> bool:
|
|
83
|
+
"""两个 token 之间断开是否安全 —— 拉丁↔拉丁即同一词组内部,不安全。
|
|
84
|
+
|
|
85
|
+
拆与并共用这一个判据:断的时候躲开的边界,并的时候就得把空格补回去。
|
|
86
|
+
"""
|
|
87
|
+
if not prev_token or not next_token:
|
|
88
|
+
return True
|
|
89
|
+
return not (_is_latin_word_char(prev_token[-1]) and _is_latin_word_char(next_token[0]))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _concat_segments(left: str, right: str) -> str:
|
|
93
|
+
"""拼接两个字幕段,必要时补回被切分吃掉的那个空格。
|
|
94
|
+
|
|
95
|
+
``_split_at_spaces`` 在空格处断开,空格本身不属于任何一段;直接 ``+`` 拼回去
|
|
96
|
+
会把 ``capabilities and`` + ``more`` 粘成 ``capabilities andmore`` —— 这串同样会
|
|
97
|
+
原样进 TTS,读出来就是一个不存在的词。中文两侧不补空格(中文本来就不分词)。
|
|
98
|
+
"""
|
|
99
|
+
if _breakable(left, right):
|
|
100
|
+
return left + right
|
|
101
|
+
return left + " " + right
|
|
102
|
+
|
|
103
|
+
|
|
53
104
|
def _split_at_spaces(text: str, max_len: int) -> list[str]:
|
|
54
|
-
"""Split long text at space boundaries
|
|
105
|
+
"""Split long text at space boundaries, keeping multi-word Latin terms whole.
|
|
106
|
+
|
|
107
|
+
These boundaries are not only subtitle cuts: ``segment_narration`` joins the
|
|
108
|
+
result with ``\\n`` and that is what goes to TTS, where each line is a hard
|
|
109
|
+
segment boundary. Cutting between two Latin words therefore breaks the term
|
|
110
|
+
apart in the *audio* — Minimax loses both the liaison and the context that
|
|
111
|
+
told it this was English at all.
|
|
112
|
+
|
|
113
|
+
实测(2026-09-20): ``最近刷爆科技圈的 AI Agent,`` 是 18 字、超过 16 的上限,而这
|
|
114
|
+
段里仅有的两个空格都在 "AI Agent" 中间,于是旧实现只能从词组中间下刀,切成
|
|
115
|
+
``最近刷爆科技圈的 AI`` + ``Agent,`` —— 合成出来 "AI" 收在上一段末尾、"Agent"
|
|
116
|
+
孤零零起一段,那个词就读不准了。同句末尾的 "Agent Loop。" 因为没超上限反而完好,
|
|
117
|
+
用户听到的正是"中间那个 Agent 不对"。
|
|
118
|
+
|
|
119
|
+
所以断点优先选**中文↔拉丁**边界(回退到词组之前),找不到再退回"就近断开"。
|
|
120
|
+
纯英文旁白里根本不存在中文↔拉丁边界,每个断点都会走回退分支,行为与旧实现逐字
|
|
121
|
+
相同 —— picture-book-en 这类全英文模板不受影响。
|
|
122
|
+
"""
|
|
55
123
|
if len(text) <= max_len:
|
|
56
124
|
return [text]
|
|
125
|
+
|
|
57
126
|
parts: list[str] = []
|
|
58
|
-
|
|
127
|
+
buf: list[str] = [] # 当前段已收的 token
|
|
59
128
|
for word in text.split(" "):
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
129
|
+
if buf and len(" ".join(buf + [word])) > max_len:
|
|
130
|
+
# 从最靠后的边界往回找第一个安全断点;整段都没有就按旧行为就近断开。
|
|
131
|
+
cut = len(buf)
|
|
132
|
+
for i in range(len(buf), 0, -1):
|
|
133
|
+
following = word if i == len(buf) else buf[i]
|
|
134
|
+
if _breakable(buf[i - 1], following):
|
|
135
|
+
cut = i
|
|
136
|
+
break
|
|
137
|
+
parts.append(" ".join(buf[:cut]))
|
|
138
|
+
buf = buf[cut:]
|
|
139
|
+
buf.append(word)
|
|
140
|
+
if buf:
|
|
141
|
+
parts.append(" ".join(buf))
|
|
68
142
|
return parts
|
|
69
143
|
|
|
70
144
|
|
|
@@ -83,17 +157,17 @@ def _merge_short(segments: list[str], min_len: int) -> list[str]:
|
|
|
83
157
|
prev_is_sentence_end = bool(_PRIMARY_TAIL.search(merged[-1]))
|
|
84
158
|
if len(merged[-1]) < min_len:
|
|
85
159
|
# Previous fragment is short — always merge forward into it
|
|
86
|
-
merged[-1]
|
|
160
|
+
merged[-1] = _concat_segments(merged[-1], seg)
|
|
87
161
|
elif len(seg) < min_len and not prev_is_sentence_end:
|
|
88
162
|
# Current fragment is short and prev is NOT a sentence end — merge backward
|
|
89
|
-
merged[-1]
|
|
163
|
+
merged[-1] = _concat_segments(merged[-1], seg)
|
|
90
164
|
else:
|
|
91
165
|
# Either both are long enough, or seg is short but prev ends a sentence
|
|
92
166
|
merged.append(seg)
|
|
93
167
|
# Handle trailing short segment (avoid merging across sentence boundary)
|
|
94
168
|
if len(merged) > 1 and len(merged[-1]) < min_len:
|
|
95
169
|
if not _PRIMARY_TAIL.search(merged[-2]):
|
|
96
|
-
merged[-2]
|
|
170
|
+
merged[-2] = _concat_segments(merged[-2], merged.pop())
|
|
97
171
|
return merged
|
|
98
172
|
|
|
99
173
|
|
|
@@ -108,10 +182,15 @@ def _strip_trailing_punct(text: str) -> str:
|
|
|
108
182
|
|
|
109
183
|
|
|
110
184
|
def segment_narration(text: str) -> list[str]:
|
|
111
|
-
"""Split narration
|
|
185
|
+
"""Split narration into **TTS lines**, cutting at speech pauses.
|
|
112
186
|
|
|
113
187
|
Use before TTS: ``"\\n".join(segment_narration(text))`` sends to Minimax
|
|
114
|
-
so it returns per-
|
|
188
|
+
so it returns per-line timestamps.
|
|
189
|
+
|
|
190
|
+
⚠️ 这里切的**不是字幕屏**。字幕分屏归渲染端(core 的
|
|
191
|
+
``chunkSubtitleSegments``),它知道字号、画幅和标点的语义强度;这里只知道
|
|
192
|
+
字符数。这一层切得越细,留给渲染端的余地越小 —— 见 ``MAX_SEGMENT_CHARS``
|
|
193
|
+
的注释。
|
|
115
194
|
"""
|
|
116
195
|
if not text:
|
|
117
196
|
return []
|
|
@@ -44,11 +44,6 @@
|
|
|
44
44
|
"properties": {
|
|
45
45
|
"title": { "type": "string" },
|
|
46
46
|
"topic": { "type": "string" },
|
|
47
|
-
"platform": {
|
|
48
|
-
"type": "string",
|
|
49
|
-
"enum": ["douyin", "xiaohongshu", "bilibili", "wechat", "youtube", "generic"],
|
|
50
|
-
"default": "generic"
|
|
51
|
-
},
|
|
52
47
|
"targetDuration": {
|
|
53
48
|
"type": "number",
|
|
54
49
|
"description": "目标总时长(秒)"
|
|
@@ -95,7 +90,8 @@
|
|
|
95
90
|
"properties": {
|
|
96
91
|
"voiceId": { "type": "string", "description": "Minimax 音色 ID" },
|
|
97
92
|
"voiceName": { "type": "string" },
|
|
98
|
-
"speed": { "type": "number", "default": 1.0 }
|
|
93
|
+
"speed": { "type": "number", "default": 1.0 },
|
|
94
|
+
"languageBoost": { "type": "string", "description": "TTS 语种提示(auto / Chinese / English …)。中英混排的旁白不给提示时,英文词容易被按中文音系读出来;留空由服务端取默认值。" }
|
|
99
95
|
}
|
|
100
96
|
},
|
|
101
97
|
"bgm": {
|
|
@@ -250,7 +246,8 @@
|
|
|
250
246
|
"description": "每行一个卡片的叙述句。长度必须等于 templateData 里对应数组(concepts/pillars/eras/items)的长度"
|
|
251
247
|
},
|
|
252
248
|
"outro": { "type": "string", "description": "可选:收尾句(此期间无卡片高亮)" },
|
|
253
|
-
"assetRef": { "type": "string", "description": "旁白音频 assetId" }
|
|
249
|
+
"assetRef": { "type": "string", "description": "旁白音频 assetId" },
|
|
250
|
+
"languageBoost": { "type": "string", "description": "TTS 语种提示(auto / Chinese / English …)。中英混排的旁白不给提示时,英文词容易被按中文音系读出来;覆盖 global.narration 的同名字段;留空则跟随全局/服务端默认。" }
|
|
254
251
|
}
|
|
255
252
|
},
|
|
256
253
|
"bgm": {
|