@remixmate/cli 0.9.37 → 0.9.39
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 +75 -2
- package/package.json +1 -1
- package/skills/doc-parse/SKILL.md +124 -0
- package/skills/doc-parse/scripts/extract_via_render.py +218 -0
- package/skills/doc-parse/skill.json +54 -0
- package/skills/gen-script/SKILL.md +14 -3
- package/skills/template-registry/video_dsl/runtime/timeline_compiler.py +34 -4
package/dist/manifest.json
CHANGED
|
@@ -1,8 +1,81 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"generatedAt": "2026-09-
|
|
3
|
+
"version": "0.9.39",
|
|
4
|
+
"generatedAt": "2026-09-21T14:35:49.704Z",
|
|
5
5
|
"skills": [
|
|
6
|
+
{
|
|
7
|
+
"id": "doc-parse",
|
|
8
|
+
"toolName": "doc_parse",
|
|
9
|
+
"tier": "tool",
|
|
10
|
+
"category": "consuming",
|
|
11
|
+
"title": "Document Extraction",
|
|
12
|
+
"summary": "Extract a document (.pptx) into structured data: per-page title and body text, the speaker notes, and — when asked for the layout layer — the exact shapes, coordinates, colors and animation beats of every slide. Runs server-side (ab-render /extract); nothing is installed locally and no model is involved, so the result is deterministic. Two layers via `fields`: `content` is lightweight text (title / text / rawNotes) and is what you want for narration or for summarizing a deck; `layout` additionally returns the slide payload that the ppt-to-video template renders one-to-one from the original file — it embeds every icon as a data URI and is large, so it is written to a file rather than printed.",
|
|
13
|
+
"triggers": [
|
|
14
|
+
"Turn this PPT / slide deck into a video",
|
|
15
|
+
"Extract text or speaker notes from a .pptx",
|
|
16
|
+
"Use a PowerPoint file as source material",
|
|
17
|
+
"Get customPayload.slide for ppt-to-video"
|
|
18
|
+
],
|
|
19
|
+
"entry": {
|
|
20
|
+
"type": "python",
|
|
21
|
+
"scriptPath": "scripts/extract_via_render.py"
|
|
22
|
+
},
|
|
23
|
+
"runtime": "python",
|
|
24
|
+
"envVars": [
|
|
25
|
+
"RENDER_API_URL",
|
|
26
|
+
"PRIV_TOKEN",
|
|
27
|
+
"CONVERSATION_ID"
|
|
28
|
+
],
|
|
29
|
+
"parameters": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"properties": {
|
|
32
|
+
"url": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "Direct document URL (http/https). A page link or a share link will not work — pass the file URL itself (e.g. what the asset library returns)."
|
|
35
|
+
},
|
|
36
|
+
"kind": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"enum": [
|
|
39
|
+
"pptx"
|
|
40
|
+
],
|
|
41
|
+
"description": "Document kind. Only pptx today; a binary .ppt must be re-saved as .pptx first."
|
|
42
|
+
},
|
|
43
|
+
"fields": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"description": "Comma-separated layers: `content` (title/text/rawNotes — small) and/or `layout` (slide shapes + beats — large, embeds icons). Default `content,layout`. Ask for content only when you just need the words."
|
|
46
|
+
},
|
|
47
|
+
"slide": {
|
|
48
|
+
"type": "number",
|
|
49
|
+
"description": "Only extract this 1-based page. Omit for the whole deck."
|
|
50
|
+
},
|
|
51
|
+
"out": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": "Where to write the full JSON result (default ./<name>.extract.json). stdout gets a per-page summary instead."
|
|
54
|
+
},
|
|
55
|
+
"json_output": {
|
|
56
|
+
"type": "boolean",
|
|
57
|
+
"description": "Pipeline mode — emit the full JSON on stdout. Avoid with the layout layer: it is hundreds of KB of base64 per page."
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"required": [
|
|
61
|
+
"url"
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
"ui": {
|
|
65
|
+
"primary": [
|
|
66
|
+
"url",
|
|
67
|
+
"fields"
|
|
68
|
+
],
|
|
69
|
+
"advanced": [
|
|
70
|
+
"slide",
|
|
71
|
+
"kind"
|
|
72
|
+
],
|
|
73
|
+
"hidden": [
|
|
74
|
+
"out",
|
|
75
|
+
"json_output"
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
},
|
|
6
79
|
{
|
|
7
80
|
"id": "export-jianying",
|
|
8
81
|
"toolName": "export_jianying",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remixmate/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.39",
|
|
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",
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: doc-parse
|
|
3
|
+
description: |
|
|
4
|
+
Document extraction skill — turn a `.pptx` into structured data through the ab-render `/extract` endpoint.
|
|
5
|
+
|
|
6
|
+
Two layers, picked with `--fields`:
|
|
7
|
+
- **content** — per-page `title`, `text[]`, and `rawNotes` (the speaker notes). Small, no base64. This is the narration source material.
|
|
8
|
+
- **layout** — the full `slide` payload the `ppt-to-video` template renders from: every shape, coordinate, color, clip path and the animation `beats`, plus the `mode` verdict. Large (icons are embedded as data URIs).
|
|
9
|
+
|
|
10
|
+
Deterministic: the endpoint parses OOXML, it does **not** call a model. Notes come back verbatim — rewriting them into narration is your job, not the endpoint's.
|
|
11
|
+
|
|
12
|
+
Use this skill immediately whenever the user asks for any of:
|
|
13
|
+
- Turn this PPT / slide deck into a video
|
|
14
|
+
- Read / extract / summarize a .pptx, pull the text or the speaker notes out of a deck
|
|
15
|
+
- Use a PowerPoint file as source material for a script
|
|
16
|
+
- Anything that needs `customPayload.slide` for the `ppt-to-video` template
|
|
17
|
+
|
|
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.
|
|
19
|
+
triggers:
|
|
20
|
+
- Turn this PPT / slide deck into a video
|
|
21
|
+
- Extract text or speaker notes from a .pptx
|
|
22
|
+
- Use a PowerPoint file as source material
|
|
23
|
+
- Get customPayload.slide for ppt-to-video
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
# Document Extraction Skill (`doc_parse`)
|
|
27
|
+
|
|
28
|
+
Entry script **`scripts/extract_via_render.py`**, pure Python (stdlib only — no Office libraries, no local install).
|
|
29
|
+
|
|
30
|
+
> Path convention: read the system-injected `Base directory for this skill: <path>` as `<SkillDir>`. Never hardcode an absolute path.
|
|
31
|
+
|
|
32
|
+
## How it works
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
POST $RENDER_API_URL/extract → { taskId }
|
|
36
|
+
POST $RENDER_API_URL/extractStatus → { status, result? } (polled for you)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The heavy lifting (download, unzip, OOXML parsing, geometry) happens server-side. The script submits, polls, then **writes the full JSON to a file and prints a per-page summary**.
|
|
40
|
+
|
|
41
|
+
That default matters: one page of `layout` with embedded icons can be hundreds of KB. Printing it would fill your context with base64 and bury the three facts you actually need — the page's mode, its beat count, and whether it has speaker notes.
|
|
42
|
+
|
|
43
|
+
## Environment
|
|
44
|
+
|
|
45
|
+
| Var | Meaning | Default |
|
|
46
|
+
| --- | --- | --- |
|
|
47
|
+
| `RENDER_API_URL` | ab-render base URL (falls back to `REMOTION_RENDER_API_URL`) | `https://api-render.remixmate.ai` |
|
|
48
|
+
| `PRIV_TOKEN` | ab-api private token — **required**, the endpoint refuses anonymous calls | — |
|
|
49
|
+
| `CONVERSATION_ID` | Conversation id, for provenance | — |
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
Whole deck, both layers (what `ppt-to-video` needs):
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python3 <SkillDir>/scripts/extract_via_render.py --url "https://oss.example.com/deck.pptx"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Text and notes only — summarizing a deck, or writing narration without touching the visuals:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
python3 <SkillDir>/scripts/extract_via_render.py --url "https://…/deck.pptx" --fields content
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
One page:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
python3 <SkillDir>/scripts/extract_via_render.py --url "https://…/deck.pptx" --slide 3
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Output
|
|
72
|
+
|
|
73
|
+
```jsonc
|
|
74
|
+
{
|
|
75
|
+
"source": { "url": "…", "name": "deck.pptx", "slideCount": 12, "canvas": [1920, 1080], "fields": ["content","layout"] },
|
|
76
|
+
"slides": [
|
|
77
|
+
{
|
|
78
|
+
"slideIndex": 1,
|
|
79
|
+
// content 层
|
|
80
|
+
"title": "产品介绍",
|
|
81
|
+
"text": ["三大优势", "…"],
|
|
82
|
+
"rawNotes": "备注页原文,逐字返回,未经改写",
|
|
83
|
+
// layout 层
|
|
84
|
+
"mode": "sequenced",
|
|
85
|
+
"modeReason": "4×1 格点,4 块 + 2 组页面家具",
|
|
86
|
+
"nodes": [ /* … */ ],
|
|
87
|
+
"beats": [ { "reveal": ["n12","n13"] } ],
|
|
88
|
+
"ambientIds": ["n2"],
|
|
89
|
+
"recommendedSec": 5.3,
|
|
90
|
+
"background": { "color": "#FFFFFF" },
|
|
91
|
+
"flags": ["unsupported_geom:rightArrow×3"]
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Using the result
|
|
98
|
+
|
|
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.
|
|
100
|
+
|
|
101
|
+
**For the words** — `rawNotes` and `text` are two different kinds of raw material, and they need different treatment:
|
|
102
|
+
|
|
103
|
+
| Source | What it is | What to do | Can you check yourself? |
|
|
104
|
+
| --- | --- | --- | --- |
|
|
105
|
+
| `rawNotes` | already a script, written to be spoken | re-cut into one sentence per beat, make it spoken-register, drop stage directions (“as you can see here”, “(next slide)”) and non-script items (citations, TODOs) | **yes** — the original is right there |
|
|
106
|
+
| `text` | bullet points, sometimes just keywords | expand into sentences | **no** — there is nothing to check against |
|
|
107
|
+
|
|
108
|
+
Two rules:
|
|
109
|
+
|
|
110
|
+
1. **Do not introduce facts that are not on the slide.** This is the user's own deck; inventing content puts words in their mouth.
|
|
111
|
+
2. **Talk about it, don't read it out.** The body text is already on screen. Narrating it verbatim gives the viewer the same sentence three times — on the slide, in the subtitle, and in the voice-over.
|
|
112
|
+
|
|
113
|
+
When a page had no notes and you wrote the narration from `text`, say so and ask the user to look it over. Do not ship it silently.
|
|
114
|
+
|
|
115
|
+
## Failure modes
|
|
116
|
+
|
|
117
|
+
Errors come back as plain sentences; pass them to the user as-is rather than retrying:
|
|
118
|
+
|
|
119
|
+
- a binary `.ppt` (PowerPoint 97-2003) — it must be re-saved as `.pptx`, the formats are unrelated
|
|
120
|
+
- not a zip at all, or a zip without `ppt/slides/`
|
|
121
|
+
- the deck exceeds the per-call page limit — extract page by page with `--slide`, or split the file
|
|
122
|
+
- the file is too large, or unpacks to too much
|
|
123
|
+
|
|
124
|
+
`flags` on a page is **not** an error: it records what the extractor refused to reconstruct. Surface it — a silently missing element on screen is worse than a mentioned one.
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
文档抽取脚本 — 通过 ab-render HTTP 接口实现,本地不需要任何 Office/解析依赖。
|
|
4
|
+
|
|
5
|
+
调用链:
|
|
6
|
+
POST $RENDER_API_URL/extract → { taskId }
|
|
7
|
+
POST $RENDER_API_URL/extractStatus → { status, progress, result?, error? }
|
|
8
|
+
|
|
9
|
+
两层产物(--fields):
|
|
10
|
+
content title / text[] / rawNotes —— 正文与备注页,轻量,不含任何 base64
|
|
11
|
+
layout slide{nodes,beats,mode,…} —— 坐标形状,带内嵌图标,**体积很大**
|
|
12
|
+
|
|
13
|
+
默认**不把完整结果打到 stdout**:一页 PPT 的 layout 带上内嵌图标可能几百 KB,
|
|
14
|
+
直接进对话正文会撑爆上下文,还会让用户在聊天记录里滚过一屏 base64。默认写文件 +
|
|
15
|
+
打一份人能读的摘要;确实要管道传全量时用 --json-output(或只要 content 层)。
|
|
16
|
+
|
|
17
|
+
必要环境变量(由 ab-agent 自动注入):
|
|
18
|
+
RENDER_API_URL ab-render 服务地址(默认 https://api-render.remixmate.ai)
|
|
19
|
+
PRIV_TOKEN ab-api 私有 token
|
|
20
|
+
|
|
21
|
+
可选环境变量:
|
|
22
|
+
CONVERSATION_ID 对话 ID,用于留痕与产物归属
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
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
|
+
|
|
116
|
+
if out_path:
|
|
117
|
+
lines += ["", f"完整结果已写入:{out_path}"]
|
|
118
|
+
return "\n".join(lines)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ─── 主逻辑 ───────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def main() -> None:
|
|
125
|
+
parser = argparse.ArgumentParser(
|
|
126
|
+
description="Document extraction — calls the ab-render /extract endpoint"
|
|
127
|
+
)
|
|
128
|
+
parser.add_argument("--url", required=True, help="Direct document URL (.pptx)")
|
|
129
|
+
parser.add_argument("--kind", default="pptx", choices=["pptx"], help="Document kind (default pptx)")
|
|
130
|
+
parser.add_argument("--slide", type=int, help="Only extract this 1-based page")
|
|
131
|
+
parser.add_argument(
|
|
132
|
+
"--fields", default="content,layout",
|
|
133
|
+
help="Comma-separated: content / layout (default both)",
|
|
134
|
+
)
|
|
135
|
+
parser.add_argument(
|
|
136
|
+
"--out", help="Write the full JSON result here (default: ./<name>.extract.json)",
|
|
137
|
+
)
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"--json-output", action="store_true",
|
|
140
|
+
help="Pipeline mode: emit the full JSON on stdout (careful: layout is large)",
|
|
141
|
+
)
|
|
142
|
+
args = parser.parse_args()
|
|
143
|
+
|
|
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()]
|
|
152
|
+
|
|
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)
|
|
163
|
+
sys.exit(1)
|
|
164
|
+
|
|
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
|
|
172
|
+
|
|
173
|
+
while elapsed < max_wait_sec:
|
|
174
|
+
time.sleep(poll_interval)
|
|
175
|
+
elapsed += poll_interval
|
|
176
|
+
|
|
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
|
|
182
|
+
|
|
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)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
main()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "doc-parse",
|
|
3
|
+
"toolName": "doc_parse",
|
|
4
|
+
"tier": "tool",
|
|
5
|
+
"category": "consuming",
|
|
6
|
+
"title": "Document Extraction",
|
|
7
|
+
"description": "Extract a document (.pptx) into structured data: per-page title and body text, the speaker notes, and — when asked for the layout layer — the exact shapes, coordinates, colors and animation beats of every slide. Runs server-side (ab-render /extract); nothing is installed locally and no model is involved, so the result is deterministic. Two layers via `fields`: `content` is lightweight text (title / text / rawNotes) and is what you want for narration or for summarizing a deck; `layout` additionally returns the slide payload that the ppt-to-video template renders one-to-one from the original file — it embeds every icon as a data URI and is large, so it is written to a file rather than printed.",
|
|
8
|
+
"auth": "required",
|
|
9
|
+
"envVars": [
|
|
10
|
+
"RENDER_API_URL",
|
|
11
|
+
"PRIV_TOKEN",
|
|
12
|
+
"CONVERSATION_ID"
|
|
13
|
+
],
|
|
14
|
+
"entry": {
|
|
15
|
+
"type": "python",
|
|
16
|
+
"scriptPath": "scripts/extract_via_render.py"
|
|
17
|
+
},
|
|
18
|
+
"parameters": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"properties": {
|
|
21
|
+
"url": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "Direct document URL (http/https). A page link or a share link will not work — pass the file URL itself (e.g. what the asset library returns)."
|
|
24
|
+
},
|
|
25
|
+
"kind": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"enum": ["pptx"],
|
|
28
|
+
"description": "Document kind. Only pptx today; a binary .ppt must be re-saved as .pptx first."
|
|
29
|
+
},
|
|
30
|
+
"fields": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "Comma-separated layers: `content` (title/text/rawNotes — small) and/or `layout` (slide shapes + beats — large, embeds icons). Default `content,layout`. Ask for content only when you just need the words."
|
|
33
|
+
},
|
|
34
|
+
"slide": {
|
|
35
|
+
"type": "number",
|
|
36
|
+
"description": "Only extract this 1-based page. Omit for the whole deck."
|
|
37
|
+
},
|
|
38
|
+
"out": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"description": "Where to write the full JSON result (default ./<name>.extract.json). stdout gets a per-page summary instead."
|
|
41
|
+
},
|
|
42
|
+
"json_output": {
|
|
43
|
+
"type": "boolean",
|
|
44
|
+
"description": "Pipeline mode — emit the full JSON on stdout. Avoid with the layout layer: it is hundreds of KB of base64 per page."
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"required": ["url"]
|
|
48
|
+
},
|
|
49
|
+
"ui": {
|
|
50
|
+
"primary": ["url", "fields"],
|
|
51
|
+
"advanced": ["slide", "kind"],
|
|
52
|
+
"hidden": ["out", "json_output"]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -441,11 +441,22 @@ Apply the following principles when producing the DSL:
|
|
|
441
441
|
1. **Narrative-first**: opening hook → point expansion → CTA close.
|
|
442
442
|
2. **Assets are declared, not executed**: every asset starts as `AssetRef` with `status: planned`; no generation API call from this skill.
|
|
443
443
|
3. **Narration drives duration**: each scene's duration should match its narration reading time (≈ 3–4 zh chars/sec, or ~2 words/sec for English).
|
|
444
|
-
4. **
|
|
444
|
+
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.
|
|
445
|
+
|
|
446
|
+
**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.
|
|
447
|
+
|
|
448
|
+
Measured on a shipped video (2026-09-21): narration written as 6–8 character lines produced 57 subtitle screens in 121.5s — a median of 2.10s each, 33% of them under 1.5s, with `AI Agent` alone on a screen. The renderer's own splitter never even fired.
|
|
449
|
+
|
|
450
|
+
Write:
|
|
445
451
|
```
|
|
446
|
-
"text": "
|
|
452
|
+
"text": "最近刷爆科技圈的 AI Agent 到底厉害在哪\n真正的分水岭不是文案更精准,而是自主执行能力"
|
|
447
453
|
```
|
|
448
|
-
|
|
454
|
+
not:
|
|
455
|
+
```
|
|
456
|
+
"text": "最近刷爆科技圈的\nAI Agent\n到底厉害在哪\n不是文案更精准\n也不是对话更流畅"
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
**Use full-width punctuation in Chinese narration** (`,。:;?!`), not half-width (`,.:;?!`). Half-width punctuation in Chinese text renders with no trailing space — `第一步,目标感知:只给最终目标` reads cramped on screen.
|
|
449
460
|
5. **Moderate scene count**: 30-second videos work well with 4–6 scenes, 60-second videos with 6–10.
|
|
450
461
|
6. **Leave room for templates**: pick generic layouts; do not assume a specific template implementation.
|
|
451
462
|
7. **Image model allowlist**: every `type: image` + `source: gen-image` `AssetRef`'s `payload.model` **must** be one of the values in the table below. **Never** use display names, short forms, or made-up ids (e.g. `seedream`, `seedream-5`, etc.).
|
|
@@ -20,10 +20,35 @@ from typing import Optional
|
|
|
20
20
|
CHARS_PER_SECOND = 5.0
|
|
21
21
|
|
|
22
22
|
_PRIMARY_BREAK = re.compile(r"(?<=[。!?;\n])")
|
|
23
|
-
|
|
23
|
+
# 次级断点**不含顿号 `、`**。顿号是并列项之间的分隔,在它上面下刀就是把一串并列
|
|
24
|
+
# 拦腰切开(`调研、策划、` / `开发、分析,…`)。这一层只数字符、分不出标点的语义
|
|
25
|
+
# 强度,所以干脆不碰它 —— 真需要再切时交给渲染端,那边是分级的(句末 > 冒号 >
|
|
26
|
+
# 逗号 > 顿号 > 空格),只有前面几级都切不动才会降到顿号。
|
|
27
|
+
_SECONDARY_BREAK = re.compile(r"(?<=[,,::])")
|
|
24
28
|
_PRIMARY_TAIL = re.compile(r"[。!?;\n]\s*$")
|
|
25
29
|
|
|
26
|
-
|
|
30
|
+
# ─── 这两个上限管的是「喂给 TTS 的行」,不再是「一屏字幕」 ────────────────
|
|
31
|
+
#
|
|
32
|
+
# 曾经是 16 / 6,那是渲染端还不会分屏时的产物:谁都不会再切,所以必须在这里
|
|
33
|
+
# 就把旁白预切成一屏放得下的大小。现在渲染端(core 的 `chunkSubtitleSegments`)
|
|
34
|
+
# 会按**实际字号与实际可用宽度**算容量、按**标点语义强度分级**切分,这件事它
|
|
35
|
+
# 做得比这里好得多:
|
|
36
|
+
# · 这里只能数字符数,数不出 `HNSW` 比 4 个中文字窄一半;
|
|
37
|
+
# · 这里的 `_SECONDARY_BREAK` 是平的,冒号和顿号同权,会把并列项拦腰切开;
|
|
38
|
+
# · 这里一个全局 16 要同时服务 16:9(一行放 44 字)和 9:16(一行放 22 字)。
|
|
39
|
+
#
|
|
40
|
+
# 于是 16 的唯一效果变成了**抢在渲染端之前把句子剁碎**。2026-09-21 线上成片实测:
|
|
41
|
+
# 121.5s 里 57 屏、中位 2.10s 一屏、33% 短于 1.5s,「AI Agent」独占一屏 —— 全都是
|
|
42
|
+
# 这个 16 的产物,渲染端的分屏阈值(20)一次都没被触发。
|
|
43
|
+
#
|
|
44
|
+
# 现在这里只负责一件事:**在真实语音停顿处断行**,让 Minimax 按停顿返回时间戳。
|
|
45
|
+
# 句末(`_PRIMARY_BREAK`)永远断;逗号那一级只在句子长到一行 TTS 太长时才用。
|
|
46
|
+
# 30 是「一个中文长句」的量级 —— 越过它说明这句本来就该有个呼吸点。
|
|
47
|
+
#
|
|
48
|
+
# ⚠️ 别再把它当字幕宽度调:想改一屏显示多少字,改 core 的
|
|
49
|
+
# `DEFAULT_SPLIT_MAX_UNITS` / `LINE_FILL_LIMIT`,那边才看得见字号和画幅。
|
|
50
|
+
MAX_SEGMENT_CHARS = 30
|
|
51
|
+
# 合并下限保持 6:TTS 行太短会让合成的语气断掉(和字幕闪烁是两回事)。
|
|
27
52
|
MIN_SEGMENT_CHARS = 6
|
|
28
53
|
|
|
29
54
|
# 字幕显示时需要去掉的末尾标点(中英文句末 + 逗号等)
|
|
@@ -157,10 +182,15 @@ def _strip_trailing_punct(text: str) -> str:
|
|
|
157
182
|
|
|
158
183
|
|
|
159
184
|
def segment_narration(text: str) -> list[str]:
|
|
160
|
-
"""Split narration
|
|
185
|
+
"""Split narration into **TTS lines**, cutting at speech pauses.
|
|
161
186
|
|
|
162
187
|
Use before TTS: ``"\\n".join(segment_narration(text))`` sends to Minimax
|
|
163
|
-
so it returns per-
|
|
188
|
+
so it returns per-line timestamps.
|
|
189
|
+
|
|
190
|
+
⚠️ 这里切的**不是字幕屏**。字幕分屏归渲染端(core 的
|
|
191
|
+
``chunkSubtitleSegments``),它知道字号、画幅和标点的语义强度;这里只知道
|
|
192
|
+
字符数。这一层切得越细,留给渲染端的余地越小 —— 见 ``MAX_SEGMENT_CHARS``
|
|
193
|
+
的注释。
|
|
164
194
|
"""
|
|
165
195
|
if not text:
|
|
166
196
|
return []
|