@remixmate/cli 0.9.51 → 0.9.53

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.51",
4
- "generatedAt": "2026-09-27T12:37:01.962Z",
3
+ "version": "0.9.53",
4
+ "generatedAt": "2026-09-28T02:19:31.947Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "doc-parse",
@@ -944,16 +944,19 @@
944
944
  },
945
945
  "template_id": {
946
946
  "type": "string",
947
- "description": "Return this template's FULL definition instead of the summary list: llmHint in full, customPayloadSchema (every template-specific field incl. the legal slideId values), slotMapping, compositions, variants. This is the per-template authoring contract — read it before writing DSL for that template."
947
+ "description": "Return this template's full authoring contract instead of the summary list: llmHint in full, customPayloadSchema (every template-specific field incl. the legal slideId values), slotMapping, compositions, variants. Multi-layout templates return slideSchemas as a MENU (per layout: description, field names, card-list key / item fields / maxItems, and whether narration must be split into items) — enough to pick layouts and draft the script. Tool results do not carry over between chat turns: read the menu in the turn that drafts the script; the turn that writes templateData only needs slide_id."
948
+ },
949
+ "slide_id": {
950
+ "type": "array",
951
+ "items": {
952
+ "type": "string"
953
+ },
954
+ "description": "With template_id: return the FULL templateData schema for these layouts (the slideIds you chose from the menu). Call this once, right before writing templateData, with every layout the script uses. 'all' returns every layout (large — avoid)."
948
955
  },
949
956
  "full": {
950
957
  "type": "boolean",
951
958
  "description": "List mode only: emit full definitions for every listed template instead of summaries. Rejected when the result would be too large for one tool result — prefer template_id, or narrow with the filter_* parameters."
952
959
  },
953
- "list_examples": {
954
- "type": "boolean",
955
- "description": "List the *.dsl.json / *.binding.json reference examples under template-registry/video_dsl/schema/examples/, grouped by templateId. That directory is optional and is not part of the published package, so this is normally empty — to read a template's reference shape, use template_id instead (the registry's own contract)."
956
- },
957
960
  "filter_tag": {
958
961
  "type": "string",
959
962
  "description": "Show only templates whose styleTags contain (or are contained in) this string. Case-insensitive. E.g. 'tech' / '科普' / 'walkthrough'."
@@ -982,8 +985,8 @@
982
985
  "advanced": [
983
986
  "list_templates",
984
987
  "template_id",
988
+ "slide_id",
985
989
  "full",
986
- "list_examples",
987
990
  "filter_tag",
988
991
  "filter_aspect",
989
992
  "filter_language",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.51",
3
+ "version": "0.9.53",
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",
@@ -36,7 +36,8 @@
36
36
  "test:declared-keys": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-declared-keys.py",
37
37
  "test:payload-hydration": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-payload-hydration.py",
38
38
  "test:sentence-alignment": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-sentence-alignment.py",
39
- "test:unit": "npm run -s test:narration-speed && npm run -s test:template-look && npm run -s test:narration-tts && npm run -s test:render-resume && npm run -s test:required-props && npm run -s test:countdown && npm run -s test:prepare-contracts && npm run -s test:image-sequence && npm run -s test:image-prompt-compose && npm run -s test:page-contracts && npm run -s test:declared-keys && npm run -s test:payload-hydration && npm run -s test:sentence-alignment",
39
+ "test:authoring-budget": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-authoring-budget.py",
40
+ "test:unit": "npm run -s test:narration-speed && npm run -s test:template-look && npm run -s test:narration-tts && npm run -s test:render-resume && npm run -s test:required-props && npm run -s test:countdown && npm run -s test:prepare-contracts && npm run -s test:image-sequence && npm run -s test:image-prompt-compose && npm run -s test:page-contracts && npm run -s test:declared-keys && npm run -s test:payload-hydration && npm run -s test:sentence-alignment && npm run -s test:authoring-budget",
40
41
  "integration": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/integration/regress.py",
41
42
  "integration:render": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/integration/render-template-check.py"
42
43
  },
@@ -39,7 +39,7 @@ Turns a user-supplied **topic** into a **Video DSL v1alpha1** JSON describing wh
39
39
 
40
40
  The full schema is `template-registry` skill's `video_dsl/schema/video-dsl-v1alpha1.json`.
41
41
 
42
- **Per-template reference DSLs do not ship with this package.** `video_dsl/schema/examples/` is a local-only directory: it is absent from the published npm package (and has never existed in the repo), so `template_registry list_examples=true` normally returns nothing. Do not read "no examples found" as "this template is unsupported" — the authoritative per-template contract ships inside the registry's `template.json`, see below.
42
+ **Per-template reference DSLs do not ship with this package.** `video_dsl/schema/examples/` is a local-only directory: it is absent from the published npm package (and has never existed in the repo). Do not go looking for examples — the authoritative per-template contract ships inside the registry's `template.json`, see below.
43
43
 
44
44
  ## Agent behavior: DSL generation when a template is selected
45
45
 
@@ -52,15 +52,14 @@ The intent is to avoid the failure mode where "the generic DSL looks compatible
52
52
 
53
53
  ### Mandatory steps
54
54
 
55
- 1. Read the requested template's full definition from the registry — `template_registry` with `template_id=<the id>` and `json_output=true` emits that one template whole. This is the shipping source of truth, and unlike the summary list it truncates nothing. Do **not** ask for every template's full definition (`list_templates=true` + `full=true`): the whole registry is far more JSON than one tool result can carry, and the call fails instead of returning the contract.
55
+ 1. Read the requested template's full definition from the registry — `template_registry` with `template_id=<the id>` emits that one template's contract. This is the shipping source of truth. Never fetch it twice in one turn. Tool results do **not** carry over between chat turns (each turn only sees the chat text), so the turn that writes `dsl_json` after the user confirms needs only step 3b's `slide_id` call, not the menu again. Do **not** ask for every template's full definition (`list_templates=true` + `full=true`): the whole registry is far more JSON than one tool result can carry, and the call fails instead of returning the contract.
56
56
  2. Read its `llmHint` end to end — that is where the template states how its on-screen text and its layouts must be authored.
57
57
  3. Read its `customPayloadSchema`: every template-specific field lives there, including the enum of legal `customPayload.slideId` values for multi-layout templates.
58
- 3b. If the template ships `slideSchemas` (multi-layout templates do), **author `templateData` straight from `slideSchemas[slideId]`** — it is the only place the per-layout field names exist. Do not infer them: the same concept is named `name` in one layout, `label` in another and `era` in a third, and a wrong name is dropped in silence (see below).
58
+ 3b. If the template ships `slideSchemas` (multi-layout templates do), step 1 returns them as a **menu** — per layout: description, field names, card-list key / item fields / `maxItems`, and a `narration` note. Pick layouts and draft the script from the menu. Then, before writing `templateData`, call `template_registry` once with `template_id` and `slide_id=[every chosen layout]`, and **author `templateData` straight from those `slideSchemas[slideId]`** — it is the only place the per-layout field names exist. Do not infer them: the same concept is named `name` in one layout, `label` in another and `era` in a third, and a wrong name is dropped in silence (see below).
59
59
  4. Combine that with the template's `slotMapping`, `requiredProps`, `optionalProps`, `propExtractors`, `assetRequirements`, `supportedAspectRatios`, `supportedDurations`, `constraints`, and `scenePatterns`.
60
60
  5. Generate the DSL in the template's native shape — not the generic DSL shape.
61
61
  6. Make sure the DSL explicitly contains every template-specific field, e.g. `templateData.words`, `customPayload.slideId`, `visuals.avatar.assetRef`.
62
- 7. If `template_registry list_examples=true` does return files locally, read them as an extra sample — never as a substitute for step 1.
63
- 8. After generation, run the schema check and show the script summary to the user for confirmation.
62
+ 7. After generation, run the schema check and show the script summary to the user for confirmation.
64
63
 
65
64
  ### Document-driven templates: pass the source URL, don't author the payload
66
65
 
@@ -473,7 +472,7 @@ Apply the following principles when producing the DSL:
473
472
 
474
473
  1. **Narrative-first**: opening hook → point expansion → CTA close.
475
474
  2. **Assets are declared, not executed**: every asset starts as `AssetRef` with `status: planned`; no generation API call from this skill.
476
- 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).
475
+ 3. **Narration drives duration**: the rendered length of a scene is its narration's reading time plus a short tail, not `scenes[].duration`. Each skeleton placeholder states the scene's budget (e.g. `(8s,整段≤32 字;分行旁白每多一行约少 4 字)`) — write inside it. The budget comes from `video_dsl/runtime/narration_budget.py` (≈4.5 zh chars/s at 1.0× speed, ~0.9s pause per extra narration line, 1.5s tail per scene unless the template declares `capabilities.defaultTailPadSec`). If you go over, report the longer estimated length at script confirmation; `prepare_video_assets` also warns when the estimate exceeds `meta.targetDuration` by more than 30%.
477
476
  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.
478
477
 
479
478
  **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.
@@ -526,4 +525,4 @@ prefix and version — not a short alias:
526
525
  |------|---------|
527
526
  | `gen_script.py` | Core script — produces the Video DSL JSON from a topic. |
528
527
 
529
- The authoritative per-template contract is the registry's `template.json` (`llmHint` + `customPayloadSchema` + `slideSchemas` + `slotMapping`) — everything you need to author against is in there, so read it rather than inferring field names from the template's name or from another template. `template_registry list_examples=true` lists local reference DSLs when any exist, but that directory does not ship — see *DSL schema* above.
528
+ The authoritative per-template contract is the registry's `template.json` (`llmHint` + `customPayloadSchema` + `slideSchemas` + `slotMapping`) — everything you need to author against is in there, so read it rather than inferring field names from the template's name or from another template.
@@ -73,6 +73,14 @@ except ImportError:
73
73
  _HAS_ENHANCER = False
74
74
  SKELETON_PREFIXES = ("[骨架待填充]", "[skeleton placeholder]")
75
75
 
76
+ # 旁白字数预算:写进骨架占位旁白,让模型写脚本时就知道这一场放得下多少字。
77
+ # 守护式导入同上;缺失时占位文案退回不带预算的旧格式。
78
+ try:
79
+ from video_dsl.runtime import narration_budget as _nb
80
+ _HAS_BUDGET = True
81
+ except ImportError:
82
+ _HAS_BUDGET = False
83
+
76
84
  # 场景数上限的唯一实现在 dsl_validator(它同时是门禁方)。守护式导入的理由同上:
77
85
  # 共享目录可能不在(单测 / 裁剪安装),那时退回历史常量 20,行为与改动前一致。
78
86
  try:
@@ -110,7 +118,8 @@ except ImportError:
110
118
  VALID_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]
111
119
  VALID_PURPOSES = ["opening", "point", "example", "explanation", "highlight", "cta", "ending"]
112
120
 
113
- CHARS_PER_SECOND = 5.0 # 与 timeline_compiler.py 保持一致(中文约 5 字/秒)
121
+ # 规划用语速 / 行间停顿 / 场景尾巴统一在 video_dsl.runtime.narration_budget。
122
+ # 这里原有的 CHARS_PER_SECOND = 5.0 从未参与计算(只出现在注释里),已删。
114
123
 
115
124
  # ── Language contract (mirrors apps/ab-skill/src/handlers/template-meta.ts) ──
116
125
  # template.json may declare:
@@ -230,20 +239,25 @@ def _localize_label(language, key, **fmt):
230
239
  "label_point": {"zh": "内容要点 {n}", "en": "Point {n}"},
231
240
  "label_cta": {"zh": "结尾 CTA", "en": "Closing CTA"},
232
241
  "narration_opening": {
233
- "zh": "[骨架待填充] 关于{topic}的开场 Hook({duration}s)",
234
- "en": "[skeleton placeholder] Opening hook about {topic} ({duration}s)",
242
+ "zh": "[骨架待填充] 关于{topic}的开场 Hook({duration}s{budget})",
243
+ "en": "[skeleton placeholder] Opening hook about {topic} ({duration}s{budget})",
235
244
  },
236
245
  "narration_point": {
237
- "zh": "[骨架待填充] {label}:关于{topic}的一个要点({duration}s)",
238
- "en": "[skeleton placeholder] {label}: a point about {topic} ({duration}s)",
246
+ "zh": "[骨架待填充] {label}:关于{topic}的一个要点({duration}s{budget})",
247
+ "en": "[skeleton placeholder] {label}: a point about {topic} ({duration}s{budget})",
239
248
  },
240
249
  "narration_cta": {
241
- "zh": "[骨架待填充] 关于{topic}的 CTA 结尾({duration}s)",
242
- "en": "[skeleton placeholder] Closing CTA about {topic} ({duration}s)",
250
+ "zh": "[骨架待填充] 关于{topic}的 CTA 结尾({duration}s{budget})",
251
+ "en": "[skeleton placeholder] Closing CTA about {topic} ({duration}s{budget})",
252
+ },
253
+ # {budget} 由 _budget_suffix 填:「,整段≤32 字;分行每多一行约少 4 字」。
254
+ "budget_suffix": {
255
+ "zh": ",整段≤{chars} 字;分行旁白每多一行约少 {per_line} 字",
256
+ "en": ", ≤{chars} words as one block; ~{per_line} fewer per extra narration line",
243
257
  },
244
258
  "narration_default": {
245
- "zh": "[骨架待填充] 关于{topic}的内容({duration}s)",
246
- "en": "[skeleton placeholder] Content about {topic} ({duration}s)",
259
+ "zh": "[骨架待填充] 关于{topic}的内容({duration}s{budget})",
260
+ "en": "[skeleton placeholder] Content about {topic} ({duration}s{budget})",
247
261
  },
248
262
  # NOTE: 这里曾有 "point_description"("关于{topic}的要点内容")与
249
263
  # "more_about"("更多关于{topic}的内容")两条,用来给 no-visual 场景拼画面
@@ -257,6 +271,9 @@ def _localize_label(language, key, **fmt):
257
271
  }
258
272
  entry = table.get(key, {})
259
273
  template = entry.get(language) or entry.get(DEFAULT_LANGUAGE) or ""
274
+ # 字数预算只有按场景规划的路径才算得出来(轮播 / 按字幕定时长的模板没有
275
+ # 「每场秒数」这个前提),没给就是不带预算的旧格式。
276
+ fmt.setdefault("budget", "")
260
277
  return template.format(**fmt)
261
278
 
262
279
 
@@ -363,17 +380,47 @@ def plan_scenes(
363
380
  return scenes
364
381
 
365
382
 
366
- def estimate_narration_text(label: str, purpose: str, duration: int, topic: str, language: str = DEFAULT_LANGUAGE) -> str:
383
+ def _budget_suffix(duration: int, language: str, speed: float, tail_pad_sec: float | None) -> str:
384
+ """占位旁白里的字数预算。拿不到预算模块时返回空串(退回旧格式)。
385
+
386
+ 为什么写进占位文案而不是单独字段:模型填旁白时一定会读到这一句,而骨架多一个
387
+ 字段就得在校验白名单、TTS payload 两头都处理,漏一头就是新的静默问题。
388
+ """
389
+ if not _HAS_BUDGET:
390
+ return ""
391
+ tail = _nb.DEFAULT_TAIL_PAD_SEC if tail_pad_sec is None else tail_pad_sec
392
+ lang = "zh" if language == "zh" else "en"
393
+ chars = _nb.speech_budget(duration, speed, lang, lines=1, tail_pad_sec=tail)
394
+ per_line = max(1, round(_nb.LINE_PAUSE_SEC * _nb.speech_rate(lang, speed)))
395
+ return _localize_label(language, "budget_suffix", chars=chars, per_line=per_line)
396
+
397
+
398
+ def estimate_narration_text(
399
+ label: str,
400
+ purpose: str,
401
+ duration: int,
402
+ topic: str,
403
+ language: str = DEFAULT_LANGUAGE,
404
+ speed: float = 1.0,
405
+ tail_pad_sec: float | None = None,
406
+ ) -> str:
367
407
  """Generate a short skeleton narration hint for a scene.
368
408
 
369
409
  This returns a *skeleton only* — the agent is expected to replace this
370
410
  text with real narration before submitting DSL to prepare_video_assets. We
371
411
  deliberately return a short, clearly-placeholder-looking string rather
372
- than padding to duration × CHARS_PER_SECOND, because padding with a
412
+ than padding to the scene's length, because padding with a
373
413
  repeating filler sentence historically caused the skeleton to be
374
414
  submitted verbatim to TTS (audio narration mismatch bug).
415
+
416
+ It does carry the scene's narration *budget* (see narration_budget): the
417
+ one number the agent needs while writing, and never had — a 2026-09-27
418
+ session wrote 45–58 chars into 8s scenes and shipped 117s for a 75s target.
375
419
  """
376
- fmt = {"label": label, "topic": topic, "duration": duration}
420
+ fmt = {
421
+ "label": label, "topic": _clip(topic, _PLACEHOLDER_TOPIC_CHARS), "duration": duration,
422
+ "budget": _budget_suffix(duration, language, speed, tail_pad_sec),
423
+ }
377
424
  if purpose == "opening":
378
425
  return _localize_label(language, "narration_opening", **fmt)
379
426
  if purpose == "point":
@@ -681,6 +728,32 @@ def _placeholder_text_layers(rules: dict) -> list[dict]:
681
728
  return layers
682
729
 
683
730
 
731
+ # 视频标题 / 占位旁白里引用 topic 的长度上限。topic 是给模型写脚本用的内容提要,
732
+ # 线上常见 200+ 字(2026-09-27 一条会话 250 字),原样拿去当标题或塞进每场占位旁白
733
+ # 都不合适。
734
+ _TITLE_MAX_CHARS = 60
735
+ _PLACEHOLDER_TOPIC_CHARS = 30
736
+
737
+
738
+ def _clip(text: str, limit: int) -> str:
739
+ text = " ".join((text or "").split())
740
+ return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
741
+
742
+
743
+ def _video_title(headline: str | None, topic: str) -> str:
744
+ """meta.title:成片的标题。
745
+
746
+ 它会成为上传点播的标题(render_video 的 upload_title_hint)和脚本产物列表的
747
+ 标题(ab-api artifact_narration.dslTitle)。以前直接等于 topic —— 模型传进来的
748
+ 是一段内容提要,于是作品标题成了「讲解 AI Agent 的核心底层逻辑「Agent Loop」:
749
+ 传统大模型的单次响应 vs…」(render_video 只能截到 128 字兜底)。用户给了
750
+ headline 就用 headline(折掉换行),没给才退回截短的 topic;完整 topic 仍在
751
+ meta.topic。
752
+ """
753
+ # 画面文案里的 **强调** 标记对标题没有意义,去掉。
754
+ return _clip((headline or "").replace("**", ""), _TITLE_MAX_CHARS) or _clip(topic, _TITLE_MAX_CHARS)
755
+
756
+
684
757
  def _normalize_linebreaks(value: str | None) -> str:
685
758
  """把画面文字里的**字面量** ``\\n`` 折成真换行。
686
759
 
@@ -1121,7 +1194,7 @@ def _build_carousel_caption_dsl(
1121
1194
 
1122
1195
  # ── Narration skeleton ────────────────────────────────────────────────
1123
1196
  narration_text = _localize_label(output_language, "narration_opening",
1124
- label="", topic=topic, duration=duration)
1197
+ label="", topic=_clip(topic, _PLACEHOLDER_TOPIC_CHARS), duration=duration)
1125
1198
 
1126
1199
  # ── Effective scene duration ──────────────────────────────────────────
1127
1200
  # fit-caption 策略:时长由打字机自然节奏决定,而不是盲取调用方传入的 targetDuration。
@@ -1173,7 +1246,7 @@ def _build_carousel_caption_dsl(
1173
1246
  dsl = {
1174
1247
  "version": "v1alpha1",
1175
1248
  "meta": {
1176
- "title": topic,
1249
+ "title": _video_title(headline, topic),
1177
1250
  "topic": topic,
1178
1251
  "headline": effective_headline,
1179
1252
  "subheadline": effective_subheadline,
@@ -1577,6 +1650,7 @@ def build_dsl(
1577
1650
  image_compose = _HAS_ENHANCER and is_compose_mode(image_style_guide)
1578
1651
  image_ratio = image_style_guide.get("ratio") or ratio
1579
1652
 
1653
+ tail_pad_sec = _nb.template_tail_pad_sec(template_config) if _HAS_BUDGET else None
1580
1654
  for idx, plan in enumerate(scene_plans):
1581
1655
  scene_id = f"scene-{idx + 1:02d}"
1582
1656
  # 视觉素材的 assetId 命名遵循类型前缀:image → img-,video → video-
@@ -1586,7 +1660,10 @@ def build_dsl(
1586
1660
  visual_asset_id = f"img-{scene_id}"
1587
1661
  narration_asset_id = f"narration-{scene_id}" if needs_narration else None
1588
1662
  narration_text = (
1589
- estimate_narration_text(plan["label"], plan["purpose"], plan["duration"], topic, language=output_language)
1663
+ estimate_narration_text(
1664
+ plan["label"], plan["purpose"], plan["duration"], topic, language=output_language,
1665
+ speed=narration_speed, tail_pad_sec=tail_pad_sec,
1666
+ )
1590
1667
  if needs_narration else ""
1591
1668
  )
1592
1669
 
@@ -1828,7 +1905,7 @@ def build_dsl(
1828
1905
  dsl = {
1829
1906
  "version": "v1alpha1",
1830
1907
  "meta": {
1831
- "title": topic,
1908
+ "title": _video_title(effective_headline if headline else None, topic),
1832
1909
  "topic": topic,
1833
1910
  "headline": effective_headline,
1834
1911
  "subheadline": effective_subheadline,
@@ -2,6 +2,6 @@
2
2
  "skillName": "gen-script",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "474",
5
- "version": "V17",
5
+ "version": "V18",
6
6
  "skillDescription": "Video-script generation skill. Turns a user-supplied topic into a structured Video DSL (JSON) that describes the full video — scene structure, asset requirements, and narrative flow.\n\nUse this skill as soon as the user mentions any of these intents:\n- Write a video script, generate a video script, plan a video, write storyboards\n- Create a short video, plan video content, generate a Video DSL\n- Turn a topic into a video structure / video content plan\n\nEven when the user does not say \"generate the DSL\", use this skill whenever they want to turn a topic into a structured video plan.\n\n⚠️ Stop-and-confirm gate: after this skill returns a DSL, show the full script and wait for the user's explicit confirmation. Never call `prepare_video_assets` in the same turn."
7
7
  }
@@ -17,7 +17,7 @@ HTTP 拉取带磁盘缓存(TTL + ETag/304);瞬时故障时降级复用同
17
17
 
18
18
  ab-api 响应包装格式 `{code, msg, data}` 由 `registry_loader._fetch_http` 透明拆封;静态 JSON endpoint 也支持(直接返回 registry 文档)。
19
19
 
20
- > 早期文档曾描述本 skill 通过 `@ab-templates/metadata` npm 包 / monorepo 文件消费 registry —— 这些本地来源已移除,运行时只走 ab-api HTTP。monorepo 文件仅供 `check_contracts.py` / `sync_registry.py` 等 monorepo-only 维护脚本使用。
20
+ > 早期文档曾描述本 skill 通过 `@ab-templates/metadata` npm 包 / monorepo 文件消费 registry —— 这些本地来源已移除,运行时只走 ab-api HTTP。配套的 monorepo-only 维护脚本(`check_contracts.py` / `sync_registry.py`)也已随之删除,契约对账改由 template-library 的 `generateManifest` / `validate:contracts` 在上游负责。
21
21
 
22
22
  ## 新增模板工作流
23
23
 
@@ -61,7 +61,8 @@ the registry is loaded **only** from the ab-api HTTP endpoint — explicit `VIDE
61
61
  > The Python binding logic that maps DSL → TemplateBinding lives in `scripts/match_template.py` but is **not exposed as a CLI** — it is only consumed as a Python library by `render-video`'s `render_video.py` via `import match_template`.
62
62
 
63
63
  1. **List available templates**: run `remixmate template-registry --list-templates` to view the templates in the registry along with their supported aspect ratios / style tags, and decide which `templateId` to pick.
64
- 2. **Read that template's contract**: run `remixmate template-registry --template-id <id> --json-output` for the full definition (llmHint, `customPayloadSchema` with the legal `slideId` values, `slotMapping`, `variants`). The list view only carries a 200-char llmHint preview, which is not enough to author against.
64
+ 2. **Read that template's contract**: run `remixmate template-registry --template-id <id>` for the full definition (llmHint, `customPayloadSchema` with the legal `slideId` values, `slotMapping`, `variants`). The list view only carries a 200-char llmHint preview, which is not enough to author against. Never fetch it twice in one turn. In the chat agent, tool results do **not** carry over between turns, so fetch per turn only what that turn needs: the menu while drafting the script, `--slide-id` while writing `templateData`.
65
+ 2b. **Multi-layout templates: expand only the layouts you use.** For templates that ship `slideSchemas`, step 2 returns a per-layout **menu** (description, field names, card-list key / item fields / `maxItems`, and a `narration` note on layouts that must split narration into `items`). Choose layouts and draft the script from the menu; then, right before writing `templateData`, call once more with `--slide-id <id>` for every chosen layout (repeatable) to get their exact field schemas.
65
66
  3. **Write the DSL**: when generating the Video DSL, put the chosen `templateId` into `meta.templateId` (the canonical location). Use `meta.templateVariant` / `renderHints.templateVariant` to explicitly select a variant. The legacy `renderHints.templatePreference[0]` is still tolerated by `match_template.py` and `dsl_validator._pick_template_id` during transition, but new authors should write `meta.templateId`.
66
67
  4. **Produce the TemplateBinding**: there is no standalone CLI for DSL → TemplateBinding; `prepare_video_assets` calls `match_template.build_binding(template, dsl)` inline during the asset-resolution pipeline and embeds the binding into the RenderPlan it hands to the renderer — no separate `*.binding.json` file is written.
67
68
 
@@ -91,13 +92,14 @@ print(json.dumps(binding, ensure_ascii=False, indent=2))
91
92
  | Flag | Description | Default |
92
93
  |------|-------------|---------|
93
94
  | `--list-templates` | List every available template as a **summary** (templateId / name / description / aspect ratios / language / status / styleTags / variantIds / the `capabilities` keys that drive authoring — `payloadStyle` / `needsNarration` / `durationStrategy` / `narrationDriver` — and llmHint truncated to 200 chars). | — |
94
- | `--template-id <id>` | Print that template's **full definition** — llmHint in full, `customPayloadSchema`, `slotMapping`, `compositions`, `variants`. Repeatable. | — |
95
+ | `--template-id <id>` | Print that template's **full definition** — llmHint in full, `customPayloadSchema`, `slotMapping`, `compositions`, `variants` — as compact JSON. Multi-layout templates return `slideSchemas` as a menu. Repeatable. | — |
96
+ | `--slide-id <slideId>` | With `--template-id`: expand these layouts' full `slideSchemas` (repeatable; `all` = every layout, large). Unknown ids fail with the legal list. | menu |
95
97
  | `--full` | List mode: emit full definitions instead of summaries. Refuses when the result would exceed 60 K characters. | off |
96
98
  | `--filter-tag` | Keep only templates whose `styleTags` match this substring (case-insensitive). | none |
97
99
  | `--filter-aspect` | Keep only templates declaring this aspect ratio (e.g. `9:16`). | none |
98
100
  | `--filter-language` | Keep only templates whose `contentLanguage` includes this code (`zh`/`en`); language-agnostic templates always show. | none |
99
101
  | `--include-beta` | Also show `status: beta` templates (same effect as `ENABLE_BETA_TEMPLATES=1`). | off |
100
- | `--json-output` | Emit `{ "templates": [...] }` instead of the table — summaries, or full definitions under `--template-id` / `--full`. | off |
102
+ | `--json-output` | List mode: emit `{ "templates": [...] }` summaries (or full definitions with `--full`) instead of the table. Detail mode (`--template-id`) is always compact JSON. | off |
101
103
 
102
104
  ### List vs. detail
103
105
 
@@ -105,9 +107,12 @@ A registry row is 4–17 KB of JSON, so dumping every full definition at once ov
105
107
 
106
108
  ```bash
107
109
  remixmate template-registry --list-templates # choose
108
- remixmate template-registry --template-id <templateId> --json-output # then read its contract
110
+ remixmate template-registry --template-id <templateId> # then read its contract (slide menu)
111
+ remixmate template-registry --template-id <templateId> --slide-id key-points --slide-id timeline # exact schemas of the chosen layouts
109
112
  ```
110
113
 
114
+ Detail mode used to pretty-print, which nearly doubled the size (html-slide: 37 K → 65 K characters) and pushed the plain call past the agent's tool-result cap — the result was spilled to a file the agent cannot open, so it re-called with other flags, and re-read the whole contract on the next turn too (session review 2026-09-27). Output is now always compact, and `slideSchemas` — two thirds of a multi-layout template — is a menu until you name the layouts.
115
+
111
116
  This mirrors the list/detail split described in `scripts/registry_loader.py` (P1.2), realized CLI-side so it holds even while the backend still serves one merged registry payload.
112
117
 
113
118
  ## Props extraction rules
@@ -32,10 +32,27 @@ List vs. detail (why ``--json-output`` no longer dumps everything):
32
32
  programmatic callers, guarded by a size check so it can't silently recreate
33
33
  the overflow.
34
34
 
35
+ Slide menu (why --template-id no longer returns every slideSchemas body):
36
+ slideSchemas is two thirds of a multi-layout template (html-slide: 24.6 K of
37
+ 37.2 K chars, 26 layouts), while one video uses 5–9 layouts. Returning all of
38
+ it every time pushed the detail view past the agent's tool-result size cap
39
+ (the result got spilled to a file the agent cannot open, so it re-called
40
+ with other flags), and the agent re-read the whole thing on the next turn
41
+ anyway. So detail mode returns a per-layout *menu* — description, field
42
+ names, list key / item fields / maxItems, and whether narration must be
43
+ split into items — which is enough to choose layouts and draft a script;
44
+ ``--slide-id`` then returns the exact schemas of the chosen layouts for
45
+ writing ``templateData``. Registry consumers inside the pipeline (validator,
46
+ render) read the registry directly and are unaffected.
47
+
35
48
  Flags:
36
49
  --list-templates (accepted; listing is the default verb)
37
50
  --template-id <id> print the FULL definition of this template
38
- (repeatable); this is the detail verb
51
+ (repeatable); this is the detail verb.
52
+ Multi-layout templates come back with a
53
+ slideSchemas *menu* (see "Slide menu" below)
54
+ --slide-id <slideId|all> detail mode: expand these layouts' full
55
+ templateData schemas (repeatable)
39
56
  --full in list mode, emit full definitions instead
40
57
  of summaries (size-guarded; prefer
41
58
  --template-id)
@@ -159,6 +176,96 @@ def _detail_hint(template_id: str = "<templateId>") -> str:
159
176
  )
160
177
 
161
178
 
179
+ # ----- Slide menu (--template-id without --slide-id) -------------------------
180
+
181
+ _SLIDE_EXPAND_ALL = "all"
182
+
183
+
184
+ def _slide_schema_ids(tpl: dict) -> list[str]:
185
+ ss = tpl.get("slideSchemas")
186
+ if not isinstance(ss, dict):
187
+ return []
188
+ return [k for k, v in ss.items() if not k.startswith("_") and isinstance(v, dict)]
189
+
190
+
191
+ def _menu_entry(schema: dict, line_sync_key: str | None) -> dict:
192
+ """One layout's menu row: enough to pick it and draft a script, not to fill it."""
193
+ props = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
194
+ entry: dict = {}
195
+ if schema.get("description"):
196
+ entry["description"] = schema["description"]
197
+ entry["fields"] = list(props.keys())
198
+ if schema.get("required"):
199
+ entry["required"] = schema["required"]
200
+ # The card list: the line-sync key when declared, else the first array of objects.
201
+ list_key = line_sync_key if line_sync_key in props else None
202
+ if list_key is None:
203
+ for k, v in props.items():
204
+ if isinstance(v, dict) and v.get("type") == "array" and isinstance(v.get("items"), dict) \
205
+ and v["items"].get("type") == "object":
206
+ list_key = k
207
+ break
208
+ if list_key is not None:
209
+ arr = props[list_key]
210
+ item = arr.get("items") if isinstance(arr.get("items"), dict) else {}
211
+ item_props = item.get("properties") if isinstance(item.get("properties"), dict) else {}
212
+ lst = {"key": list_key, "itemFields": list(item_props.keys())}
213
+ if item.get("required"):
214
+ lst["itemRequired"] = item["required"]
215
+ if "maxItems" in arr:
216
+ lst["maxItems"] = arr["maxItems"]
217
+ entry["list"] = lst
218
+ if line_sync_key:
219
+ # The single most expensive miss in practice: authoring one narration
220
+ # block for a layout that lights cards one by one (validator rejects it).
221
+ entry["narration"] = (
222
+ f"items — one audio.narration.items line per templateData.{line_sync_key} entry, no text"
223
+ )
224
+ static = [k for k, v in props.items() if isinstance(v, dict) and v.get("x-staticHighlight")]
225
+ if static:
226
+ entry["narrationStaticAlternative"] = f"set templateData.{static[0]} to keep it static instead"
227
+ return entry
228
+
229
+
230
+ def _apply_slide_view(tpl: dict, slide_ids: list[str] | None) -> dict:
231
+ """Return tpl with slideSchemas reduced to a menu, or to the requested layouts."""
232
+ ss = tpl.get("slideSchemas")
233
+ if not isinstance(ss, dict):
234
+ return tpl
235
+ ids = _slide_schema_ids(tpl)
236
+ out = dict(tpl)
237
+ if slide_ids and _SLIDE_EXPAND_ALL in slide_ids:
238
+ out["slideSchemas"] = {k: v for k, v in ss.items() if k != "_comment"}
239
+ return out
240
+ line_sync = tpl.get("lineSyncSlides") if isinstance(tpl.get("lineSyncSlides"), dict) else {}
241
+ if slide_ids:
242
+ view: dict = {
243
+ "_view": (
244
+ f"Full templateData schemas for: {', '.join(slide_ids)}. Copy field names from here. "
245
+ "Other layouts are omitted — pass their ids in slide_id to read them."
246
+ ),
247
+ }
248
+ if isinstance(ss.get("_shared"), dict):
249
+ view["_shared"] = ss["_shared"]
250
+ for sid in slide_ids:
251
+ view[sid] = ss[sid]
252
+ out["slideSchemas"] = view
253
+ return out
254
+ menu: dict = {
255
+ "_view": (
256
+ "MENU ONLY — per-layout summary, not the full schema. Use it to choose layouts and draft the "
257
+ "script. Before writing templateData, call template_registry again with this template_id and "
258
+ "slide_id=<each chosen layout> (repeatable) to get their exact field schemas. "
259
+ "Tool results do not carry over between chat turns: fetch the menu while drafting the script, slide_id while writing templateData."
260
+ ),
261
+ }
262
+ for sid in ids:
263
+ lk = line_sync.get(sid)
264
+ menu[sid] = _menu_entry(ss[sid], lk if isinstance(lk, str) else None)
265
+ out["slideSchemas"] = menu
266
+ return out
267
+
268
+
162
269
  # ----- Examples discovery (--list-examples) ---------------------------------
163
270
  #
164
271
  # Examples live under <SkillDir>/video_dsl/schema/examples/ as `*.dsl.json` and
@@ -217,6 +324,9 @@ def main() -> None:
217
324
  help="List every available template (summaries; the default verb).")
218
325
  ap.add_argument("--template-id", action="append", dest="template_ids", metavar="ID",
219
326
  help="Print this template's FULL definition (repeatable).")
327
+ ap.add_argument("--slide-id", action="append", dest="slide_ids", metavar="SLIDE_ID",
328
+ help="Detail mode: expand these layouts' full slideSchemas (repeatable; "
329
+ "'all' = every layout). Without it multi-layout templates return a menu.")
220
330
  ap.add_argument("--full", action="store_true",
221
331
  help="List mode: emit full definitions instead of summaries "
222
332
  "(size-guarded; prefer --template-id).")
@@ -326,13 +436,28 @@ def main() -> None:
326
436
  known = ", ".join(sorted(str(t.get("templateId")) for t in templates))
327
437
  print(f" Available ids: {known}", file=sys.stderr)
328
438
  sys.exit(1)
329
- if args.json_output:
330
- print(json.dumps({"templates": picked}, ensure_ascii=False))
331
- else:
332
- # Pretty-printed rather than tabular: the whole point of detail mode
333
- # is the nested contract (customPayloadSchema / slotMapping), which
334
- # a table cannot show.
335
- print(json.dumps({"templates": picked}, ensure_ascii=False, indent=2))
439
+ if args.slide_ids:
440
+ wanted = [s for s in args.slide_ids if s != _SLIDE_EXPAND_ALL]
441
+ known_slides = {sid for t in picked for sid in _slide_schema_ids(t)}
442
+ if not known_slides:
443
+ print("⚠️ --slide-id ignored: this template has no slideSchemas (single layout).",
444
+ file=sys.stderr)
445
+ else:
446
+ unknown = [s for s in wanted if s not in known_slides]
447
+ if unknown:
448
+ print(f"❌ Unknown slide id(s): {', '.join(unknown)}", file=sys.stderr)
449
+ print(f" Legal slide ids: {', '.join(sorted(known_slides))}", file=sys.stderr)
450
+ sys.exit(1)
451
+ picked = [
452
+ _apply_slide_view(t, [s for s in (args.slide_ids or []) if s in _slide_schema_ids(t)
453
+ or s == _SLIDE_EXPAND_ALL] or None)
454
+ for t in picked
455
+ ]
456
+ # Always compact, with or without --json-output. Every caller is an LLM
457
+ # tool call: indent=2 nearly doubled the size (html-slide 37 K → 65 K
458
+ # chars), which alone pushed the plain call past the tool-result cap and
459
+ # got it spilled to a file the agent cannot read. Humans can `| jq`.
460
+ print(json.dumps({"templates": picked}, ensure_ascii=False))
336
461
  return
337
462
 
338
463
  visible = [
@@ -12,9 +12,9 @@
12
12
  "type": "object",
13
13
  "properties": {
14
14
  "list_templates": { "type": "boolean", "description": "List available templates as summaries — templateId / name / description / aspect ratios / language / status / styleTags / variantIds / key capabilities / a truncated llmHint (default behavior; also implied when other flags are passed)." },
15
- "template_id": { "type": "string", "description": "Return this template's FULL definition instead of the summary list: llmHint in full, customPayloadSchema (every template-specific field incl. the legal slideId values), slotMapping, compositions, variants. This is the per-template authoring contract — read it before writing DSL for that template." },
15
+ "template_id": { "type": "string", "description": "Return this template's full authoring contract instead of the summary list: llmHint in full, customPayloadSchema (every template-specific field incl. the legal slideId values), slotMapping, compositions, variants. Multi-layout templates return slideSchemas as a MENU (per layout: description, field names, card-list key / item fields / maxItems, and whether narration must be split into items) — enough to pick layouts and draft the script. Tool results do not carry over between chat turns: read the menu in the turn that drafts the script; the turn that writes templateData only needs slide_id." },
16
+ "slide_id": { "type": "array", "items": { "type": "string" }, "description": "With template_id: return the FULL templateData schema for these layouts (the slideIds you chose from the menu). Call this once, right before writing templateData, with every layout the script uses. 'all' returns every layout (large — avoid)." },
16
17
  "full": { "type": "boolean", "description": "List mode only: emit full definitions for every listed template instead of summaries. Rejected when the result would be too large for one tool result — prefer template_id, or narrow with the filter_* parameters." },
17
- "list_examples": { "type": "boolean", "description": "List the *.dsl.json / *.binding.json reference examples under template-registry/video_dsl/schema/examples/, grouped by templateId. That directory is optional and is not part of the published package, so this is normally empty — to read a template's reference shape, use template_id instead (the registry's own contract)." },
18
18
  "filter_tag": { "type": "string", "description": "Show only templates whose styleTags contain (or are contained in) this string. Case-insensitive. E.g. 'tech' / '科普' / 'walkthrough'." },
19
19
  "filter_aspect": { "type": "string", "description": "Show only templates that declare this aspect ratio. E.g. '9:16' / '16:9' / '1:1'." },
20
20
  "filter_language": { "type": "string", "description": "Show only templates whose contentLanguage includes this code ('zh' or 'en'). Templates with no declared language are always shown (treated as language-agnostic)." },
@@ -2,6 +2,6 @@
2
2
  "skillName": "template-registry",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "475",
5
- "version": "V23",
5
+ "version": "V24",
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
  }
@@ -822,6 +822,66 @@ def _check_step_narration_alignment(dsl: dict) -> list[ValidationError]:
822
822
  return errors
823
823
 
824
824
 
825
+ def _check_duration_estimate(dsl: dict) -> list[ValidationError]:
826
+ """按旁白字数估出的成片时长明显超过 ``meta.targetDuration`` → warning。
827
+
828
+ 为什么要有:旁白字数决定时长,但没有任何一环在出素材之前核对过。2026-09-27 线上
829
+ 一条 html-slide 会话,确认稿写 75s、每场 8–9s,每场却写了 45–58 字,还有 7 场是
830
+ 分行旁白(每行之间有停顿)—— 成片 116.9s,用户确认的是一个从来不可能兑现的数。
831
+
832
+ 只报 warning 不拦:走到这里用户已经确认过脚本,再拦只会多一轮来回;它存在的意义
833
+ 是让这件事**有人看见**(模型据此如实告知用户,复盘据此定位)。估算口径见
834
+ ``narration_budget``。骨架占位旁白还没填时不估 —— 那是另一条检查的事。
835
+ """
836
+ try:
837
+ from . import narration_budget as nb
838
+ except ImportError:
839
+ return []
840
+
841
+ meta = dsl.get("meta") or {}
842
+ target = meta.get("targetDuration")
843
+ if isinstance(target, bool) or not isinstance(target, (int, float)) or target <= 0:
844
+ return []
845
+ narration_cfg = ((dsl.get("global") or {}).get("narration") or {})
846
+ speed = narration_cfg.get("speed")
847
+ speed = float(speed) if isinstance(speed, (int, float)) and not isinstance(speed, bool) and speed > 0 else 1.0
848
+ tail = nb.template_tail_pad_sec(_get_template_cfg(_pick_template_id(dsl)))
849
+
850
+ total = 0.0
851
+ narrated = 0
852
+ overruns: list[tuple[float, str, float, float]] = []
853
+ for idx, scene in enumerate(dsl.get("scenes", []) or []):
854
+ narration = (scene.get("audio") or {}).get("narration") or {}
855
+ lines = nb.narration_lines(narration)
856
+ if any(line.startswith(("[骨架待填充]", "[skeleton placeholder]")) for line in lines):
857
+ return []
858
+ planned = scene.get("duration")
859
+ planned = float(planned) if isinstance(planned, (int, float)) and not isinstance(planned, bool) else 0.0
860
+ est = nb.estimate_scene_sec(narration, speed, tail)
861
+ if est > 0:
862
+ narrated += 1
863
+ if planned > 0 and est - planned > 1.0:
864
+ overruns.append((est - planned, scene.get("id", f"scenes[{idx}]"), est, planned))
865
+ total += max(est, planned) if est > 0 else planned
866
+
867
+ if not narrated or total <= target * (1 + nb.OVERRUN_WARN_RATIO):
868
+ return []
869
+
870
+ overruns.sort(reverse=True)
871
+ detail = "; ".join(f"{sid} ≈{est:.0f}s (planned {planned:.0f}s)" for _, sid, est, planned in overruns[:5])
872
+ return [ValidationError(
873
+ "meta.targetDuration",
874
+ f"estimated video length ≈{total:.0f}s vs target {target:.0f}s "
875
+ f"(+{(total / target - 1) * 100:.0f}%) at narration speed {speed:g}× — "
876
+ f"~{nb.PLAN_CHARS_PER_SEC_1X * speed:.1f} zh chars/s, +{nb.LINE_PAUSE_SEC}s per extra "
877
+ f"narration line, +{tail:g}s tail per scene. Longest overruns: {detail or 'n/a'}. "
878
+ "Report the real length to the user (not the target) when you show the result; "
879
+ "narration drives duration, so to hit the target the narration must get shorter "
880
+ "or use fewer items.",
881
+ severity="warning",
882
+ )]
883
+
884
+
825
885
  def _check_slide_payload_fields(dsl: dict) -> list[ValidationError]:
826
886
  """逐版式校验 ``customPayload.templateData`` 的字段名。
827
887
 
@@ -1269,6 +1329,8 @@ def validate_integrity(dsl: dict) -> list[ValidationError]:
1269
1329
  errors += _check_step_narration_alignment(dsl)
1270
1330
  errors += _check_slide_cross_field(dsl)
1271
1331
  errors += _check_text_layer_rules(dsl)
1332
+ # warning,不阻断。见 _check_duration_estimate。
1333
+ errors += _check_duration_estimate(dsl)
1272
1334
  return errors
1273
1335
 
1274
1336
 
@@ -1304,4 +1366,6 @@ def validate_dsl(dsl: dict) -> list[ValidationError]:
1304
1366
  errors += _check_step_narration_alignment(dsl)
1305
1367
  errors += _check_slide_cross_field(dsl)
1306
1368
  errors += _check_text_layer_rules(dsl)
1369
+ # warning,不阻断。见 _check_duration_estimate。
1370
+ errors += _check_duration_estimate(dsl)
1307
1371
  return errors
@@ -0,0 +1,129 @@
1
+ """
2
+ 旁白时长预算 —— 规划期(写脚本时)估算「这段旁白念完、这一场实际会有多长」。
3
+
4
+ 为什么单独成模块:以前这个数有三份且互不一致 —— gen_script 的 ``CHARS_PER_SECOND = 5.0``
5
+ (从没参与计算,只出现在注释里)、gen-script SKILL.md 的「3–4 字/秒」、timeline_compiler
6
+ 的 5.0(那份是没有 TTS 时间戳时估字幕时长的兜底,语义不同,不归这里管)。没有任何一环
7
+ 真的拿旁白字数去核对场景时长,于是 2026-09-27 线上一条会话:确认稿写 75s、每场 8–9s,
8
+ 每场却写了 45–58 字,成片 116.9s(+56%),用户确认的时长从一开始就不可能兑现。
9
+
10
+ 模型 = 语速 + 行间停顿 + 场景尾巴,三项都按那条会话的 9 条 TTS 实测标定:
11
+
12
+ * 语速:纯整段旁白的场景 1.1× 下 ≈ 4.8–5.2 计数字/秒(汉字 + 英文词×2)→ 1.0× 取
13
+ ``PLAN_CHARS_PER_SEC_1X = 4.5``(略偏保守);
14
+ * 行间停顿:分行旁白(``narration.items`` / intro / outro)每多一行,TTS 按段合成,
15
+ 段间约 0.9s 的静音 —— 逐项高亮版式同样字数要慢出 30–60%,主要就是它;
16
+ * 场景尾巴:``render_video.adjust_timeline_to_audio`` 在每场音频后补的留白
17
+ (模板 ``capabilities.defaultTailPadSec``,缺省 1.5s)。9 场就是 13.5s,写脚本时
18
+ 从没人算进去。
19
+
20
+ 样本只有一条会话,所以这些是**规划用**的估计,不是承诺;估不准的方向应当是偏保守
21
+ (宁可让模型少写几个字)。英文语速未标定,先按常见 TTS 语速给。
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import math
27
+ import re
28
+ from typing import Optional
29
+
30
+ # 语速(1.0× 下)。中文按「计数字」(见 count_speech_units),英文按词。
31
+ PLAN_CHARS_PER_SEC_1X = 4.5
32
+ PLAN_WORDS_PER_SEC_1X = 2.5 # 未标定
33
+
34
+ # 分行旁白每一处换行带来的静音。
35
+ LINE_PAUSE_SEC = 0.9
36
+
37
+ # 必须与 render_video.adjust_timeline_to_audio 的缺省尾巴一致(int(fps * 1.5))。
38
+ DEFAULT_TAIL_PAD_SEC = 1.5
39
+
40
+ # 估算总时长超出目标多少才告警。低于这个幅度属于估算误差本身的量级。
41
+ OVERRUN_WARN_RATIO = 0.3
42
+
43
+ _CJK = re.compile(r"[\u3400-\u9fff\uf900-\ufaff]")
44
+ # 中文旁白里夹的英文词 / 数字组("AI Agent"、"2026")。一个英文词 TTS 念出来大约
45
+ # 占两个汉字的时长。
46
+ _LATIN_WORD = re.compile(r"[A-Za-z0-9]+(?:['’.-][A-Za-z0-9]+)*")
47
+
48
+
49
+ def count_speech_units(text: str) -> tuple[float, str]:
50
+ """返回 (计数, 语言)。有汉字按中文计(汉字 + 英文词×2),否则按英文词计。"""
51
+ if not isinstance(text, str) or not text.strip():
52
+ return 0.0, "zh"
53
+ cjk = len(_CJK.findall(text))
54
+ words = len(_LATIN_WORD.findall(text))
55
+ if cjk:
56
+ return float(cjk + 2 * words), "zh"
57
+ return float(words), "en"
58
+
59
+
60
+ def narration_lines(narration: object) -> list[str]:
61
+ """旁白按 TTS 实际合成的段拆开:分行形态 = intro + items + outro,否则整段 text。
62
+
63
+ 与 render_video.extract_narration_lines 同口径:有 items 时 text 被整个忽略。
64
+ """
65
+ if not isinstance(narration, dict):
66
+ return []
67
+ items = narration.get("items")
68
+ lines: list[str] = []
69
+ if isinstance(items, list):
70
+ for x in items:
71
+ t = x.get("text", "") if isinstance(x, dict) else x
72
+ if isinstance(t, str) and t.strip():
73
+ lines.append(t.strip())
74
+ if lines:
75
+ for key, at_front in (("intro", True), ("outro", False)):
76
+ v = narration.get(key)
77
+ if isinstance(v, str) and v.strip():
78
+ if at_front:
79
+ lines.insert(0, v.strip())
80
+ else:
81
+ lines.append(v.strip())
82
+ return lines
83
+ text = narration.get("text")
84
+ return [text.strip()] if isinstance(text, str) and text.strip() else []
85
+
86
+
87
+ def speech_rate(language: str, speed: float) -> float:
88
+ base = PLAN_CHARS_PER_SEC_1X if language == "zh" else PLAN_WORDS_PER_SEC_1X
89
+ return base * (speed if speed and speed > 0 else 1.0)
90
+
91
+
92
+ def estimate_speech_sec(narration: object, speed: float = 1.0) -> float:
93
+ """念完这段旁白要多少秒(含分行停顿,不含场景尾巴)。"""
94
+ lines = narration_lines(narration)
95
+ if not lines:
96
+ return 0.0
97
+ units, language = count_speech_units("\n".join(lines))
98
+ return units / speech_rate(language, speed) + LINE_PAUSE_SEC * (len(lines) - 1)
99
+
100
+
101
+ def template_tail_pad_sec(template_config: Optional[dict]) -> float:
102
+ """场景尾巴:模板声明的 capabilities.defaultTailPadSec,缺省 1.5s。"""
103
+ caps = (template_config or {}).get("capabilities") or {}
104
+ v = caps.get("defaultTailPadSec") if isinstance(caps, dict) else None
105
+ if isinstance(v, bool) or not isinstance(v, (int, float)) or v < 0:
106
+ return DEFAULT_TAIL_PAD_SEC
107
+ return float(v)
108
+
109
+
110
+ def estimate_scene_sec(narration: object, speed: float = 1.0, tail_pad_sec: float = DEFAULT_TAIL_PAD_SEC) -> float:
111
+ """这一场按旁白估出来的时长。没有旁白返回 0(由调用方退回场景自己的 duration)。"""
112
+ speech = estimate_speech_sec(narration, speed)
113
+ return speech + tail_pad_sec if speech > 0 else 0.0
114
+
115
+
116
+ def speech_budget(
117
+ duration_sec: float,
118
+ speed: float = 1.0,
119
+ language: str = "zh",
120
+ lines: int = 1,
121
+ tail_pad_sec: float = DEFAULT_TAIL_PAD_SEC,
122
+ ) -> int:
123
+ """一场 ``duration_sec`` 秒的场景,旁白最多写多少字(中文)/ 词(英文)。
124
+
125
+ ``lines`` > 1 时扣掉分行停顿 —— 逐项高亮的版式同样秒数能写的字明显更少。
126
+ 至少返回 1:预算为 0 的场景说明秒数本身就不够,调用方应当提示加时长而不是写空旁白。
127
+ """
128
+ available = duration_sec - tail_pad_sec - LINE_PAUSE_SEC * max(lines - 1, 0)
129
+ return max(1, int(math.floor(available * speech_rate(language, speed))))