@remixmate/cli 0.9.36 → 0.9.37
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 +2 -5
- 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 +62 -13
- 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.37",
|
|
4
|
+
"generatedAt": "2026-09-21T03:49:51.210Z",
|
|
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.37",
|
|
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` |
|
|
@@ -474,7 +472,6 @@ prefix and version — not a short alias:
|
|
|
474
472
|
|
|
475
473
|
- **Schema validation failed**: check the DSL JSON shape and required fields against the schema.
|
|
476
474
|
- **Scene duration mismatch**: adjust the narration length or the scene duration.
|
|
477
|
-
- **Invalid platform**: look at the supported-platform list.
|
|
478
475
|
- **`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
476
|
- **`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
477
|
- **`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)
|
|
@@ -50,21 +50,70 @@ def resolve_dimensions(ratio: str, resolution: str) -> tuple[int, int]:
|
|
|
50
50
|
return dims.get(resolution, dims.get("1080p", (1920, 1080)))
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def _is_latin_word_char(ch: str) -> bool:
|
|
54
|
+
return ch.isascii() and ch.isalnum()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _breakable(prev_token: str, next_token: str) -> bool:
|
|
58
|
+
"""两个 token 之间断开是否安全 —— 拉丁↔拉丁即同一词组内部,不安全。
|
|
59
|
+
|
|
60
|
+
拆与并共用这一个判据:断的时候躲开的边界,并的时候就得把空格补回去。
|
|
61
|
+
"""
|
|
62
|
+
if not prev_token or not next_token:
|
|
63
|
+
return True
|
|
64
|
+
return not (_is_latin_word_char(prev_token[-1]) and _is_latin_word_char(next_token[0]))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _concat_segments(left: str, right: str) -> str:
|
|
68
|
+
"""拼接两个字幕段,必要时补回被切分吃掉的那个空格。
|
|
69
|
+
|
|
70
|
+
``_split_at_spaces`` 在空格处断开,空格本身不属于任何一段;直接 ``+`` 拼回去
|
|
71
|
+
会把 ``capabilities and`` + ``more`` 粘成 ``capabilities andmore`` —— 这串同样会
|
|
72
|
+
原样进 TTS,读出来就是一个不存在的词。中文两侧不补空格(中文本来就不分词)。
|
|
73
|
+
"""
|
|
74
|
+
if _breakable(left, right):
|
|
75
|
+
return left + right
|
|
76
|
+
return left + " " + right
|
|
77
|
+
|
|
78
|
+
|
|
53
79
|
def _split_at_spaces(text: str, max_len: int) -> list[str]:
|
|
54
|
-
"""Split long text at space boundaries
|
|
80
|
+
"""Split long text at space boundaries, keeping multi-word Latin terms whole.
|
|
81
|
+
|
|
82
|
+
These boundaries are not only subtitle cuts: ``segment_narration`` joins the
|
|
83
|
+
result with ``\\n`` and that is what goes to TTS, where each line is a hard
|
|
84
|
+
segment boundary. Cutting between two Latin words therefore breaks the term
|
|
85
|
+
apart in the *audio* — Minimax loses both the liaison and the context that
|
|
86
|
+
told it this was English at all.
|
|
87
|
+
|
|
88
|
+
实测(2026-09-20): ``最近刷爆科技圈的 AI Agent,`` 是 18 字、超过 16 的上限,而这
|
|
89
|
+
段里仅有的两个空格都在 "AI Agent" 中间,于是旧实现只能从词组中间下刀,切成
|
|
90
|
+
``最近刷爆科技圈的 AI`` + ``Agent,`` —— 合成出来 "AI" 收在上一段末尾、"Agent"
|
|
91
|
+
孤零零起一段,那个词就读不准了。同句末尾的 "Agent Loop。" 因为没超上限反而完好,
|
|
92
|
+
用户听到的正是"中间那个 Agent 不对"。
|
|
93
|
+
|
|
94
|
+
所以断点优先选**中文↔拉丁**边界(回退到词组之前),找不到再退回"就近断开"。
|
|
95
|
+
纯英文旁白里根本不存在中文↔拉丁边界,每个断点都会走回退分支,行为与旧实现逐字
|
|
96
|
+
相同 —— picture-book-en 这类全英文模板不受影响。
|
|
97
|
+
"""
|
|
55
98
|
if len(text) <= max_len:
|
|
56
99
|
return [text]
|
|
100
|
+
|
|
57
101
|
parts: list[str] = []
|
|
58
|
-
|
|
102
|
+
buf: list[str] = [] # 当前段已收的 token
|
|
59
103
|
for word in text.split(" "):
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
104
|
+
if buf and len(" ".join(buf + [word])) > max_len:
|
|
105
|
+
# 从最靠后的边界往回找第一个安全断点;整段都没有就按旧行为就近断开。
|
|
106
|
+
cut = len(buf)
|
|
107
|
+
for i in range(len(buf), 0, -1):
|
|
108
|
+
following = word if i == len(buf) else buf[i]
|
|
109
|
+
if _breakable(buf[i - 1], following):
|
|
110
|
+
cut = i
|
|
111
|
+
break
|
|
112
|
+
parts.append(" ".join(buf[:cut]))
|
|
113
|
+
buf = buf[cut:]
|
|
114
|
+
buf.append(word)
|
|
115
|
+
if buf:
|
|
116
|
+
parts.append(" ".join(buf))
|
|
68
117
|
return parts
|
|
69
118
|
|
|
70
119
|
|
|
@@ -83,17 +132,17 @@ def _merge_short(segments: list[str], min_len: int) -> list[str]:
|
|
|
83
132
|
prev_is_sentence_end = bool(_PRIMARY_TAIL.search(merged[-1]))
|
|
84
133
|
if len(merged[-1]) < min_len:
|
|
85
134
|
# Previous fragment is short — always merge forward into it
|
|
86
|
-
merged[-1]
|
|
135
|
+
merged[-1] = _concat_segments(merged[-1], seg)
|
|
87
136
|
elif len(seg) < min_len and not prev_is_sentence_end:
|
|
88
137
|
# Current fragment is short and prev is NOT a sentence end — merge backward
|
|
89
|
-
merged[-1]
|
|
138
|
+
merged[-1] = _concat_segments(merged[-1], seg)
|
|
90
139
|
else:
|
|
91
140
|
# Either both are long enough, or seg is short but prev ends a sentence
|
|
92
141
|
merged.append(seg)
|
|
93
142
|
# Handle trailing short segment (avoid merging across sentence boundary)
|
|
94
143
|
if len(merged) > 1 and len(merged[-1]) < min_len:
|
|
95
144
|
if not _PRIMARY_TAIL.search(merged[-2]):
|
|
96
|
-
merged[-2]
|
|
145
|
+
merged[-2] = _concat_segments(merged[-2], merged.pop())
|
|
97
146
|
return merged
|
|
98
147
|
|
|
99
148
|
|
|
@@ -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": {
|