@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.
- package/dist/manifest.json +11 -8
- package/package.json +3 -2
- package/skills/gen-script/SKILL.md +6 -7
- package/skills/gen-script/scripts/gen_script.py +93 -16
- package/skills/gen-script/version.json +1 -1
- package/skills/template-registry/README.md +1 -1
- package/skills/template-registry/SKILL.md +9 -4
- package/skills/template-registry/scripts/list_templates.py +133 -8
- package/skills/template-registry/skill.json +2 -2
- package/skills/template-registry/version.json +1 -1
- package/skills/template-registry/video_dsl/runtime/dsl_validator.py +64 -0
- package/skills/template-registry/video_dsl/runtime/narration_budget.py +129 -0
package/dist/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"generatedAt": "2026-09-
|
|
3
|
+
"version": "0.9.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
|
|
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.
|
|
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:
|
|
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)
|
|
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>`
|
|
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.
|
|
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**:
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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 = {
|
|
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(
|
|
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": "
|
|
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
|
|
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
|
|
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
|
|
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` |
|
|
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>
|
|
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.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
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": "
|
|
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))))
|