@remixmate/cli 0.9.44 → 0.9.46
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 +14 -4
- package/package.json +1 -1
- package/skills/doc-parse/SKILL.md +35 -5
- package/skills/doc-parse/scripts/extract_via_render.py +54 -157
- package/skills/gen-script/SKILL.md +31 -0
- package/skills/gen-script/scripts/gen_script.py +147 -7
- package/skills/gen-script/skill.json +6 -1
- package/skills/gen-script/version.json +1 -1
- package/skills/prepare-video-assets/skill.json +4 -0
- package/skills/prepare-video-assets/version.json +1 -1
- package/skills/render-video/scripts/render_video.py +118 -3
- package/skills/render-video/version.json +1 -1
- package/skills/template-registry/scripts/doc_extract.py +226 -0
- package/skills/template-registry/scripts/payload_hydrator.py +315 -0
- package/skills/template-registry/version.json +1 -1
- package/skills/template-registry/video_dsl/runtime/dsl_validator.py +136 -3
- package/skills/template-registry/video_dsl/runtime/timeline_compiler.py +51 -2
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared document extraction client — ab-render ``/extract`` submit + poll.
|
|
3
|
+
|
|
4
|
+
为什么这段逻辑住在共享目录而不是 doc-parse skill 里:**有两个消费方,而且它们要的
|
|
5
|
+
形态相反**。
|
|
6
|
+
|
|
7
|
+
doc-parse(给模型看) → 摘要进 stdout,全量落盘。layout 层一页带内嵌图标可达
|
|
8
|
+
几百 KB,进对话正文会撑爆上下文。
|
|
9
|
+
payload_hydrator(给机器用)→ 要的就是全量 layout,直接填进 DSL 的 customPayload。
|
|
10
|
+
|
|
11
|
+
第二个消费方以前不存在,于是 ppt-to-video 是这样断的:抽取结果只有落盘一条出路,而
|
|
12
|
+
模型在 ab-agent 里**没有任何文件读取工具**(``mcp-tools.ts`` 的 ALLOWED_TOOL_NAMES 刻意
|
|
13
|
+
只放行 MCP skill,内置 Read/Bash 全部排除,那是对的——bypassPermissions 下放开读写等于
|
|
14
|
+
把 .env 里的 PRIV_TOKEN 交出去)。模型能看到「完整结果已写入 ./source.pptx.layout.json」,
|
|
15
|
+
然后无论如何也读不到它。2026-09-23 线上那条会话在这里试了 ``web_read file://``,被
|
|
16
|
+
「--url 只接受 http/https」打回,接着整条链路就没有第二条路了。
|
|
17
|
+
|
|
18
|
+
结论:**layout 数据不该过模型**。它从 ab-render 出来,直接进 DSL,中间不经过上下文。
|
|
19
|
+
这个模块就是那条直连的通道。
|
|
20
|
+
|
|
21
|
+
环境变量(由 ab-agent 注入,与 doc-parse skill 同一套):
|
|
22
|
+
RENDER_API_URL / REMOTION_RENDER_API_URL ab-render 地址
|
|
23
|
+
PRIV_TOKEN ab-api 私有 token(端点拒绝匿名调用)
|
|
24
|
+
CONVERSATION_ID 留痕用,可选
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
import time
|
|
33
|
+
import urllib.error
|
|
34
|
+
import urllib.request
|
|
35
|
+
from typing import Callable, Optional
|
|
36
|
+
|
|
37
|
+
DEFAULT_RENDER_API_URL = "https://api-render.remixmate.ai"
|
|
38
|
+
|
|
39
|
+
# 端点是异步任务制:submit 拿 taskId,再轮询 extractStatus。
|
|
40
|
+
DEFAULT_MAX_WAIT_SEC = 600
|
|
41
|
+
DEFAULT_POLL_INTERVAL_SEC = 2
|
|
42
|
+
|
|
43
|
+
SUPPORTED_KINDS = ("pptx",)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DocExtractError(RuntimeError):
|
|
47
|
+
"""抽取失败。
|
|
48
|
+
|
|
49
|
+
message 直接面向调用方的 stderr —— ab-render 的抽取错误本来就是人话
|
|
50
|
+
(「不是 zip」「超页数上限」「.ppt 需另存为 .pptx」),再包一层只会让它更远。
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def render_api_url() -> str:
|
|
55
|
+
return (
|
|
56
|
+
os.environ.get("RENDER_API_URL")
|
|
57
|
+
or os.environ.get("REMOTION_RENDER_API_URL")
|
|
58
|
+
or DEFAULT_RENDER_API_URL
|
|
59
|
+
).rstrip("/")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _api_post(path: str, body: dict, timeout: int = 30) -> dict:
|
|
63
|
+
url = f"{render_api_url()}{path}"
|
|
64
|
+
headers = {
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
"X-Priv-Token": os.environ.get("PRIV_TOKEN", ""),
|
|
67
|
+
}
|
|
68
|
+
conversation_id = os.environ.get("CONVERSATION_ID", "")
|
|
69
|
+
if conversation_id:
|
|
70
|
+
headers["x-conversation-id"] = conversation_id
|
|
71
|
+
|
|
72
|
+
req = urllib.request.Request(
|
|
73
|
+
url, data=json.dumps(body).encode(), headers=headers, method="POST"
|
|
74
|
+
)
|
|
75
|
+
try:
|
|
76
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310 - fixed https base
|
|
77
|
+
return json.loads(resp.read())
|
|
78
|
+
except urllib.error.HTTPError as exc:
|
|
79
|
+
detail = exc.read().decode(errors="replace")
|
|
80
|
+
raise DocExtractError(f"HTTP {exc.code} from {path}: {detail}") from exc
|
|
81
|
+
except urllib.error.URLError as exc:
|
|
82
|
+
raise DocExtractError(f"cannot reach ab-render at {url}: {exc.reason}") from exc
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def extract_document(
|
|
86
|
+
url: str,
|
|
87
|
+
*,
|
|
88
|
+
kind: str = "pptx",
|
|
89
|
+
fields: str | list[str] = "content,layout",
|
|
90
|
+
slide: Optional[int] = None,
|
|
91
|
+
max_wait_sec: int = DEFAULT_MAX_WAIT_SEC,
|
|
92
|
+
poll_interval_sec: int = DEFAULT_POLL_INTERVAL_SEC,
|
|
93
|
+
log: Optional[Callable[[str], None]] = None,
|
|
94
|
+
) -> dict:
|
|
95
|
+
"""Submit + poll one extraction; return the endpoint's ``result`` dict.
|
|
96
|
+
|
|
97
|
+
``log`` 收到的是进度行,调用方自己决定丢到 stderr 还是丢掉。这里**不直接写
|
|
98
|
+
stdout** —— doc-parse 的 ``--json-output`` 管道模式要求 stdout 只有那一份 JSON。
|
|
99
|
+
"""
|
|
100
|
+
emit = log or (lambda _msg: None)
|
|
101
|
+
|
|
102
|
+
if not os.environ.get("PRIV_TOKEN"):
|
|
103
|
+
raise DocExtractError(
|
|
104
|
+
"PRIV_TOKEN env var is not configured — the /extract endpoint refuses anonymous calls."
|
|
105
|
+
)
|
|
106
|
+
if kind not in SUPPORTED_KINDS:
|
|
107
|
+
raise DocExtractError(
|
|
108
|
+
f"unsupported document kind '{kind}' (supported: {', '.join(SUPPORTED_KINDS)})"
|
|
109
|
+
)
|
|
110
|
+
if not isinstance(url, str) or not url.lower().startswith(("http://", "https://")):
|
|
111
|
+
raise DocExtractError(
|
|
112
|
+
f"document url must be http/https, got {url!r}. "
|
|
113
|
+
"Pass the file URL itself (what the asset library returns), not a page or share link."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
field_list = (
|
|
117
|
+
[f.strip() for f in fields.split(",") if f.strip()]
|
|
118
|
+
if isinstance(fields, str)
|
|
119
|
+
else [str(f).strip() for f in fields if str(f).strip()]
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
body: dict = {"documentUrl": url, "kind": kind, "fields": field_list}
|
|
123
|
+
if slide is not None:
|
|
124
|
+
body["slide"] = slide
|
|
125
|
+
|
|
126
|
+
emit(f"[doc-extract] submitting: {url[:80]}")
|
|
127
|
+
resp = _api_post("/extract", body)
|
|
128
|
+
if resp.get("code") != 0:
|
|
129
|
+
raise DocExtractError(f"submission failed: {resp.get('msg', 'unknown error')}")
|
|
130
|
+
|
|
131
|
+
task_id = ((resp.get("data") or {}).get("taskId")) or ""
|
|
132
|
+
if not task_id:
|
|
133
|
+
raise DocExtractError(f"/extract did not return a taskId: {resp}")
|
|
134
|
+
emit(f"[doc-extract] task accepted, taskId={task_id}")
|
|
135
|
+
|
|
136
|
+
elapsed = 0
|
|
137
|
+
while elapsed < max_wait_sec:
|
|
138
|
+
time.sleep(poll_interval_sec)
|
|
139
|
+
elapsed += poll_interval_sec
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
status_resp = _api_post("/extractStatus", {"taskId": task_id})
|
|
143
|
+
except DocExtractError as exc:
|
|
144
|
+
# 网络抖动不是任务失败:任务还在服务端跑着,重试轮询。
|
|
145
|
+
emit(f"[doc-extract] polling error (will retry): {exc}")
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
if status_resp.get("code") != 0:
|
|
149
|
+
emit(f"[doc-extract] polling error code (will retry): {status_resp.get('msg')}")
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
task = status_resp.get("data") or {}
|
|
153
|
+
status = task.get("status", "unknown")
|
|
154
|
+
|
|
155
|
+
if status == "succeeded":
|
|
156
|
+
return task.get("result") or {}
|
|
157
|
+
if status == "failed":
|
|
158
|
+
raise DocExtractError(f"extraction failed: {task.get('error', 'unknown error')}")
|
|
159
|
+
|
|
160
|
+
emit(f"[doc-extract] status={status} elapsed={elapsed}s")
|
|
161
|
+
|
|
162
|
+
raise DocExtractError(f"extraction timed out after {max_wait_sec}s (taskId={task_id})")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def slides_by_index(result: dict) -> dict[int, dict]:
|
|
166
|
+
"""``slides[]`` 按 1-based ``slideIndex`` 建索引。
|
|
167
|
+
|
|
168
|
+
不用列表位置当页号:``--slide N`` 单页抽取时列表只有一项,而它的 slideIndex 是 N。
|
|
169
|
+
按位置取会把第 7 页当第 1 页用,而且画面看起来"有内容",没人会发现。
|
|
170
|
+
"""
|
|
171
|
+
index: dict[int, dict] = {}
|
|
172
|
+
for position, slide in enumerate(result.get("slides") or [], start=1):
|
|
173
|
+
if not isinstance(slide, dict):
|
|
174
|
+
continue
|
|
175
|
+
raw = slide.get("slideIndex")
|
|
176
|
+
index[raw if isinstance(raw, int) and raw > 0 else position] = slide
|
|
177
|
+
return index
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def summarize(result: dict, out_path: Optional[str] = None) -> str:
|
|
181
|
+
"""人能读的一页纸。
|
|
182
|
+
|
|
183
|
+
**这是 doc-parse 存在的一半价值** —— 端点给的是全量结构,决定"喂多少进上下文"
|
|
184
|
+
是入口层的责任(设计文档 §4.4)。每页一行:页码、档位、拍数、标题、讲稿来源。
|
|
185
|
+
档位与拍数决定后面怎么写旁白,讲稿来源决定走严格改写还是扩写。
|
|
186
|
+
"""
|
|
187
|
+
src = result.get("source", {})
|
|
188
|
+
lines = [
|
|
189
|
+
f"共 {src.get('slideCount', '?')} 页,本次抽到 {len(result.get('slides', []))} 页"
|
|
190
|
+
f"(fields={'+'.join(src.get('fields', []))})",
|
|
191
|
+
"",
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
for s in result.get("slides", []):
|
|
195
|
+
idx = s.get("slideIndex", "?")
|
|
196
|
+
bits = [f"第 {idx} 页"]
|
|
197
|
+
|
|
198
|
+
if s.get("mode"):
|
|
199
|
+
beats = len(s.get("beats") or [])
|
|
200
|
+
bits.append(f"{s['mode']}/{beats} 拍")
|
|
201
|
+
if s.get("title"):
|
|
202
|
+
bits.append(f"「{s['title']}」")
|
|
203
|
+
|
|
204
|
+
notes = (s.get("rawNotes") or "").strip()
|
|
205
|
+
if notes:
|
|
206
|
+
head = notes.replace("\n", " ")[:40]
|
|
207
|
+
bits.append(f"备注 {len(notes)} 字:{head}…")
|
|
208
|
+
elif "rawNotes" in s:
|
|
209
|
+
bits.append("无备注(旁白需按正文扩写)")
|
|
210
|
+
|
|
211
|
+
lines.append(" " + " · ".join(bits))
|
|
212
|
+
|
|
213
|
+
if s.get("modeReason"):
|
|
214
|
+
lines.append(f" 判定:{s['modeReason']}")
|
|
215
|
+
flags = s.get("flags") or []
|
|
216
|
+
if flags:
|
|
217
|
+
# flag 是显式的降级记录,不是日志 —— 画面上少一块,这里不说就没人知道为什么。
|
|
218
|
+
lines.append(f" ⚠ 降级:{'; '.join(flags[:3])}")
|
|
219
|
+
|
|
220
|
+
if out_path:
|
|
221
|
+
lines += ["", f"完整结果已写入:{out_path}"]
|
|
222
|
+
return "\n".join(lines)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def stderr_logger(msg: str) -> None:
|
|
226
|
+
print(msg, file=sys.stderr, flush=True)
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fill a template-declared ``customPayload`` field from a source document, by reference.
|
|
3
|
+
|
|
4
|
+
问题(2026-09-23 线上 conv_Sesw2g8oM81 的根因)
|
|
5
|
+
---------------------------------------------
|
|
6
|
+
``ppt-to-video`` 要求每个场景都有 ``customPayload.slide`` —— 一整页 PPT 的节点树、
|
|
7
|
+
坐标、配色、beats。这份数据只有一个合法产出方(ab-render 的 ``/extract`` layout 层),
|
|
8
|
+
而它**体积大到不能过模型**:一页带内嵌图标可达几百 KB,23 页就是好几 MB。
|
|
9
|
+
|
|
10
|
+
于是模板的 llmHint 写着「把返回的 slides[i].slide 整个填进 customPayload.slide」,
|
|
11
|
+
doc-parse 的 SKILL.md 写着「feed slides[i] straight into customPayload.slide」,而实际
|
|
12
|
+
链路是:doc_parse 把全量写进本地文件 → 模型没有任何文件读取工具 → 拿不到 →
|
|
13
|
+
prepare_video_assets 报 ``customPayload.slide (required)`` ×N。模型无论怎么改都过不了。
|
|
14
|
+
|
|
15
|
+
解法
|
|
16
|
+
----
|
|
17
|
+
**不让这份数据过模型**。DSL 里只留一个引用(``meta.sourceDocument``),skill 侧在
|
|
18
|
+
校验之前自己去把全量取回来填进每个场景。模型负责的是它真正能负责的东西:旁白、
|
|
19
|
+
场景与页的对应关系、以及 mode 的下压。
|
|
20
|
+
|
|
21
|
+
这与 ``extractCustomPayloadOverrides`` / ``extractNarrationOverrides`` 是同一类教训的
|
|
22
|
+
第三次:**合并层丢掉了数据,而下游报的错指向模型**。前两次丢的是 slideId 和 items。
|
|
23
|
+
|
|
24
|
+
模板怎么声明
|
|
25
|
+
-----------
|
|
26
|
+
``template.json`` 的 ``capabilities.payloadHydration``::
|
|
27
|
+
|
|
28
|
+
"payloadHydration": {
|
|
29
|
+
"payloadField": "slide", # 要填的 customPayload 字段名
|
|
30
|
+
"source": "doc-extract", # 数据来源(目前只有这一个)
|
|
31
|
+
"docKind": "pptx",
|
|
32
|
+
"fields": "content,layout", # 传给 /extract 的层
|
|
33
|
+
"indexField": "slideIndex" # 场景→页的映射键(写在 customPayload 里)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
不写 ``if template_id == "ppt-to-video"``(AGENTS.md 明令禁止):任何模板声明了这一段
|
|
37
|
+
就自动获得同样的行为。
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import os
|
|
43
|
+
import sys
|
|
44
|
+
from typing import Callable, Optional
|
|
45
|
+
|
|
46
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
47
|
+
|
|
48
|
+
from doc_extract import ( # noqa: E402
|
|
49
|
+
DocExtractError,
|
|
50
|
+
extract_document,
|
|
51
|
+
slides_by_index,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
SUPPORTED_SOURCES = ("doc-extract",)
|
|
55
|
+
|
|
56
|
+
DEFAULT_INDEX_FIELD = "slideIndex"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def declared_subfields(template_config: Optional[dict], payload_field: str) -> Optional[set[str]]:
|
|
60
|
+
"""模板为 ``customPayload.<payload_field>`` 声明的子字段名集合。
|
|
61
|
+
|
|
62
|
+
注入必须按这个集合**投影**,不能把抽取器返回的整个对象原样塞进去 ——
|
|
63
|
+
两边的字段集根本不是一回事:
|
|
64
|
+
|
|
65
|
+
抽取器返回(每页) slideIndex / title / text / rawNotes / verdict / coverage /
|
|
66
|
+
flags / nodes / beats / mode / modeReason / ambientIds /
|
|
67
|
+
background / recommendedSec / canvas …
|
|
68
|
+
模板声明(slide) nodes / beats / mode / modeReason / ambientIds /
|
|
69
|
+
background / recommendedSec / canvas
|
|
70
|
+
|
|
71
|
+
前者多出来的那些是**内容层与抽取器自己的记账字段**(备注原文、质检结论、覆盖率),
|
|
72
|
+
渲染端一个都不读。而 ``dsl_validator._check_custom_payload_fields`` 会逐字段核对
|
|
73
|
+
声明表,遇到没声明的就报「the slide does not read 'x' — the value is silently
|
|
74
|
+
dropped」并整单拒绝。2026-09-23 线上 conv_StrnauS7TaZNUQV4BfLX 就是这样:
|
|
75
|
+
注入明明成功(23/23 页),却因为多了 5 个字段被校验整单拒掉,而且**重试必然复现**。
|
|
76
|
+
|
|
77
|
+
为什么用投影而不是写个黑名单:抽取器以后再加字段(verdict / coverage 就是后加的)
|
|
78
|
+
黑名单就会再漏一次。声明表是渲染端真正读的那一份,按它投影才不会二次失效。
|
|
79
|
+
|
|
80
|
+
声明表读不到时返回 None = 不投影。这与校验层的行为一致:没有
|
|
81
|
+
``customPayloadSchema`` 时它也不查字段名,所以不投影不会引入新的失败。
|
|
82
|
+
"""
|
|
83
|
+
if not template_config:
|
|
84
|
+
return None
|
|
85
|
+
cps = template_config.get("customPayloadSchema")
|
|
86
|
+
if not isinstance(cps, dict):
|
|
87
|
+
return None
|
|
88
|
+
node = cps.get(payload_field)
|
|
89
|
+
if not isinstance(node, dict):
|
|
90
|
+
return None
|
|
91
|
+
props = node.get("properties")
|
|
92
|
+
if not isinstance(props, dict) or not props:
|
|
93
|
+
return None
|
|
94
|
+
return set(props.keys())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class HydrationError(RuntimeError):
|
|
98
|
+
"""注入失败。message 必须直接告诉调用方下一步做什么。
|
|
99
|
+
|
|
100
|
+
这个类存在的全部意义是替换掉 ``customPayload.slide (required)`` ×20 那种报错 ——
|
|
101
|
+
它说的是"某个字段没填",而真相是"这个字段的数据源没有通路",两者要采取的行动
|
|
102
|
+
完全不同,而模型只看得到前者。
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def hydration_spec(template_config: Optional[dict]) -> Optional[dict]:
|
|
107
|
+
"""读 ``capabilities.payloadHydration``;未声明 / 声明无效返回 None。
|
|
108
|
+
|
|
109
|
+
宽容读取:一个写坏的 template.json 不该让所有模板的渲染一起失败。但
|
|
110
|
+
``source`` 认不出来时要报错而不是静默跳过 —— 静默跳过会退回到「必填字段缺失」
|
|
111
|
+
那条死路,也就是这个模块本来要修掉的东西。
|
|
112
|
+
"""
|
|
113
|
+
caps = (template_config or {}).get("capabilities")
|
|
114
|
+
if not isinstance(caps, dict):
|
|
115
|
+
return None
|
|
116
|
+
spec = caps.get("payloadHydration")
|
|
117
|
+
if not isinstance(spec, dict) or not spec:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
payload_field = spec.get("payloadField")
|
|
121
|
+
if not isinstance(payload_field, str) or not payload_field:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
source = spec.get("source") or "doc-extract"
|
|
125
|
+
if source not in SUPPORTED_SOURCES:
|
|
126
|
+
raise HydrationError(
|
|
127
|
+
f"template declares capabilities.payloadHydration.source='{source}', which this "
|
|
128
|
+
f"version of remixmate does not implement (supported: {', '.join(SUPPORTED_SOURCES)}). "
|
|
129
|
+
"Upgrade @remixmate/cli or fix the template."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
index_field = spec.get("indexField")
|
|
133
|
+
return {
|
|
134
|
+
"payload_field": payload_field,
|
|
135
|
+
"source": source,
|
|
136
|
+
"doc_kind": spec.get("docKind") or "pptx",
|
|
137
|
+
"fields": spec.get("fields") or "content,layout",
|
|
138
|
+
"index_field": index_field if isinstance(index_field, str) and index_field else DEFAULT_INDEX_FIELD,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def source_document_ref(dsl: dict, override_url: Optional[str] = None) -> Optional[dict]:
|
|
143
|
+
"""取 DSL 里的源文件引用。``override_url`` 优先(CLI 显式传入时)。
|
|
144
|
+
|
|
145
|
+
位置是 ``meta.sourceDocument`` 而不是场景级:一条片子对一份源文件,写在场景里
|
|
146
|
+
会得到 23 份同样的 URL,还得处理它们互相矛盾的情况。
|
|
147
|
+
|
|
148
|
+
为什么是 ``meta``:ab-agent 的合并层(``applyNarrationToSkeleton``)以 gen_script
|
|
149
|
+
骨架为基底,只从模型提交的 DSL 里搬 narration 与 customPayload,``meta`` / ``global``
|
|
150
|
+
归骨架所有。所以写在 meta 的引用能活着穿过合并层,而模型自己往 dsl_json 里写
|
|
151
|
+
``meta.sourceDocument`` 会被丢掉 —— 引用必须在 gen_script 那一步就进骨架。
|
|
152
|
+
"""
|
|
153
|
+
if override_url:
|
|
154
|
+
return {"url": override_url}
|
|
155
|
+
meta = dsl.get("meta")
|
|
156
|
+
if not isinstance(meta, dict):
|
|
157
|
+
return None
|
|
158
|
+
ref = meta.get("sourceDocument")
|
|
159
|
+
if isinstance(ref, str) and ref.strip():
|
|
160
|
+
return {"url": ref.strip()}
|
|
161
|
+
if isinstance(ref, dict) and isinstance(ref.get("url"), str) and ref["url"].strip():
|
|
162
|
+
return {"url": ref["url"].strip(), "kind": ref.get("kind")}
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _scene_payload(scene: dict) -> dict:
|
|
167
|
+
payload = scene.get("customPayload")
|
|
168
|
+
if not isinstance(payload, dict):
|
|
169
|
+
payload = {}
|
|
170
|
+
scene["customPayload"] = payload
|
|
171
|
+
return payload
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def hydrate_dsl(
|
|
175
|
+
dsl: dict,
|
|
176
|
+
template_config: Optional[dict],
|
|
177
|
+
*,
|
|
178
|
+
override_url: Optional[str] = None,
|
|
179
|
+
log: Optional[Callable[[str], None]] = None,
|
|
180
|
+
) -> list[str]:
|
|
181
|
+
"""把源文件的每一页填进对应场景的 ``customPayload.<payloadField>``。
|
|
182
|
+
|
|
183
|
+
返回一份给人看的报告行列表(调用方自己决定打到哪)。什么都没做时返回空列表。
|
|
184
|
+
|
|
185
|
+
映射规则:场景的 ``customPayload.<indexField>`` 是 1-based 页号,缺省按场景顺序
|
|
186
|
+
取第 i 页。显式页号优先 —— 用户可能只讲其中几页,或者把两页并成一个场景。
|
|
187
|
+
|
|
188
|
+
已有值不覆盖:场景里已经有 ``customPayload.slide`` 时跳过。直接调 CLI 的调试流程
|
|
189
|
+
会手动塞一份进去,把它冲掉等于让调试者的输入人间蒸发。
|
|
190
|
+
"""
|
|
191
|
+
emit = log or (lambda _msg: None)
|
|
192
|
+
|
|
193
|
+
spec = hydration_spec(template_config)
|
|
194
|
+
if not spec:
|
|
195
|
+
return []
|
|
196
|
+
|
|
197
|
+
scenes = [s for s in (dsl.get("scenes") or []) if isinstance(s, dict)]
|
|
198
|
+
if not scenes:
|
|
199
|
+
return []
|
|
200
|
+
|
|
201
|
+
field = spec["payload_field"]
|
|
202
|
+
index_field = spec["index_field"]
|
|
203
|
+
|
|
204
|
+
pending = [s for s in scenes if _scene_payload(s).get(field) is None]
|
|
205
|
+
if not pending:
|
|
206
|
+
return [f"customPayload.{field} already present on all {len(scenes)} scene(s); nothing to hydrate"]
|
|
207
|
+
|
|
208
|
+
ref = source_document_ref(dsl, override_url)
|
|
209
|
+
if not ref:
|
|
210
|
+
template_id = ((dsl.get("meta") or {}).get("templateId")) or "this template"
|
|
211
|
+
raise HydrationError(
|
|
212
|
+
f"{len(pending)} of {len(scenes)} scene(s) need customPayload.{field}, and "
|
|
213
|
+
f"'{template_id}' builds it from the source document rather than from hand-written JSON — "
|
|
214
|
+
"but the DSL carries no source document reference.\n"
|
|
215
|
+
" This field is intentionally NOT something you author: one page of layout data is "
|
|
216
|
+
"hundreds of KB, so it never travels through the conversation.\n"
|
|
217
|
+
f" Fix: pass the document URL once and the pipeline fills all {len(scenes)} scenes itself.\n"
|
|
218
|
+
" • preferred — call gen_script with --source-document-url <url> so the reference is "
|
|
219
|
+
"recorded in meta.sourceDocument\n"
|
|
220
|
+
" • or pass --source-document-url <url> to this call directly\n"
|
|
221
|
+
" The URL is the same .pptx you gave doc_parse."
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
kind = ref.get("kind") or spec["doc_kind"]
|
|
225
|
+
emit(f"🧩 hydrating customPayload.{field} from the source document ({kind}, fields={spec['fields']})")
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
result = extract_document(
|
|
229
|
+
ref["url"], kind=kind, fields=spec["fields"], log=emit
|
|
230
|
+
)
|
|
231
|
+
except DocExtractError as exc:
|
|
232
|
+
raise HydrationError(
|
|
233
|
+
f"could not read the source document needed for customPayload.{field}: {exc}"
|
|
234
|
+
) from exc
|
|
235
|
+
|
|
236
|
+
pages = slides_by_index(result)
|
|
237
|
+
if not pages:
|
|
238
|
+
raise HydrationError(
|
|
239
|
+
f"the source document extraction returned no pages, so customPayload.{field} "
|
|
240
|
+
"cannot be filled. Check that the URL points at the .pptx file itself."
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
report: list[str] = []
|
|
244
|
+
filled = 0
|
|
245
|
+
missing: list[str] = []
|
|
246
|
+
|
|
247
|
+
# 按模板声明投影。抽取结果里混着内容层与抽取器记账字段,渲染端一个都不读,
|
|
248
|
+
# 而校验层见到未声明字段会整单拒绝(见 declared_subfields 的说明)。
|
|
249
|
+
allowed = declared_subfields(template_config, field)
|
|
250
|
+
dropped: set[str] = set()
|
|
251
|
+
|
|
252
|
+
# 页号只有在模板**声明**了它的时候才写回去。没声明就是未知字段,写了会被校验
|
|
253
|
+
# 拒掉 —— 这正是上一版的第二个错:既投影不足,又多写了一个 customPayload.slideIndex。
|
|
254
|
+
index_is_declared = index_field in (declared_subfields(template_config, index_field) or ())
|
|
255
|
+
if not index_is_declared:
|
|
256
|
+
cps = (template_config or {}).get("customPayloadSchema")
|
|
257
|
+
index_is_declared = isinstance(cps, dict) and index_field in cps
|
|
258
|
+
|
|
259
|
+
for position, scene in enumerate(scenes, start=1):
|
|
260
|
+
payload = _scene_payload(scene)
|
|
261
|
+
if payload.get(field) is not None:
|
|
262
|
+
continue
|
|
263
|
+
declared = payload.get(index_field)
|
|
264
|
+
page_no = declared if isinstance(declared, int) and declared > 0 else position
|
|
265
|
+
page = pages.get(page_no)
|
|
266
|
+
if page is None:
|
|
267
|
+
missing.append(f"scene {scene.get('id', position)} → page {page_no}")
|
|
268
|
+
continue
|
|
269
|
+
if allowed is None:
|
|
270
|
+
payload[field] = page
|
|
271
|
+
else:
|
|
272
|
+
dropped |= {k for k in page if k not in allowed}
|
|
273
|
+
payload[field] = {k: v for k, v in page.items() if k in allowed}
|
|
274
|
+
# 留下"这一场用的是第几页"的痕迹 —— 仅当模板声明了这个字段。
|
|
275
|
+
if index_is_declared:
|
|
276
|
+
payload.setdefault(index_field, page_no)
|
|
277
|
+
filled += 1
|
|
278
|
+
|
|
279
|
+
source_meta = result.get("source") or {}
|
|
280
|
+
report.append(
|
|
281
|
+
f"✅ filled customPayload.{field} on {filled}/{len(scenes)} scene(s) from "
|
|
282
|
+
f"{source_meta.get('slideCount', len(pages))} page(s) of "
|
|
283
|
+
f"{source_meta.get('name', 'the source document')}"
|
|
284
|
+
)
|
|
285
|
+
if dropped:
|
|
286
|
+
# 说清丢了什么:这些是内容层/记账字段,渲染端本来就不读。写出来是为了
|
|
287
|
+
# 抽取器哪天把一个**该读**的字段也归到这里时,有人看得见。
|
|
288
|
+
report.append(
|
|
289
|
+
f" (已按模板声明投影,忽略 {len(dropped)} 个渲染端不读的字段:"
|
|
290
|
+
f"{', '.join(sorted(dropped)[:8])})"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
if missing:
|
|
294
|
+
# 页号对不上不是警告而是错误:少一页 = 成片里一个空白场景,而它渲得出来。
|
|
295
|
+
raise HydrationError(
|
|
296
|
+
f"{len(missing)} scene(s) point at a page that the source document does not have "
|
|
297
|
+
f"({'; '.join(missing[:5])}"
|
|
298
|
+
f"{f'; … {len(missing)} total' if len(missing) > 5 else ''}).\n"
|
|
299
|
+
f" The document has {len(pages)} page(s). Either lower the scene count to match, "
|
|
300
|
+
f"or fix customPayload.{index_field} on those scenes."
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# 降级记录要顺着传上来 —— 画面上少一块,这里不说就没人知道为什么。
|
|
304
|
+
flagged = [
|
|
305
|
+
f"page {no}: {'; '.join(page.get('flags') or [])}"
|
|
306
|
+
for no, page in sorted(pages.items())
|
|
307
|
+
if page.get("flags")
|
|
308
|
+
]
|
|
309
|
+
if flagged:
|
|
310
|
+
report.append(
|
|
311
|
+
"⚠️ the extractor refused to reconstruct some elements (they render as an "
|
|
312
|
+
"approximation): " + " | ".join(flagged[:5])
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
return report
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"skillName": "template-registry",
|
|
3
3
|
"repoName": "agent-skill-media-maker",
|
|
4
4
|
"skillId": "475",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "V21",
|
|
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
|
}
|