@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.
@@ -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', { provider: voice.provider, text, voiceSetting: { voiceId, speed }, outputFormat: 'url' }, { timeoutMs: 120_000 });
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)}`);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.9.36",
4
- "generatedAt": "2026-09-20T05:40:23.517Z",
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.36",
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
- - Platform: douyin | Aspect ratio: 9:16 | Estimated duration: 45s
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, target platform, duration, style, audience, etc.
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, platform,
7
- duration, and other constraints.
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 to protect English words."""
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
- current = ""
102
+ buf: list[str] = [] # 当前段已收的 token
59
103
  for word in text.split(" "):
60
- candidate = f"{current} {word}" if current else word
61
- if len(candidate) > max_len and current:
62
- parts.append(current)
63
- current = word
64
- else:
65
- current = candidate
66
- if current:
67
- parts.append(current)
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] += seg
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] += seg
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] += merged.pop()
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": {