@remixmate/cli 0.9.43 → 0.9.45

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.43",
4
- "generatedAt": "2026-09-22T14:20:35.434Z",
3
+ "version": "0.9.45",
4
+ "generatedAt": "2026-09-23T05:34:40.922Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "doc-parse",
@@ -14,7 +14,7 @@
14
14
  "Turn this PPT / slide deck into a video",
15
15
  "Extract text or speaker notes from a .pptx",
16
16
  "Use a PowerPoint file as source material",
17
- "Get customPayload.slide for ppt-to-video"
17
+ "Read the page count and speaker notes of a slide deck"
18
18
  ],
19
19
  "entry": {
20
20
  "type": "python",
@@ -437,7 +437,7 @@
437
437
  },
438
438
  "scenes": {
439
439
  "type": "number",
440
- "description": "Scene count"
440
+ "description": "Scene count. Only applies to templates with capabilities.sceneStrategy=arc (the default); single / fixed / countdown templates own their own count. Bounded below by 3 and above by the template's capabilities.maxScenes (default 20) — a clamp is reported on stderr and is NOT an error, so always read the real count back from the returned DSL (scenes[].length) before describing the script to the user. For a template that maps one scene per source page (e.g. ppt-to-video), pass the page count you saw in doc_parse's summary."
441
441
  },
442
442
  "voice_id": {
443
443
  "type": "string",
@@ -451,6 +451,10 @@
451
451
  "type": "string",
452
452
  "description": "Template id (e.g. html-slide). The template owns outputLanguage and may also declare a defaultVoiceId; both flow into the produced DSL."
453
453
  },
454
+ "source_document_url": {
455
+ "type": "string",
456
+ "description": "Source document URL (.pptx) for a template that builds its per-scene picture from that document (capabilities.payloadHydration — today: ppt-to-video). **Must** pass it for such a template: it is recorded at meta.sourceDocument, and prepare_video_assets then fetches the layout itself and fills every scene's customPayload.slide. Pass the same URL you gave doc_parse. You are NOT expected to author or even read that layout data — one page is hundreds of KB, so it never travels through the conversation; without this reference the DSL is rejected downstream after you have already written all the narration."
457
+ },
454
458
  "headline": {
455
459
  "type": "string",
456
460
  "description": "On-screen headline. Stored at meta.headline and pushed into every scene's textLayers[role=headline] so the template can render it as the top big-text. **Must** be set when the user explicitly provided a headline / main title; without it, headline falls back to the long-form topic and overflows the top text layer. This is on-screen copy, so write it for a viewer who has never heard of the subject — a bare identifier (repo name, file name, product code) is not a title. Length limits, line breaks (\\n) and emphasis syntax are per-template: follow the chosen template's llmHint, which gen_script prints in full on stderr."
@@ -517,6 +521,7 @@
517
521
  "voice_id",
518
522
  "speed",
519
523
  "template_id",
524
+ "source_document_url",
520
525
  "headline",
521
526
  "subheadline",
522
527
  "carousel_items",
@@ -777,6 +782,10 @@
777
782
  "type": "string",
778
783
  "description": "Template id. When provided, the script invokes template-registry internally; no separate binding step is needed."
779
784
  },
785
+ "source_document_url": {
786
+ "type": "string",
787
+ "description": "Source document URL (.pptx) for a template that builds its per-scene picture from that document (capabilities.payloadHydration — today: ppt-to-video). Normally unnecessary: gen_script already recorded it at meta.sourceDocument when you passed source_document_url there, and the cached skeleton carries it through. Pass it here only to recover when the skeleton lacks the reference — it saves a full gen_script round trip. The layout data is fetched here and written into every scene's customPayload.slide; you neither author nor see it."
788
+ },
780
789
  "binding_json": {
781
790
  "type": "string",
782
791
  "description": "TemplateBinding JSON as an inline string (alternative to template_id; no file needed)."
@@ -806,6 +815,7 @@
806
815
  "dsl_json",
807
816
  "dsl",
808
817
  "template_id",
818
+ "source_document_url",
809
819
  "binding_json",
810
820
  "binding",
811
821
  "save_job",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.43",
3
+ "version": "0.9.45",
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",
@@ -13,14 +13,13 @@ description: |
13
13
  - Turn this PPT / slide deck into a video
14
14
  - Read / extract / summarize a .pptx, pull the text or the speaker notes out of a deck
15
15
  - Use a PowerPoint file as source material for a script
16
- - Anything that needs `customPayload.slide` for the `ppt-to-video` template
17
16
 
18
- Never hand-write `slide` nodes for `ppt-to-video`. A page has dozens of shapes with exact EMU coordinates; transcribing them by eye is guaranteed to drift from the original, and the template's own hint says so.
17
+ You call this skill for the **words** (notes, body text) and for the **page inventory** (how many pages, each page's animation tier and beat count). You do not call it to obtain `customPayload.slide` — that payload never passes through you; see "Using the result".
19
18
  triggers:
20
19
  - Turn this PPT / slide deck into a video
21
20
  - Extract text or speaker notes from a .pptx
22
21
  - Use a PowerPoint file as source material
23
- - Get customPayload.slide for ppt-to-video
22
+ - Read the page count and speaker notes of a slide deck
24
23
  ---
25
24
 
26
25
  # Document Extraction Skill (`doc_parse`)
@@ -50,7 +49,7 @@ That default matters: one page of `layout` with embedded icons can be hundreds o
50
49
 
51
50
  ## Usage
52
51
 
53
- Whole deck, both layers (what `ppt-to-video` needs):
52
+ Whole deck, both layers. The layout layer is what makes the per-page summary useful — it is where `mode` and the beat count come from, and those drive how you cut the narration:
54
53
 
55
54
  ```bash
56
55
  python3 <SkillDir>/scripts/extract_via_render.py --url "https://oss.example.com/deck.pptx"
@@ -96,7 +95,38 @@ python3 <SkillDir>/scripts/extract_via_render.py --url "https://…/deck.pptx" -
96
95
 
97
96
  ## Using the result
98
97
 
99
- **For the picture** — feed `slides[i]` straight into the template's `customPayload.slide`. Do not edit the coordinates, and do not raise `mode`: the verdict comes from the extractor, which can see reconstruction problems you cannot (`modeReason` says why). You may lower it. Raising it requires `trustMeMode: true` and means you have personally checked that page.
98
+ ### For the picture — you don't. Pass the URL instead.
99
+
100
+ The `layout` layer is written to a file, and **in the agent runtime you have no file-reading tool** (`Read` / `Bash` / `Glob` are deliberately not exposed — see `mcp-tools.ts`). So the file on disk is not a handoff to you, and `web_read` will not open it either: it only accepts http/https.
101
+
102
+ That is on purpose. One page of layout with embedded icons runs to hundreds of KB; a 23-page deck is several MB. It would bury everything else in your context and you would still only be copying bytes.
103
+
104
+ So the picture travels **by reference**:
105
+
106
+ ```bash
107
+ # 1. here: read the words and count the pages
108
+ doc_parse --url "https://…/deck.pptx"
109
+
110
+ # 2. pass the SAME url to gen_script, plus one scene per page
111
+ gen_script --template-id ppt-to-video \
112
+ --source-document-url "https://…/deck.pptx" \
113
+ --scenes 23
114
+ ```
115
+
116
+ `gen_script` records it at `meta.sourceDocument`, and `prepare_video_assets` then fetches the layout itself and fills every scene's `customPayload.slide` before validation. You never see that data, and you never hand-write it.
117
+
118
+ What you *do* own from this result:
119
+
120
+ | You read | You do |
121
+ | --- | --- |
122
+ | page count | pass it as `--scenes` (then read the real count back off the returned DSL) |
123
+ | `rawNotes` / `text` per page | write the narration (see below) |
124
+ | `mode` + beat count per page | cut the narration into that many sentences |
125
+ | `flags` | tell the user which elements the extractor could not reconstruct |
126
+
127
+ Do not raise `mode`: the verdict comes from the extractor, which can see reconstruction problems you cannot (`modeReason` says why). You may lower it by writing `customPayload.mode`. Raising it requires `trustMeMode: true` and means you have personally checked that page.
128
+
129
+ > If `prepare_video_assets` ever tells you `customPayload.slide (required)`, the missing thing is not the payload — it is the **reference**. Re-run `gen_script` with `--source-document-url`, or pass `source_document_url` to `prepare_video_assets` to recover without regenerating the script.
100
130
 
101
131
  **For the words** — `rawNotes` and `text` are two different kinds of raw material, and they need different treatment:
102
132
 
@@ -2,7 +2,7 @@
2
2
  """
3
3
  文档抽取脚本 — 通过 ab-render HTTP 接口实现,本地不需要任何 Office/解析依赖。
4
4
 
5
- 调用链:
5
+ 调用链(实现在共享模块 ``doc_extract``):
6
6
  POST $RENDER_API_URL/extract → { taskId }
7
7
  POST $RENDER_API_URL/extractStatus → { status, progress, result?, error? }
8
8
 
@@ -14,6 +14,11 @@
14
14
  直接进对话正文会撑爆上下文,还会让用户在聊天记录里滚过一屏 base64。默认写文件 +
15
15
  打一份人能读的摘要;确实要管道传全量时用 --json-output(或只要 content 层)。
16
16
 
17
+ ⚠️ 关于 layout 层的用法,看清楚这一条:**它不是给你读的**。在 ab-agent 里模型没有
18
+ 任何文件读取工具(这是有意的安全收窄),所以落盘的那个文件你打不开。需要 layout 的
19
+ 模板(ppt-to-video)按引用取数:把同一个文档 URL 传给 ``gen_script --source-document-url``,
20
+ 渲染管线会自己把每一页填进 ``customPayload.slide``。见 SKILL.md「Using the result」。
21
+
17
22
  必要环境变量(由 ab-agent 自动注入):
18
23
  RENDER_API_URL ab-render 服务地址(默认 https://api-render.remixmate.ai)
19
24
  PRIV_TOKEN ab-api 私有 token
@@ -26,99 +31,22 @@ import argparse
26
31
  import json
27
32
  import os
28
33
  import sys
29
- import time
30
- import urllib.error
31
- import urllib.request
32
-
33
- # ─── 环境变量 ─────────────────────────────────────────────────────────────────
34
-
35
- RENDER_API_URL = (
36
- os.environ.get("RENDER_API_URL")
37
- or os.environ.get("REMOTION_RENDER_API_URL")
38
- or "https://api-render.remixmate.ai"
39
- ).rstrip("/")
40
- PRIV_TOKEN = os.environ.get("PRIV_TOKEN", "")
41
- CONVERSATION_ID = os.environ.get("CONVERSATION_ID", "")
42
-
43
- # ─── HTTP 工具 ────────────────────────────────────────────────────────────────
44
-
45
-
46
- def api_post(path: str, body: dict, timeout: int = 30) -> dict:
47
- """向 ab-render 发送 POST 请求,返回解析后的 JSON。"""
48
- url = f"{RENDER_API_URL}{path}"
49
- data = json.dumps(body).encode()
50
-
51
- headers: dict[str, str] = {
52
- "Content-Type": "application/json",
53
- "X-Priv-Token": PRIV_TOKEN,
54
- }
55
- if CONVERSATION_ID:
56
- headers["x-conversation-id"] = CONVERSATION_ID
57
-
58
- req = urllib.request.Request(url, data=data, headers=headers, method="POST")
59
- try:
60
- with urllib.request.urlopen(req, timeout=timeout) as resp:
61
- return json.loads(resp.read())
62
- except urllib.error.HTTPError as e:
63
- body_text = e.read().decode(errors="replace")
64
- raise RuntimeError(f"HTTP {e.code}: {body_text}") from e
65
-
66
-
67
- def log(msg: str, silent: bool = False) -> None:
68
- if not silent:
69
- print(msg, flush=True)
70
-
71
-
72
- # ─── 摘要 ─────────────────────────────────────────────────────────────────────
73
-
74
-
75
- def summarize(result: dict, out_path: str | None) -> str:
76
- """
77
- 人能读的一页纸。**这是这个 skill 存在的一半价值** —— 端点给的是全量结构,
78
- 决定"喂多少进上下文"是入口层的责任(设计文档 §4.4)。
79
-
80
- 每页一行:页码、档位、拍数、标题、讲稿来源。档位与拍数决定后面怎么写旁白,
81
- 讲稿来源决定走严格改写还是扩写,这两件是下一步真正要用的信息。
82
- """
83
- src = result.get("source", {})
84
- lines = [
85
- f"共 {src.get('slideCount', '?')} 页,本次抽到 {len(result.get('slides', []))} 页"
86
- f"(fields={'+'.join(src.get('fields', []))})",
87
- "",
88
- ]
89
-
90
- for s in result.get("slides", []):
91
- idx = s.get("slideIndex", "?")
92
- bits = [f"第 {idx} 页"]
93
-
94
- if s.get("mode"):
95
- beats = len(s.get("beats") or [])
96
- bits.append(f"{s['mode']}/{beats} 拍")
97
- if s.get("title"):
98
- bits.append(f"「{s['title']}」")
99
-
100
- notes = (s.get("rawNotes") or "").strip()
101
- if notes:
102
- head = notes.replace("\n", " ")[:40]
103
- bits.append(f"备注 {len(notes)} 字:{head}…")
104
- elif "rawNotes" in s:
105
- bits.append("无备注(旁白需按正文扩写)")
106
-
107
- lines.append(" " + " · ".join(bits))
108
-
109
- if s.get("modeReason"):
110
- lines.append(f" 判定:{s['modeReason']}")
111
- flags = s.get("flags") or []
112
- if flags:
113
- # flag 是显式的降级记录,不是日志 —— 画面上少一块,这里不说就没人知道为什么。
114
- lines.append(f" ⚠ 降级:{'; '.join(flags[:3])}")
115
34
 
116
- if out_path:
117
- lines += ["", f"完整结果已写入:{out_path}"]
118
- return "\n".join(lines)
35
+ # 共享 Python lib 在 skills/template-registry/scripts/(见 AGENTS.md 的约定)。
36
+ # 抽取的 submit/poll 逻辑住在那里而不是这里,因为渲染管线也要用同一份 —— 见
37
+ # doc_extract 模块头的说明。
38
+ sys.path.insert(
39
+ 0,
40
+ os.path.abspath(
41
+ os.path.join(os.path.dirname(__file__), "..", "..", "template-registry", "scripts")
42
+ ),
43
+ )
119
44
 
120
-
121
- # ─── 主逻辑 ───────────────────────────────────────────────────────────────────
45
+ from doc_extract import ( # noqa: E402
46
+ DocExtractError,
47
+ extract_document,
48
+ summarize,
49
+ )
122
50
 
123
51
 
124
52
  def main() -> None:
@@ -141,77 +69,46 @@ def main() -> None:
141
69
  )
142
70
  args = parser.parse_args()
143
71
 
144
- if not PRIV_TOKEN:
145
- print(
146
- "❌ PRIV_TOKEN env var is not configured — /extract requires auth.",
147
- file=sys.stderr,
148
- )
149
- sys.exit(1)
150
-
151
- fields = [f.strip() for f in args.fields.split(",") if f.strip()]
72
+ # 管道模式下 stdout 只能有那一份 JSON,进度行一律走 stderr。
73
+ def log(msg: str) -> None:
74
+ print(msg, file=sys.stderr if args.json_output else sys.stdout, flush=True)
152
75
 
153
- # ── 1. Submit ────────────────────────────────────────────────────────────
154
- log(f"[doc-parse] Submitting extraction: {args.url[:80]}", args.json_output)
155
-
156
- body: dict = {"documentUrl": args.url, "kind": args.kind, "fields": fields}
157
- if args.slide is not None:
158
- body["slide"] = args.slide
159
-
160
- resp = api_post("/extract", body)
161
- if resp.get("code") != 0:
162
- print(f"❌ submission failed: {resp.get('msg', 'unknown error')}", file=sys.stderr)
76
+ try:
77
+ result = extract_document(
78
+ args.url,
79
+ kind=args.kind,
80
+ fields=args.fields,
81
+ slide=args.slide,
82
+ log=log,
83
+ )
84
+ except DocExtractError as exc:
85
+ # 端点的错误已经是人话(格式不对/超页数/超体积),原样透出,不要再包一层。
86
+ print(f"❌ {exc}", file=sys.stderr)
163
87
  sys.exit(1)
164
88
 
165
- task_id: str = resp["data"]["taskId"]
166
- log(f"[doc-parse] task accepted, taskId={task_id}", args.json_output)
167
-
168
- # ── 2. Poll ──────────────────────────────────────────────────────────────
169
- max_wait_sec = 600
170
- poll_interval = 2
171
- elapsed = 0
89
+ if args.json_output:
90
+ print(json.dumps(result, ensure_ascii=False))
91
+ return
172
92
 
173
- while elapsed < max_wait_sec:
174
- time.sleep(poll_interval)
175
- elapsed += poll_interval
93
+ # 默认落盘 + 摘要:layout 层直接打进 stdout 会撑爆上下文(见文件头)。
94
+ out_path = args.out or f"./{(result.get('source') or {}).get('name', 'document')}.extract.json"
95
+ with open(out_path, "w", encoding="utf-8") as f:
96
+ json.dump(result, f, ensure_ascii=False, indent=2)
176
97
 
177
- try:
178
- status_resp = api_post("/extractStatus", {"taskId": task_id})
179
- except Exception as e: # noqa: BLE001
180
- log(f"[doc-parse] polling error (will retry): {e}", args.json_output)
181
- continue
98
+ print("\n✅ 抽取完成\n")
99
+ print(summarize(result, out_path))
182
100
 
183
- if status_resp.get("code") != 0:
184
- log(f"[doc-parse] polling error code (will retry): {status_resp.get('msg')}", args.json_output)
185
- continue
186
-
187
- task = status_resp["data"]
188
- status = task.get("status", "unknown")
189
-
190
- if status == "succeeded":
191
- result = task.get("result") or {}
192
-
193
- if args.json_output:
194
- print(json.dumps(result, ensure_ascii=False))
195
- return
196
-
197
- # 默认落盘 + 摘要:layout 层直接打进 stdout 会撑爆上下文(见文件头)。
198
- out_path = args.out or f"./{(result.get('source') or {}).get('name', 'document')}.extract.json"
199
- with open(out_path, "w", encoding="utf-8") as f:
200
- json.dump(result, f, ensure_ascii=False, indent=2)
201
-
202
- print("\n✅ 抽取完成\n")
203
- print(summarize(result, out_path))
204
- return
205
-
206
- if status == "failed":
207
- # 端点的错误已经是人话(格式不对/超页数/超体积),原样透出,不要再包一层。
208
- print(f"❌ extraction failed: {task.get('error', 'unknown error')}", file=sys.stderr)
209
- sys.exit(1)
210
-
211
- log(f"[doc-parse] status={status} elapsed={elapsed}s", args.json_output)
212
-
213
- print(f"❌ extraction timed out (over {max_wait_sec}s)", file=sys.stderr)
214
- sys.exit(1)
101
+ if "layout" in (result.get("source") or {}).get("fields", []):
102
+ # 这段是为了堵住一个具体的失败模式:上一版这里只说"已写入 <文件>",读到它的
103
+ # 模型会理所当然地去读那个文件,然后发现自己没有文件读取工具,接着就没有
104
+ # 第二条路了。所以把下一步明确写出来。
105
+ print(
106
+ "\n📎 下一步:**不要去读上面那个文件,也不要手写 slide 节点**。\n"
107
+ " 画面数据按引用进管线 —— 把同一个文档 URL 传给 gen_script:\n"
108
+ f" gen_script --template-id <id> --source-document-url \"{args.url[:100]}\"\n"
109
+ " 之后 prepare_video_assets 会自己把每一页填进对应场景的 customPayload.slide。\n"
110
+ " 你要写的是旁白(用上面每页的备注改写)与场景数(= 页数)。"
111
+ )
215
112
 
216
113
 
217
114
  if __name__ == "__main__":
@@ -62,6 +62,35 @@ The intent is to avoid the failure mode where "the generic DSL looks compatible
62
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
63
  8. After generation, run the schema check and show the script summary to the user for confirmation.
64
64
 
65
+ ### Document-driven templates: pass the source URL, don't author the payload
66
+
67
+ Some templates build each scene's picture from a source file rather than from JSON you write. They declare it as `capabilities.payloadHydration` (today: `ppt-to-video`, which fills `customPayload.slide` from a `.pptx`).
68
+
69
+ For those, pass **`--source-document-url`** — the same URL you gave `doc_parse`:
70
+
71
+ ```bash
72
+ gen_script --template-id ppt-to-video \
73
+ --source-document-url "https://…/deck.pptx" \
74
+ --scenes 23 --duration 600
75
+ ```
76
+
77
+ It is recorded at `meta.sourceDocument`, and `prepare_video_assets` fetches the layout itself and fills every scene before validation runs.
78
+
79
+ Two things follow, and both matter:
80
+
81
+ - **You cannot supply this payload yourself, so don't try.** One page of layout data is hundreds of KB. It is not in any tool result you can read, and the file `doc_parse` writes is not openable from the agent runtime. `customPayload.slide (required)` on every scene means the *reference* is missing, not the data.
82
+ - **The reference has to be set here, at `gen_script` time.** The agent layer merges your submitted `dsl_json` onto the cached skeleton and keeps `meta` / `global` from the skeleton — so writing `meta.sourceDocument` into `dsl_json` yourself is silently dropped. If you already generated a skeleton without it, either re-run `gen_script` or pass `source_document_url` to `prepare_video_assets`.
83
+
84
+ Scene count for these templates is **one scene per page**, taken from `doc_parse`'s summary. Such templates raise `capabilities.maxScenes` above the default 20 for exactly this reason.
85
+
86
+ ### Scene count: read it back, never assume it
87
+
88
+ `--scenes` is a request, not a guarantee. It is clamped to `[3, capabilities.maxScenes]` (default max 20), and a clamp is a **stderr warning, not an error** — the call still succeeds and returns a DSL with a different number of scenes than you asked for.
89
+
90
+ So after every call, take the scene count from `scenes[]` in the returned DSL. Do not describe scene 21 of a 20-scene DSL to the user. A 23-page deck once got clamped to 20 here; the agent wrote 23 scenes' worth of narration anyway, the user confirmed a 23-scene script, and the mismatch only surfaced three tool calls later as a payload error that said nothing about scene counts.
91
+
92
+ If the count you need is above the template's maximum, say so and stop — do not quietly ship fewer scenes than the source material has.
93
+
65
94
  ### Multi-layout templates: pick a layout per scene
66
95
 
67
96
  When a template's `customPayloadSchema.slideId.enum` holds more than one value (today `html-slide` and `html-slide-blackboard`, 16 layouts each), **every scene must name one**, chosen to fit that scene's information shape — a comparison, a timeline, a code walkthrough and a set of numbers are four different layouts.
@@ -94,6 +123,8 @@ Two consequences for the agent:
94
123
  - **Forbidden**: reusing a single `slideId` across the whole video on a multi-layout template because it was the first one in the enum.
95
124
  - **Forbidden**: the user specified a template, but the agent first generated a generic DSL and then passed `--template-id` to `template-registry` to force-bind it.
96
125
  - **Forbidden**: continuing into binding / rendering despite knowing that template-specific fields, scene shapes, or binding details are missing.
126
+ - **Forbidden**: showing the user a confirmation summary whose scene count differs from `scenes[]` in the returned DSL.
127
+ - **Forbidden**: omitting `--source-document-url` for a template that declares `capabilities.payloadHydration`, or trying to hand-write the payload it hydrates.
97
128
  - **Forbidden**: silently degrading to a generic DSL because the current script cannot support a template, leaving the failure to the downstream stage.
98
129
 
99
130
  ### When a template is not yet supported
@@ -65,6 +65,27 @@ try:
65
65
  except ImportError:
66
66
  _HAS_ENHANCER = False
67
67
 
68
+ # 场景数上限的唯一实现在 dsl_validator(它同时是门禁方)。守护式导入的理由同上:
69
+ # 共享目录可能不在(单测 / 裁剪安装),那时退回历史常量 20,行为与改动前一致。
70
+ try:
71
+ from video_dsl.runtime.dsl_validator import (
72
+ DEFAULT_MAX_SCENES,
73
+ max_scenes_for_template,
74
+ )
75
+ except ImportError:
76
+ DEFAULT_MAX_SCENES = 20
77
+
78
+ def max_scenes_for_template(template_config): # type: ignore[misc]
79
+ return DEFAULT_MAX_SCENES
80
+
81
+ # payloadHydration 声明的读取。gen_script 只用它来判断"这个模板需不需要源文件引用",
82
+ # 真正的取数发生在 render-video 侧(见 payload_hydrator 模块头)。
83
+ try:
84
+ from payload_hydrator import hydration_spec # noqa: E402
85
+ _HAS_HYDRATOR = True
86
+ except ImportError:
87
+ _HAS_HYDRATOR = False
88
+
68
89
  try:
69
90
  from registry_loader import (
70
91
  load_registry_data as _load_registry_data,
@@ -257,8 +278,23 @@ def validate_dsl(dsl: dict) -> list:
257
278
  return errors_as_strings(validate_structural(dsl))
258
279
 
259
280
 
260
- def plan_scenes(topic: str, duration: int, scene_count: int | None, allow_digital_human: bool, language: str = DEFAULT_LANGUAGE) -> list:
261
- """Plan scene structure based on topic and constraints."""
281
+ def plan_scenes(
282
+ topic: str,
283
+ duration: int,
284
+ scene_count: int | None,
285
+ allow_digital_human: bool,
286
+ language: str = DEFAULT_LANGUAGE,
287
+ max_scenes: int = DEFAULT_MAX_SCENES,
288
+ ) -> list:
289
+ """Plan scene structure based on topic and constraints.
290
+
291
+ ``max_scenes`` 来自模板的 ``capabilities.maxScenes``(见 ``_resolve_contract``)。
292
+ 被 clamp 时**必须出声**:调用方要的场景数和拿到的不一样,而这个差值一路传到
293
+ 下游才会以别的形态爆出来——2026-09-23 线上一份 23 页 PPT 在这里被静默砍到 20,
294
+ 模型接着写了 23 段旁白、让用户确认了 23 个场景,最后 prepare_video_assets 报的是
295
+ ``scenes[0..19].customPayload.slide (required)``,跟"场景被砍了"半点关系看不出来。
296
+ """
297
+ requested = scene_count
262
298
  if scene_count is None:
263
299
  if duration <= 15:
264
300
  scene_count = 3
@@ -269,7 +305,22 @@ def plan_scenes(topic: str, duration: int, scene_count: int | None, allow_digita
269
305
  else:
270
306
  scene_count = min(10, duration // 8)
271
307
 
272
- scene_count = max(3, min(scene_count, 20))
308
+ scene_count = max(3, min(scene_count, max_scenes))
309
+
310
+ if requested is not None and requested != scene_count:
311
+ detail = (
312
+ f"raised to the {scene_count}-scene minimum"
313
+ if requested < scene_count
314
+ else f"clamped to the template's maximum of {max_scenes}"
315
+ )
316
+ print(
317
+ f"⚠️ requested {requested} scene(s) but planned {scene_count}: {detail}.\n"
318
+ f" The DSL below has {scene_count} scenes — read the count back from it before "
319
+ f"showing the script to the user, and do not describe scenes that are not there.\n"
320
+ f" If this template needs one scene per source page, raise "
321
+ f"capabilities.maxScenes in its template.json.",
322
+ file=sys.stderr,
323
+ )
273
324
 
274
325
  scenes = []
275
326
  content_scenes = scene_count - 2
@@ -491,6 +542,8 @@ def _template_needs_image(template_config: dict | None) -> bool:
491
542
  # - needs_narration bool capabilities.needsNarration,默认 True
492
543
  # - scene_strategy str capabilities.sceneStrategy ∈ {arc, single, fixed, countdown},默认 arc
493
544
  # - fixed_scenes list[dict] capabilities.fixedScenes(single/fixed 用;缺省单 opening)
545
+ # - max_scenes int capabilities.maxScenes,默认 20(arc 的 --scenes 上限;
546
+ # "一页一场景"的模板由源文件页数决定场景数,应声明抬高)
494
547
  # - countdown dict|None countdown 策略的解析结果(见 countdown_spec 模块;
495
548
  # 非 countdown 模板为 None)
496
549
  # - payload_style str capabilities.payloadStyle ∈ {visual-overlay, slide, carousel-caption}
@@ -553,6 +606,8 @@ def _resolve_contract(template_config: dict | None) -> dict:
553
606
  "needs_narration": needs_narration,
554
607
  "scene_strategy": scene_strategy,
555
608
  "fixed_scenes": fixed_scenes,
609
+ # 上限读取放共享模块:dsl_validator 的门禁读的是同一个函数。
610
+ "max_scenes": max_scenes_for_template(template_config),
556
611
  # 声明解析放共享模块:render-video 的计划校验要读同一份声明,
557
612
  # 两边各抄一遍正是这个功能上一版栽过的跟头。
558
613
  "countdown": _resolve_countdown(template_config) if scene_strategy == "countdown" else None,
@@ -675,6 +730,46 @@ def _enforce_supported_duration(
675
730
  sys.exit(1)
676
731
 
677
732
 
733
+ def _record_source_document(
734
+ dsl: dict,
735
+ template_config: dict | None,
736
+ template_id: str | None,
737
+ source_document_url: str | None,
738
+ ) -> None:
739
+ """把源文件引用写进 ``meta.sourceDocument``,供渲染管线按引用取数。
740
+
741
+ 这个键必须由**骨架**携带,不能指望模型后面补:ab-agent 的合并层
742
+ (``applyNarrationToSkeleton``)以骨架为基底,只从模型提交的 dsl_json 里搬
743
+ narration 与 customPayload,``meta`` / ``global`` 归骨架所有。模型往 dsl_json 里写
744
+ ``meta.sourceDocument`` 会被静默丢掉。
745
+
746
+ 模板声明了 ``payloadHydration`` 却没拿到 URL 时在这里就警告,而不是等到
747
+ prepare_video_assets 才失败 —— 那时模型已经写完全部旁白并让用户确认过了。
748
+ """
749
+ if source_document_url:
750
+ dsl["meta"]["sourceDocument"] = {"url": source_document_url}
751
+ return
752
+
753
+ if not _HAS_HYDRATOR or not template_config:
754
+ return
755
+ try:
756
+ spec = hydration_spec(template_config)
757
+ except Exception: # noqa: BLE001 - 模板声明写坏了不该让脚本生成失败
758
+ return
759
+ if not spec:
760
+ return
761
+
762
+ print(
763
+ f"⚠️ template '{template_id}' builds customPayload.{spec['payload_field']} from a source "
764
+ f"document, but no --source-document-url was given.\n"
765
+ f" The skeleton below has no meta.sourceDocument, so prepare_video_assets will refuse it "
766
+ f"— and it will refuse it *after* you have written every scene's narration and asked the "
767
+ f"user to confirm.\n"
768
+ f" Re-run gen_script with --source-document-url <the same .pptx URL you gave doc_parse>.",
769
+ file=sys.stderr,
770
+ )
771
+
772
+
678
773
  def _plan_contract_scenes(
679
774
  contract: dict, topic: str, duration: int, scene_count: int | None, language: str
680
775
  ) -> list:
@@ -688,7 +783,11 @@ def _plan_contract_scenes(
688
783
  这类模板由此能产出真正的单场景,而不再被强行补到 3 段。``--scenes`` 只对 arc 生效。
689
784
  """
690
785
  if contract["scene_strategy"] == "arc":
691
- return plan_scenes(topic, duration, scene_count, False, language=language)
786
+ return plan_scenes(
787
+ topic, duration, scene_count, False,
788
+ language=language,
789
+ max_scenes=contract.get("max_scenes", DEFAULT_MAX_SCENES),
790
+ )
692
791
 
693
792
  fixed = contract["fixed_scenes"] or [{"purpose": "opening"}]
694
793
  n = len(fixed)
@@ -1154,6 +1253,7 @@ def build_dsl(
1154
1253
  caption_lines: list[str] | None = None,
1155
1254
  scene_images: list[str] | None = None,
1156
1255
  item_count: int | None = None,
1256
+ source_document_url: str | None = None,
1157
1257
  ) -> dict:
1158
1258
  """Build a complete Video DSL JSON.
1159
1259
 
@@ -1607,6 +1707,8 @@ def build_dsl(
1607
1707
  if template_id:
1608
1708
  dsl["meta"]["templateId"] = template_id
1609
1709
 
1710
+ _record_source_document(dsl, template_config, template_id, source_document_url)
1711
+
1610
1712
  _enforce_supported_duration(dsl, template_config, template_id)
1611
1713
 
1612
1714
  return dsl
@@ -1628,7 +1730,15 @@ Examples:
1628
1730
  parser.add_argument("--ratio", default=None, choices=VALID_RATIOS, help="Aspect ratio. When omitted: if --template-id is set, use the template's supportedAspectRatios[0]; otherwise default 16:9.")
1629
1731
  parser.add_argument("--resolution", default="1080p", help="Resolution (default: 1080p)")
1630
1732
  parser.add_argument("--item-count", type=int, default=None, help="Number of content beats, for templates declaring capabilities.sceneStrategy=countdown (Top-N lists, '5 个技巧', '7 个常见错误'). Produces one opening plus N scenes counting down from #N to #1. Bounds and the default are declared by the template — this flag has no range of its own. Pass the count the user asked for, NOT the total scene count; --scenes is the total and the two are cross-checked.")
1631
- parser.add_argument("--scenes", type=int, default=None, help="Scene count (default: auto-planned)")
1733
+ parser.add_argument("--scenes", type=int, default=None, help="Scene count (default: auto-planned). Only applies to arc templates. Bounded below by 3 and above by the template's capabilities.maxScenes (default 20); a clamp is reported on stderr, so read the scene count back from the returned DSL rather than assuming you got what you asked for.")
1734
+ parser.add_argument(
1735
+ "--source-document-url", default=None,
1736
+ help="Source document URL (.pptx) for templates that build their per-scene payload from a "
1737
+ "document (capabilities.payloadHydration, e.g. ppt-to-video). Recorded at "
1738
+ "meta.sourceDocument; prepare_video_assets then fetches the layout itself and fills "
1739
+ "every scene's customPayload. Pass the same URL you gave doc_parse. Without it such a "
1740
+ "template's DSL is rejected downstream.",
1741
+ )
1632
1742
  parser.add_argument("--voice-id", default=None, help="Narration voice id. When omitted, the resolver picks template.defaultVoiceId, then the language-keyed fallback (zh→Chinese (Mandarin)_Male_Announcer, en→TBD English voice). gen_voice --list-voices prints the live catalog.")
1633
1743
  parser.add_argument(
1634
1744
  "--speed",
@@ -1843,6 +1953,7 @@ Examples:
1843
1953
  caption_lines=args.caption_lines,
1844
1954
  scene_images=args.scene_images,
1845
1955
  item_count=args.item_count,
1956
+ source_document_url=args.source_document_url,
1846
1957
  )
1847
1958
  except ValueError as exc:
1848
1959
  # Covers both a bad --item-count and an unusable countdown declaration