@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.
@@ -1,8 +1,81 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.9.37",
4
- "generatedAt": "2026-09-21T03:49:51.210Z",
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.37",
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. **Segment narration at subtitle granularity**: separate segments inside `audio.narration.text` with `\n`; each segment is 10–16 Chinese characters (or an equivalent number of English words). TTS returns per-line timestamps split on `\n`; the renderer uses them directly as subtitle cut points. Segments longer than 18 characters wrap awkwardly on screen; segments shorter than 6 flicker. Example:
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": "Alibaba's open-source AI video-editing project\nalready has 4.3k stars on GitHub"
452
+ "text": "最近刷爆科技圈的 AI Agent 到底厉害在哪\n真正的分水岭不是文案更精准,而是自主执行能力"
447
453
  ```
448
- instead of one continuous paragraph.
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
- _SECONDARY_BREAK = re.compile(r"(?<=[,,、::])")
23
+ # 次级断点**不含顿号 `、`**。顿号是并列项之间的分隔,在它上面下刀就是把一串并列
24
+ # 拦腰切开(`调研、策划、` / `开发、分析,…`)。这一层只数字符、分不出标点的语义
25
+ # 强度,所以干脆不碰它 —— 真需要再切时交给渲染端,那边是分级的(句末 > 冒号 >
26
+ # 逗号 > 顿号 > 空格),只有前面几级都切不动才会降到顿号。
27
+ _SECONDARY_BREAK = re.compile(r"(?<=[,,::])")
24
28
  _PRIMARY_TAIL = re.compile(r"[。!?;\n]\s*$")
25
29
 
26
- MAX_SEGMENT_CHARS = 16
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 text into subtitle-display-sized segments (text only).
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-segment timestamps.
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 []