@remixmate/cli 0.9.47 → 0.9.49

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.9.47",
4
- "generatedAt": "2026-09-24T01:36:07.207Z",
3
+ "version": "0.9.49",
4
+ "generatedAt": "2026-09-26T14:13:13.805Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "doc-parse",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.47",
3
+ "version": "0.9.49",
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",
@@ -41,7 +41,8 @@
41
41
  "test:prepare-contracts": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-prepare-contracts.py",
42
42
  "test:image-sequence": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-image-sequence.py",
43
43
  "test:ranking-count": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-ranking-count.py",
44
- "test:image-prompt-compose": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-image-prompt-compose.py"
44
+ "test:image-prompt-compose": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-image-prompt-compose.py",
45
+ "test:picture-book-contract": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-picture-book-contract.py"
45
46
  },
46
47
  "keywords": [
47
48
  "claude-code",
@@ -628,9 +628,58 @@ def _resolve_contract(template_config: dict | None) -> dict:
628
628
  "primary_visual_type": primary_visual_type,
629
629
  "default_layout": caps.get("defaultLayout"),
630
630
  "authored_scenes": caps.get("authoredScenes") is True,
631
+ # 画面文字契约(dsl_validator 按 error 执行);骨架据此为每个必填 role 留占位
632
+ "text_layer_rules": caps.get("textLayerRules") if isinstance(caps.get("textLayerRules"), dict) else None,
633
+ # arc 叙事弧的收尾场景用途(缺省 cta)。绘本收尾是 ending(寓意页),不是「关注我们」
634
+ "arc_closing_purpose": caps.get("arcClosingPurpose") if isinstance(caps.get("arcClosingPurpose"), str) else None,
635
+ # 骨架里 transitions.default 的取值;缺省 fade(历史行为)
636
+ "default_transition": caps.get("defaultTransition") if isinstance(caps.get("defaultTransition"), str) else None,
631
637
  }
632
638
 
633
639
 
640
+ def _slot_reads_visual(template_config: dict | None, purpose: str) -> bool:
641
+ """这个用途的版式会不会显示背景素材(slotMapping 里有没有读 visuals.background.assetRef)。
642
+
643
+ 不显示的版式(如绘本的 ending 寓意页)不该规划图片:素材照样会被生成、扣积分,
644
+ 却永远不上屏。模板没声明 slotMapping 时按「会显示」处理,保持历史行为。
645
+ """
646
+ mapping = (template_config or {}).get("slotMapping")
647
+ if not isinstance(mapping, dict) or not mapping:
648
+ return True
649
+ slot = mapping.get(purpose) or mapping.get("default") or {}
650
+ seen: set = set()
651
+ while isinstance(slot, dict) and "$ref" in slot and slot["$ref"] not in seen:
652
+ seen.add(slot["$ref"])
653
+ slot = mapping.get(slot["$ref"]) or {}
654
+ extractors = (slot or {}).get("propExtractors") if isinstance(slot, dict) else None
655
+ if not isinstance(extractors, dict):
656
+ return True
657
+ return any(
658
+ isinstance(ex, dict) and str(ex.get("from", "")).endswith("visuals.background.assetRef")
659
+ for ex in extractors.values()
660
+ )
661
+
662
+
663
+ def _placeholder_text_layers(rules: dict) -> list[dict]:
664
+ """模板声明了 capabilities.textLayerRules 时,骨架的文字层 = 每个必填 role 一条占位。
665
+
666
+ 通用骨架给的是「内容要点 N」「关注我们」这类文案,对有文字契约的模板全是错的,
667
+ 而且留着一个没声明的 role(badge)只会让模型以为那里要填东西。占位带骨架前缀,
668
+ dsl_validator 会拒绝原样提交;带上语言要求,模型不用再去 llmHint 里找。
669
+ """
670
+ language = rules.get("language") if isinstance(rules.get("language"), dict) else {}
671
+ hints = {"en": "English text for this page", "zh": "本页中文译文"}
672
+ layers = []
673
+ for role in rules.get("required") or []:
674
+ if not isinstance(role, str):
675
+ continue
676
+ lang = language.get(role)
677
+ prefix = SKELETON_PREFIXES[1] if lang == "en" else SKELETON_PREFIXES[0]
678
+ hint = hints.get(lang, role)
679
+ layers.append({"role": role, "content": f"{prefix} {role}: {hint}"})
680
+ return layers
681
+
682
+
634
683
  def _normalize_linebreaks(value: str | None) -> str:
635
684
  """把画面文字里的**字面量** ``\\n`` 折成真换行。
636
685
 
@@ -794,11 +843,15 @@ def _plan_contract_scenes(
794
843
  这类模板由此能产出真正的单场景,而不再被强行补到 3 段。``--scenes`` 只对 arc 生效。
795
844
  """
796
845
  if contract["scene_strategy"] == "arc":
797
- return plan_scenes(
846
+ plans = plan_scenes(
798
847
  topic, duration, scene_count, False,
799
848
  language=language,
800
849
  max_scenes=contract.get("max_scenes", DEFAULT_MAX_SCENES),
801
850
  )
851
+ closing = contract.get("arc_closing_purpose")
852
+ if closing and plans and plans[-1]["purpose"] == "cta":
853
+ plans[-1] = {**plans[-1], "purpose": closing}
854
+ return plans
802
855
 
803
856
  fixed = contract["fixed_scenes"] or [{"purpose": "opening"}]
804
857
  n = len(fixed)
@@ -1482,8 +1535,11 @@ def build_dsl(
1482
1535
  if needs_narration else ""
1483
1536
  )
1484
1537
 
1538
+ # 本场景的版式不显示背景素材(如绘本 ending)→ 不规划视觉素材,免得白生成
1539
+ scene_has_visual = has_visual and _slot_reads_visual(template_config, plan["purpose"])
1540
+
1485
1541
  # 仅 visual-overlay 模板生成视觉素材(slide 无视觉素材,carousel-caption 已提前返回)
1486
- if has_visual and primary_visual_type == "image":
1542
+ if scene_has_visual and primary_visual_type == "image":
1487
1543
  slot = provided_slots[idx] if idx < len(provided_slots) else ""
1488
1544
  slot_is_ai, slot_prompt = _parse_ai_slot(slot)
1489
1545
  if slot and not slot_is_ai:
@@ -1540,7 +1596,7 @@ def build_dsl(
1540
1596
  "status": "planned",
1541
1597
  "payload": img_payload,
1542
1598
  })
1543
- elif has_visual and primary_visual_type == "video":
1599
+ elif scene_has_visual and primary_visual_type == "video":
1544
1600
  if stub_video_url:
1545
1601
  assets.append({
1546
1602
  "assetId": visual_asset_id,
@@ -1587,7 +1643,7 @@ def build_dsl(
1587
1643
  },
1588
1644
  })
1589
1645
 
1590
- if has_visual:
1646
+ if scene_has_visual:
1591
1647
  layout = "text-overlay" if plan["purpose"] in ("opening", "cta") else "full-visual"
1592
1648
  text_layers = []
1593
1649
  if plan["purpose"] == "opening":
@@ -1612,6 +1668,8 @@ def build_dsl(
1612
1668
  ]
1613
1669
  if effective_subheadline:
1614
1670
  text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
1671
+ if contract["text_layer_rules"]:
1672
+ text_layers = _placeholder_text_layers(contract["text_layer_rules"])
1615
1673
 
1616
1674
  scene = {
1617
1675
  "id": scene_id,
@@ -1679,6 +1737,9 @@ def build_dsl(
1679
1737
  if effective_subheadline:
1680
1738
  text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
1681
1739
 
1740
+ if contract["text_layer_rules"]:
1741
+ text_layers = _placeholder_text_layers(contract["text_layer_rules"])
1742
+
1682
1743
  custom_payload = _build_custom_payload(template_config)
1683
1744
  if beat_count is not None:
1684
1745
  # Field names come from the template's countdown declaration — this
@@ -1737,7 +1798,8 @@ def build_dsl(
1737
1798
  },
1738
1799
  "assets": assets,
1739
1800
  "scenes": scenes,
1740
- "transitions": {"default": "fade", "duration": 0.5},
1801
+ # 模板声明了 capabilities.defaultTransition 就用它(绘本 = none:淡入会露出黑底)
1802
+ "transitions": {"default": contract["default_transition"] or "fade", "duration": 0.5},
1741
1803
  }
1742
1804
 
1743
1805
  if template_id:
@@ -1440,6 +1440,26 @@ def _auto_highlight_map(entry: dict, narration_asset: Optional[dict], render_pla
1440
1440
  })
1441
1441
 
1442
1442
 
1443
+ def _template_default_tail_pad_frames(template_id: str, fps: int) -> int | None:
1444
+ """模板声明的旁白尾巴默认值(``capabilities.defaultTailPadSec``),换算成帧。
1445
+
1446
+ 场景自己的 ``customPayload.tailPadSec`` 仍然优先;这里只替换全局 1.5s 兜底。
1447
+ 为什么要模板级:逐场景设置靠模型照着 llmHint 抄,实际经常漏 —— 2026-09-24
1448
+ 线上《狼来了》绘本每页结尾 1.9s 空白,86s 里 15s 没有声音。
1449
+ """
1450
+ if not template_id:
1451
+ return None
1452
+ try:
1453
+ from registry_loader import get_template # type: ignore
1454
+ tpl = get_template(template_id, include_all_statuses=True)
1455
+ except Exception: # noqa: BLE001 - 取不到模板就走全局默认,不因为这个挡渲染
1456
+ return None
1457
+ value = ((tpl or {}).get("capabilities") or {}).get("defaultTailPadSec")
1458
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0:
1459
+ return None
1460
+ return int(round(float(value) * fps))
1461
+
1462
+
1443
1463
  def adjust_timeline_to_audio(render_plan: dict) -> dict:
1444
1464
  """Post-process: use real TTS audio durations and timestamps to fix
1445
1465
  scene durations and subtitle segments."""
@@ -1449,6 +1469,9 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1449
1469
  assets_map = {a["assetId"]: a for a in render_plan["resolvedAssets"]}
1450
1470
  current_frame = 0
1451
1471
  adjusted = 0
1472
+ default_tail_pad = _template_default_tail_pad_frames(render_plan.get("templateId", ""), fps)
1473
+ if default_tail_pad is None:
1474
+ default_tail_pad = int(fps * 1.5)
1452
1475
 
1453
1476
  for entry in render_plan["timeline"]:
1454
1477
  # narration assetId 现在直接由 propExtractors 注入到 props.narrationAssetId,
@@ -1472,13 +1495,14 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1472
1495
  })
1473
1496
  else:
1474
1497
  audio_frames = math.ceil(audio_ms / 1000.0 * fps)
1475
- # DSL 里 scene.customPayload.tailPadSec 可覆盖默认 1.5s 尾巴
1476
- # (比如开场卡片要紧接下一场,设 0 直接贴音频结束)。
1498
+ # DSL 里 scene.customPayload.tailPadSec 可覆盖默认尾巴
1499
+ # (比如开场卡片要紧接下一场,设 0 直接贴音频结束);默认值是模板的
1500
+ # capabilities.defaultTailPadSec,没声明时 1.5s。
1477
1501
  tail_pad_override = entry.get("tailPadFrames")
1478
1502
  tail_pad = (
1479
1503
  int(tail_pad_override)
1480
1504
  if isinstance(tail_pad_override, int) and tail_pad_override >= 0
1481
- else int(fps * 1.5)
1505
+ else default_tail_pad
1482
1506
  )
1483
1507
  min_dur = int(fps * 2) # minimum 2 seconds per scene
1484
1508
  # 用户在 DSL 里指定的 scene 下限(minDurationSec → minDurationFrames),
@@ -1907,15 +1931,12 @@ def render_with_remote_api(
1907
1931
  cover_composition_id = resolve_cover_composition_id(template_id)
1908
1932
 
1909
1933
  # ─── 私有模板路由 ────────────────────────────────────────────────────
1910
- # 仅【用户私有模板】(isBuiltin=false 且带 sourceOssKey) 源码在 OSS、不在
1911
- # ab-render 启动 bundle,需走动态渲染:presignSource 取临时 GET URL → /renderDraft。
1912
- # 内置模板即使带 sourceOssKey(builtin-template-registry 回填,仅服务
1913
- # derive_template 派生),渲染仍走 ab-render 预构建的 /render,绝不走 /renderDraft,
1914
- # 否则会用 OSS 上的旧快照 + DraftMainVideo 复刻渲染,与生产 MainVideo 漂移。
1915
- # 下面这套 payload 构造在"续跑既有任务"时其实用不上(见后面的 resuming 分支),
1916
- # 所以它的失败不该让续跑也跟着失败 —— 任务还在后端好好渲着。
1934
+ # 仅【用户私有模板】(isBuiltin=false 且 hasSource) 源码在 OSS、不在
1935
+ # ab-render 启动 bundle,需走动态渲染:/renderDraft {templateKey},ab-render 服务端取包。
1936
+ # 内置模板即使带 sourceOssKey(admin 调用时 registry 才会输出),渲染仍走 ab-render
1937
+ # 预构建的 /render,绝不走 /renderDraft,否则会用 OSS 上的旧快照 + DraftMainVideo
1938
+ # 复刻渲染,与生产 MainVideo 漂移。
1917
1939
  pending_resume = bool(str(render_plan.get("remoteTaskId") or "").strip())
1918
- # 先给默认值:presign 失败时下面的分支不会走到,续跑分支仍要读 status_path。
1919
1940
  render_path = "/render"
1920
1941
  status_path = "/renderStatus"
1921
1942
 
@@ -1929,35 +1950,17 @@ def render_with_remote_api(
1929
1950
  # 是另一个模板的画面。
1930
1951
  _meta = get_template(template_id, include_all_statuses=True)
1931
1952
  if _meta and not _meta.get("isBuiltin"):
1932
- source_oss_key = _meta.get("sourceOssKey")
1953
+ # hasSource 是新字段;sourceOssKey 兼容尚未升级的 ab-api
1954
+ source_oss_key = _meta.get("hasSource") or _meta.get("sourceOssKey")
1933
1955
  except Exception:
1934
1956
  source_oss_key = None
1935
1957
 
1936
1958
  if source_oss_key:
1937
- import render_job_client # type: ignore
1938
- tarball_url = None
1939
- try:
1940
- src = render_job_client.presign_template_source(template_id, private_token)
1941
- tarball_url = src.get("tarballUrl")
1942
- if not tarball_url:
1943
- raise RuntimeError(f"presignSource 未返回 tarballUrl: {src}")
1944
- except Exception as exc:
1945
- if pending_resume:
1946
- LogPrint(
1947
- f"⚠️ presignSource failed ({exc}) — 但有待续跑的任务,忽略继续",
1948
- file=sys.stderr,
1949
- )
1950
- else:
1951
- LogPrint(f"❌ presignSource failed for private template {template_id}: {exc}", file=sys.stderr)
1952
- render_plan["status"] = "failed"
1953
- render_plan["errors"].append({
1954
- "phase": "render",
1955
- "message": f"presignSource failed: {exc}",
1956
- "timestamp": now_iso(),
1957
- })
1958
- return False
1959
+ # 只传 templateKey:由 ab-render 带本次的 X-Priv-Token 向 ab-api 换源码包 URL
1960
+ # 并自行下载(所有权校验在 ab-api)。源码包 URL 不经过客户端 ——
1961
+ # 见 ab-platform/docs/template-source-protection-design.md §4.3。
1959
1962
  payload = {
1960
- "tarballUrl": tarball_url,
1963
+ "templateKey": template_id,
1961
1964
  "inputProps": input_props,
1962
1965
  "upload": True,
1963
1966
  "uploadTitle": effective_title,
@@ -2,6 +2,6 @@
2
2
  "skillName": "render-video",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "473",
5
- "version": "V28",
5
+ "version": "V29",
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
  }
@@ -186,18 +186,6 @@ def get_job(job_id: int, priv_token: str) -> dict:
186
186
  return _post("/renderJob/get", {"jobId": job_id}, priv_token)
187
187
 
188
188
 
189
- def presign_template_source(template_key: str, priv_token: str) -> dict:
190
- """
191
- 为已注册私有模板的源码包申请临时 GET 下载 URL(渲染取包用)。
192
-
193
- 调 ab-api /remotionTemplate/presignSource,返回 { tarballUrl, sourceOssKey, version }。
194
- ab-render 用 tarballUrl 走 /renderDraft 动态 bundle 渲染。
195
-
196
- 注意:presignSource 端点不在 /renderJob 下,路径以 / 开头直接拼到 _api_base。
197
- """
198
- return _post("/remotionTemplate/presignSource", {"templateKey": template_key}, priv_token)
199
-
200
-
201
189
  # ─── CLI self-test ────────────────────────────────────────────────────────────
202
190
 
203
191
  if __name__ == "__main__":
@@ -2,6 +2,6 @@
2
2
  "skillName": "template-registry",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "475",
5
- "version": "V21",
5
+ "version": "V22",
6
6
  "skillDescription": "Video-template registry skill. Stores every video-template definition and lists the available templates (templateId / name / aspect ratio / style tags).\n\nUse this skill as soon as the user mentions any of these intents:\n- View available templates / list every template\n\nNote: DSL→TemplateBinding is no longer a separate exposed step — once prepare_video_assets receives a template_id it builds the binding internally."
7
7
  }
@@ -1132,6 +1132,77 @@ def _check_narration_language_strict(dsl: dict) -> list[ValidationError]:
1132
1132
  return errors
1133
1133
 
1134
1134
 
1135
+ _TEXT_LAYER_SKELETON_PREFIXES = ("[骨架待填充]", "[skeleton placeholder]")
1136
+
1137
+
1138
+ def _check_text_layer_rules(dsl: dict) -> list[ValidationError]:
1139
+ """模板声明 ``capabilities.textLayerRules`` 时,逐场景检查画面文字层。
1140
+
1141
+ 声明形状::
1142
+
1143
+ "textLayerRules": {
1144
+ "required": ["headline", "subheadline"], # 每个场景都必须有且非空
1145
+ "language": {"headline": "en", "subheadline": "zh"} # en = 不含中文;zh = 须含中文
1146
+ }
1147
+
1148
+ 为什么是 error 而不是 llmHint:2026-09-24 线上《狼来了》绘本整本没有一行中文译文,
1149
+ 封面把中英文都塞进了 headline —— llmHint 写得清清楚楚,模型没照做,而当时没有
1150
+ 任何一道门拦它,成片照出,书页上留着一大块给译文的空白。
1151
+ """
1152
+ tpl = _get_template_cfg(_pick_template_id(dsl))
1153
+ if not tpl:
1154
+ return []
1155
+ rules = (tpl.get("capabilities") or {}).get("textLayerRules")
1156
+ if not isinstance(rules, dict):
1157
+ return []
1158
+ required = [r for r in (rules.get("required") or []) if isinstance(r, str)]
1159
+ language = rules.get("language") if isinstance(rules.get("language"), dict) else {}
1160
+ tid = tpl.get("templateId", "<unknown>")
1161
+
1162
+ errors: list[ValidationError] = []
1163
+ for idx, scene in enumerate(dsl.get("scenes", []) or []):
1164
+ sid = scene.get("id", f"#{idx}")
1165
+ layers: dict[str, tuple[int, str]] = {}
1166
+ for li, layer in enumerate(scene.get("textLayers") or []):
1167
+ if not isinstance(layer, dict):
1168
+ continue
1169
+ role = layer.get("role")
1170
+ content = layer.get("content")
1171
+ if isinstance(role, str) and role not in layers:
1172
+ layers[role] = (li, content.strip() if isinstance(content, str) else "")
1173
+
1174
+ for role in required:
1175
+ li, content = layers.get(role, (None, ""))
1176
+ path = f"scenes[{idx}].textLayers" + (f"[{li}].content" if li is not None else "")
1177
+ if not content:
1178
+ errors.append(ValidationError(
1179
+ path,
1180
+ f"scene '{sid}': template '{tid}' requires a non-empty textLayers role '{role}'"
1181
+ + (f" (language: {language[role]})" if role in language else ""),
1182
+ ))
1183
+ elif content.startswith(_TEXT_LAYER_SKELETON_PREFIXES):
1184
+ errors.append(ValidationError(
1185
+ path, f"scene '{sid}': textLayers role '{role}' is still the gen_script placeholder",
1186
+ ))
1187
+
1188
+ for role, lang in language.items():
1189
+ li, content = layers.get(role, (None, ""))
1190
+ if li is None or not content or content.startswith(_TEXT_LAYER_SKELETON_PREFIXES):
1191
+ continue
1192
+ path = f"scenes[{idx}].textLayers[{li}].content"
1193
+ if lang == "en" and _contains_cjk(content):
1194
+ others = [r for r, l in language.items() if l == "zh"]
1195
+ hint = f"; put the Chinese in role '{others[0]}'" if others else ""
1196
+ errors.append(ValidationError(
1197
+ path, f"scene '{sid}': role '{role}' must be English only (no CJK characters){hint}",
1198
+ ))
1199
+ elif lang == "zh" and not _contains_cjk(content):
1200
+ errors.append(ValidationError(
1201
+ path, f"scene '{sid}': role '{role}' must be Chinese (the translation of this page)",
1202
+ ))
1203
+ return errors
1204
+
1205
+
1135
1206
  # ════════════════════════════════════════════════════════════════════════════
1136
1207
  # Aggregator presets
1137
1208
  # ════════════════════════════════════════════════════════════════════════════
@@ -1188,6 +1259,7 @@ def validate_integrity(dsl: dict) -> list[ValidationError]:
1188
1259
  # 是**渲得出来但错位**,只有在真要渲之前说才来得及。
1189
1260
  errors += _check_step_narration_alignment(dsl)
1190
1261
  errors += _check_slide_cross_field(dsl)
1262
+ errors += _check_text_layer_rules(dsl)
1191
1263
  return errors
1192
1264
 
1193
1265
 
@@ -1222,4 +1294,5 @@ def validate_dsl(dsl: dict) -> list[ValidationError]:
1222
1294
  errors += _check_line_sync_narration(dsl)
1223
1295
  errors += _check_step_narration_alignment(dsl)
1224
1296
  errors += _check_slide_cross_field(dsl)
1297
+ errors += _check_text_layer_rules(dsl)
1225
1298
  return errors
@@ -19,13 +19,23 @@ from typing import Optional
19
19
 
20
20
  CHARS_PER_SECOND = 5.0
21
21
 
22
- _PRIMARY_BREAK = re.compile(r"(?<=[。!?;\n])")
22
+ # 英文句末:`.!?`(可跟一个收尾引号/括号)且后面是空白。以前只认中文标点,英文旁白
23
+ # 从来不在句号处断,只能落到逗号 / 30 字空格硬切,切出 `went home. A few days later,`
24
+ # 这种跨句的行,所有段的 sentenceIndex 也全是 0。常见缩写后的句点不算句末。
25
+ _EN_SENTENCE_END = (
26
+ r"(?:(?<=[.!?])|(?<=[.!?][\"'\u201d\u2019)]))"
27
+ r"(?<!\bMr\.)(?<!\bMs\.)(?<!\bDr\.)(?<!\bSt\.)(?<!\bMrs\.)(?<!\be\.g\.)(?<!\bi\.e\.)(?<!\bvs\.)"
28
+ r"(?=\s)"
29
+ )
30
+ _PRIMARY_BREAK = re.compile(r"(?<=[。!?;\n])|" + _EN_SENTENCE_END)
23
31
  # 次级断点**不含顿号 `、`**。顿号是并列项之间的分隔,在它上面下刀就是把一串并列
24
32
  # 拦腰切开(`调研、策划、` / `开发、分析,…`)。这一层只数字符、分不出标点的语义
25
33
  # 强度,所以干脆不碰它 —— 真需要再切时交给渲染端,那边是分级的(句末 > 冒号 >
26
34
  # 逗号 > 顿号 > 空格),只有前面几级都切不动才会降到顿号。
27
35
  _SECONDARY_BREAK = re.compile(r"(?<=[,,::])")
28
- _PRIMARY_TAIL = re.compile(r"[。!?;\n]\s*$")
36
+ _PRIMARY_TAIL = re.compile(
37
+ r"(?:[。!?;\n]|(?<!\bMr)(?<!\bMs)(?<!\bDr)(?<!\bSt)(?<!\bMrs)(?<!\bvs)[.!?][\"'\u201d\u2019)]?)\s*$"
38
+ )
29
39
 
30
40
  # ─── 这两个上限管的是「喂给 TTS 的行」,不再是「一屏字幕」 ────────────────
31
41
  #