@remixmate/cli 0.9.26 → 0.9.28

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/README.md CHANGED
@@ -6,7 +6,7 @@ English | [简体中文](./README.zh-CN.md)
6
6
 
7
7
  AI media generation skills for Claude Code / Codex.
8
8
 
9
- 11 skills covering the full short-video production pipeline: image / video / voice / digital-human asset generation, web page capture, script planning, template binding, Remotion rendering, Jianying (CapCut) draft export, and video deconstruction.
9
+ 13 skills covering the full short-video production pipeline: image / video / voice / digital-human asset generation, web page capture and text extraction, script planning, template binding, Remotion rendering, Jianying (CapCut) draft export, and video deconstruction.
10
10
 
11
11
  ## Install
12
12
 
@@ -159,11 +159,11 @@ cp .env.example .env
159
159
  source .env
160
160
  ```
161
161
 
162
- Python skills (8 of 12) require `python3 >= 3.10`. `web-screenshot` / `web-record` require Playwright (auto-installs chromium on first run). `ffmpeg` is only needed for `video-parser`'s optional local tool (`deconstruct_video.py`); the default `video-parser` entry runs server-side via ab-render and needs no local ffmpeg.
162
+ Python skills (9 of 13) require `python3 >= 3.10`. `web-screenshot` / `web-record` / `web-read` require Playwright (auto-installs chromium on first run). `ffmpeg` is only needed for `video-parser`'s optional local tool (`deconstruct_video.py`); the default `video-parser` entry runs server-side via ab-render and needs no local ffmpeg.
163
163
 
164
164
  ## Skills
165
165
 
166
- This project contains 11 AI media generation skills covering the full pipeline from asset generation, script orchestration, template binding, and video rendering to Jianying export.
166
+ This project contains 13 AI media generation skills covering the full pipeline from asset generation, script orchestration, template binding, and video rendering to Jianying export.
167
167
 
168
168
  ### Skill layers
169
169
 
@@ -186,6 +186,7 @@ This project contains 11 AI media generation skills covering the full pipeline f
186
186
  │ video-parser Video deconstruction (audio / ASR / kf) │
187
187
  │ web-screenshot Web page screenshot (png / jpg) │
188
188
  │ web-record Web page recording / storyboard video │
189
+ │ web-read Web page text extraction (markdown/json) │
189
190
  └─────────────────────────────────────────────────────────────┘
190
191
  ```
191
192
 
@@ -207,7 +208,8 @@ This project contains 11 AI media generation skills covering the full pipeline f
207
208
  │ ├── export-jianying/ # orchestration: export Jianying draft ZIP
208
209
  │ ├── video-parser/ # tool: video deconstruction & analysis
209
210
  │ ├── web-screenshot/ # tool: headless-browser screenshot (also hosts record.py)
210
- └── web-record/ # tool: headless-browser recording (entry -> web-screenshot/scripts/record.py)
211
+ ├── web-record/ # tool: headless-browser recording (entry -> web-screenshot/scripts/record.py)
212
+ │ └── web-read/ # tool: headless-browser text extraction (entry -> web-screenshot/scripts/read_page.py)
211
213
  └── README.md
212
214
  ```
213
215
 
@@ -227,6 +229,7 @@ This project contains 11 AI media generation skills covering the full pipeline f
227
229
  | video-parser | tool | Video → audio + ASR + keyframes + scene segmentation | python `scripts/parse_via_render.py` |
228
230
  | web-screenshot | tool | Web page screenshot (png / jpg) | python `scripts/screenshot.py` |
229
231
  | web-record | tool | Web page recording / scroll-through / storyboard video (webm -> mp4 -> VOD) | python `../web-screenshot/scripts/record.py` |
232
+ | web-read | tool | Web page → main text (markdown / plain text / structured JSON) | python `../web-screenshot/scripts/read_page.py` |
230
233
 
231
234
  ### Core workflows
232
235
 
package/README.zh-CN.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  面向 Claude Code / Codex 的 AI 媒体生成技能集。
8
8
 
9
- 包含 11 个技能,覆盖完整的短视频生产链路:图片 / 视频 / 语音 / 数字人素材生成、网页捕获、脚本编排、模板绑定、Remotion 渲染、剪映(CapCut)草稿导出,以及视频解构。
9
+ 包含 13 个技能,覆盖完整的短视频生产链路:图片 / 视频 / 语音 / 数字人素材生成、网页捕获与正文抽取、脚本编排、模板绑定、Remotion 渲染、剪映(CapCut)草稿导出,以及视频解构。
10
10
 
11
11
  ## 安装
12
12
 
@@ -113,11 +113,11 @@ cp .env.example .env
113
113
  source .env
114
114
  ```
115
115
 
116
- Python 技能(12 个中的 8 个)需要 `python3 >= 3.10`。`web-screenshot` / `web-record` 需要 Playwright(首次运行自动安装 chromium)。`ffmpeg` 仅 `video-parser` 的可选本地工具(`deconstruct_video.py`)需要;`video-parser` 默认入口走 ab-render 服务端解构,无需本地 ffmpeg。
116
+ Python 技能(13 个中的 9 个)需要 `python3 >= 3.10`。`web-screenshot` / `web-record` / `web-read` 需要 Playwright(首次运行自动安装 chromium)。`ffmpeg` 仅 `video-parser` 的可选本地工具(`deconstruct_video.py`)需要;`video-parser` 默认入口走 ab-render 服务端解构,无需本地 ffmpeg。
117
117
 
118
118
  ## 技能
119
119
 
120
- 本项目包含 12 个 AI 媒体生成技能,覆盖从素材生成、脚本编排、模板绑定、视频渲染到剪映导出的完整视频内容生产链路。
120
+ 本项目包含 13 个 AI 媒体生成技能,覆盖从素材生成、脚本编排、模板绑定、视频渲染到剪映导出的完整视频内容生产链路。
121
121
 
122
122
  ### 技能分层
123
123
 
@@ -140,6 +140,7 @@ Python 技能(12 个中的 8 个)需要 `python3 >= 3.10`。`web-screenshot`
140
140
  │ video-parser 视频解构(音频提取 / ASR / 关键帧) │
141
141
  │ web-screenshot 网页截图(png / jpg) │
142
142
  │ web-record 网页录屏 / 滚动录屏 / 分镜视频 │
143
+ │ web-read 网页正文抽取(markdown / 纯文本 / JSON) │
143
144
  └─────────────────────────────────────────────────────────────┘
144
145
  ```
145
146
 
@@ -161,7 +162,8 @@ Python 技能(12 个中的 8 个)需要 `python3 >= 3.10`。`web-screenshot`
161
162
  │ ├── export-jianying/ # 编排: 导出剪映草稿 ZIP
162
163
  │ ├── video-parser/ # 工具: 视频解构与分析
163
164
  │ ├── web-screenshot/ # 工具: 无头浏览器截图(record.py 也放在这里)
164
- └── web-record/ # 工具: 无头浏览器录屏(入口指向 web-screenshot/scripts/record.py)
165
+ ├── web-record/ # 工具: 无头浏览器录屏(入口指向 web-screenshot/scripts/record.py)
166
+ │ └── web-read/ # 工具: 无头浏览器正文抽取(入口指向 web-screenshot/scripts/read_page.py)
165
167
  └── README.md
166
168
  ```
167
169
 
@@ -181,6 +183,7 @@ Python 技能(12 个中的 8 个)需要 `python3 >= 3.10`。`web-screenshot`
181
183
  | video-parser | 工具 | 视频 → 音频 + ASR + 关键帧 + 场景分段 | python `scripts/parse_via_render.py` |
182
184
  | web-screenshot | 工具 | 网页截图(png / jpg) | python `scripts/screenshot.py` |
183
185
  | web-record | 工具 | 网页录屏 / 滚动录屏 / 分镜视频(webm → mp4 → VOD) | python `../web-screenshot/scripts/record.py` |
186
+ | web-read | 工具 | 网页正文抽取(markdown / 纯文本 / 结构化 JSON) | python `../web-screenshot/scripts/read_page.py` |
184
187
 
185
188
  ### 核心链路
186
189
 
@@ -218,6 +221,7 @@ remixmate export-jianying --help
218
221
  remixmate video-parser --help
219
222
  remixmate web-screenshot --help
220
223
  remixmate web-record --help
224
+ remixmate web-read --help
221
225
  remixmate template-registry --help
222
226
 
223
227
  # http 技能(gen-image、gen-video、gen-voice、gen-digital-human)没有
@@ -4,9 +4,19 @@
4
4
  * Operations:
5
5
  * --list-voices true → GET available voice IDs from /voice/page (live)
6
6
  * --list-voices true --local true → print the built-in language-tagged
7
- * fallback catalog (offline, no API call)
7
+ * fallback catalog (offline, no API call).
8
+ * **CLI-only escape hatch for offline dev.**
8
9
  * default → synthesize text into an MP3 URL
9
10
  *
11
+ * `local` is deliberately absent from `skills/gen-voice/skill.json`'s
12
+ * `parameters`, which is what ab-agent turns into the model-facing tool schema.
13
+ * A model asked to check a voice id would reach for `local: true`, get a
14
+ * two-entry hardcoded list back, and conclude that a perfectly valid voice does
15
+ * not exist — which is exactly what happened on the hosted web agent
16
+ * ("本地回退列表中没有该音色"). The remote catalog is the only authority on
17
+ * which voices exist; the local list is a developer convenience for working
18
+ * without a token, and it is only reachable by typing the flag yourself.
19
+ *
10
20
  * The previous Python script also offered `--download` to write the MP3 to
11
21
  * disk. That's intentionally dropped here: the agent flow never used it (the
12
22
  * audio URL is persisted in cloud) and removing local file I/O keeps the
@@ -4,9 +4,19 @@
4
4
  * Operations:
5
5
  * --list-voices true → GET available voice IDs from /voice/page (live)
6
6
  * --list-voices true --local true → print the built-in language-tagged
7
- * fallback catalog (offline, no API call)
7
+ * fallback catalog (offline, no API call).
8
+ * **CLI-only escape hatch for offline dev.**
8
9
  * default → synthesize text into an MP3 URL
9
10
  *
11
+ * `local` is deliberately absent from `skills/gen-voice/skill.json`'s
12
+ * `parameters`, which is what ab-agent turns into the model-facing tool schema.
13
+ * A model asked to check a voice id would reach for `local: true`, get a
14
+ * two-entry hardcoded list back, and conclude that a perfectly valid voice does
15
+ * not exist — which is exactly what happened on the hosted web agent
16
+ * ("本地回退列表中没有该音色"). The remote catalog is the only authority on
17
+ * which voices exist; the local list is a developer convenience for working
18
+ * without a token, and it is only reachable by typing the flag yourself.
19
+ *
10
20
  * The previous Python script also offered `--download` to write the MP3 to
11
21
  * disk. That's intentionally dropped here: the agent flow never used it (the
12
22
  * audio URL is persisted in cloud) and removing local file I/O keeps the
@@ -21,6 +31,10 @@ import { isTrue, toNumber } from './shared.js';
21
31
  * Mirrors the language-keyed defaults that gen_script.py uses, kept in sync
22
32
  * by hand because the two callers live on different runtimes.
23
33
  *
34
+ * This is **not** a list of available voices — it is the two ids the resolver
35
+ * falls back to when nothing else is specified, and one of them is still a
36
+ * placeholder. Never treat it as evidence about what the service offers.
37
+ *
24
38
  * Promote to a JSON config once this grows beyond a handful of entries.
25
39
  */
26
40
  const LOCAL_VOICE_CATALOG = [
@@ -44,6 +58,8 @@ async function listVoicesRemote(ctx) {
44
58
  process.stdout.write(`💡 Use --voice-id <id> to select a voice for synthesis\n`);
45
59
  }
46
60
  function listVoicesLocal() {
61
+ process.stderr.write('⚠️ --local prints a hardcoded 2-entry fallback list, NOT the available voices. ' +
62
+ 'Drop --local to query the live catalog.\n');
47
63
  for (const v of LOCAL_VOICE_CATALOG) {
48
64
  process.stdout.write(`${v.id}\t${v.language}\t${v.name}\n`);
49
65
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.9.26",
4
- "generatedAt": "2026-09-13T00:51:13.551Z",
3
+ "version": "0.9.28",
4
+ "generatedAt": "2026-09-13T13:40:09.148Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "export-jianying",
@@ -636,7 +636,7 @@
636
636
  },
637
637
  "voice_id": {
638
638
  "type": "string",
639
- "description": "Voice id. Default 'Chinese (Mandarin)_Male_Announcer'. When unsure, call with list_voices=true first to see what's available — do not invent ids."
639
+ "description": "Voice id. Default 'Chinese (Mandarin)_Male_Announcer'. When unsure, call with list_voices=true first to see what's available — do not invent ids, and do not declare a voice unavailable unless it is missing from that list."
640
640
  },
641
641
  "speed": {
642
642
  "type": "number",
@@ -644,11 +644,7 @@
644
644
  },
645
645
  "list_voices": {
646
646
  "type": "boolean",
647
- "description": "List available voices and exit"
648
- },
649
- "local": {
650
- "type": "boolean",
651
- "description": "Used together with list_voices=true: print the voice-resolver fallback catalog with language tags (no remote /voice/page call). Output is one '<id>\\t<lang>\\t<name>' line per voice."
647
+ "description": "List the available voices and exit. This queries the live voice service and is the authoritative catalog — one call is enough, and its absence from this list is the only evidence that a voice id is invalid."
652
648
  },
653
649
  "json_output": {
654
650
  "type": "boolean",
@@ -667,8 +663,7 @@
667
663
  ],
668
664
  "hidden": [
669
665
  "json_output",
670
- "list_voices",
671
- "local"
666
+ "list_voices"
672
667
  ]
673
668
  }
674
669
  },
@@ -965,6 +960,162 @@
965
960
  ]
966
961
  }
967
962
  },
963
+ {
964
+ "id": "web-read",
965
+ "toolName": "web_read",
966
+ "tier": "tool",
967
+ "category": "consuming",
968
+ "title": "Web Page Reader",
969
+ "summary": "Open any URL in a headless browser (Playwright Python) and return the page's MAIN TEXT — title, headings, paragraphs, lists, code blocks and tables — as Markdown, plain text, or structured JSON. Boilerplate (nav / sidebar / comments / ads / footer) is stripped by a Readability-style pass, and JS-rendered pages work because a real browser runs the page. This is the tool to use whenever you need to KNOW WHAT A PAGE SAYS: summarizing an article, pulling source material for a script, reading a README or docs page, checking what a link contains. It returns text, not pictures — for a screenshot (png/jpg) use web_screenshot, for a recording (mp4/webm) use web_record. Output is capped by max_chars (default 20000) and truncated on a block boundary; pass an `output` path to keep the full text on disk.",
970
+ "triggers": [
971
+ "Read this link / what does this page say / summarize this article",
972
+ "Fetch page content, extract article text, get the text of a URL",
973
+ "Use a web page as source material for a script or video",
974
+ "Read a README / docs page / changelog / blog post",
975
+ "Pull code samples or tables out of a page"
976
+ ],
977
+ "entry": {
978
+ "type": "python",
979
+ "scriptPath": "../web-screenshot/scripts/read_page.py"
980
+ },
981
+ "runtime": "python",
982
+ "envVars": [
983
+ "WEB_CAPTURE_BROWSER",
984
+ "WEB_CAPTURE_ALLOW_PRIVATE_HOSTS",
985
+ "PLAYWRIGHT_BROWSERS_PATH"
986
+ ],
987
+ "parameters": {
988
+ "type": "object",
989
+ "properties": {
990
+ "url": {
991
+ "type": "string",
992
+ "description": "Target page URL (http/https). Private / loopback / link-local addresses are refused unless WEB_CAPTURE_ALLOW_PRIVATE_HOSTS=1."
993
+ },
994
+ "format": {
995
+ "type": "string",
996
+ "enum": [
997
+ "markdown",
998
+ "text",
999
+ "json"
1000
+ ],
1001
+ "description": "markdown (default: keeps headings, lists, code fences, tables) | text (plain) | json (structured blocks + metadata, not truncated)"
1002
+ },
1003
+ "max_chars": {
1004
+ "type": "number",
1005
+ "description": "Cap on the printed text, cut at a block boundary with an explicit [truncated] notice (default 20000, 0 = unlimited). Raise it when you need the whole document; a very long page will otherwise fill your context."
1006
+ },
1007
+ "selector": {
1008
+ "type": "string",
1009
+ "description": "Read only inside this CSS selector. Leave empty to auto-detect the article container — only reach for this when the auto-detected container was wrong."
1010
+ },
1011
+ "include_links": {
1012
+ "type": "boolean",
1013
+ "description": "Keep hyperlinks as [text](url) instead of plain text. Useful when you need to follow links from the page."
1014
+ },
1015
+ "include_images": {
1016
+ "type": "boolean",
1017
+ "description": "Keep images as ![alt](src). Useful for harvesting illustration URLs out of an article."
1018
+ },
1019
+ "output": {
1020
+ "type": "string",
1021
+ "description": "Also write the FULL (untruncated) text to this local path. stdout still respects max_chars — use this when a long page must be kept for later steps."
1022
+ },
1023
+ "settle_ms": {
1024
+ "type": "number",
1025
+ "description": "Extra wait before extracting, in ms. Raise for pages that render content late."
1026
+ },
1027
+ "wait_for_selector": {
1028
+ "type": "string",
1029
+ "description": "Wait for this CSS selector before extracting (the reliable fix for JS-rendered content)"
1030
+ },
1031
+ "wait_for_timeout": {
1032
+ "type": "number",
1033
+ "description": "Fixed wait before extracting, in ms"
1034
+ },
1035
+ "device": {
1036
+ "type": "string",
1037
+ "description": "Device emulation name, e.g. 'iPhone 15 Pro' — some sites serve a leaner page to mobile"
1038
+ },
1039
+ "viewport": {
1040
+ "type": "string",
1041
+ "description": "Viewport as 'width,height', e.g. '1280,800'"
1042
+ },
1043
+ "color_scheme": {
1044
+ "type": "string",
1045
+ "enum": [
1046
+ "light",
1047
+ "dark",
1048
+ "no-preference"
1049
+ ],
1050
+ "description": "Emulate prefers-color-scheme"
1051
+ },
1052
+ "user_agent": {
1053
+ "type": "string",
1054
+ "description": "Override the User-Agent (try this when a site blocks headless browsers)"
1055
+ },
1056
+ "timeout": {
1057
+ "type": "number",
1058
+ "description": "Global Playwright action timeout in ms"
1059
+ },
1060
+ "ignore_https_errors": {
1061
+ "type": "boolean",
1062
+ "description": "Ignore HTTPS certificate errors"
1063
+ },
1064
+ "storage_state": {
1065
+ "type": "string",
1066
+ "description": "Path to a Playwright storageState JSON file (logged-in session)"
1067
+ },
1068
+ "cookies": {
1069
+ "type": "string",
1070
+ "description": "Playwright cookies as a JSON string or a path to a JSON file (top level is an array)"
1071
+ },
1072
+ "browser": {
1073
+ "type": "string",
1074
+ "enum": [
1075
+ "chromium",
1076
+ "firefox",
1077
+ "webkit"
1078
+ ],
1079
+ "description": "Browser engine (default chromium)"
1080
+ },
1081
+ "quiet": {
1082
+ "type": "boolean",
1083
+ "description": "Suppress the extraction diagnostics on stderr"
1084
+ }
1085
+ },
1086
+ "required": [
1087
+ "url"
1088
+ ]
1089
+ },
1090
+ "ui": {
1091
+ "primary": [
1092
+ "url",
1093
+ "format",
1094
+ "max_chars"
1095
+ ],
1096
+ "advanced": [
1097
+ "selector",
1098
+ "include_links",
1099
+ "include_images",
1100
+ "wait_for_selector",
1101
+ "settle_ms",
1102
+ "device",
1103
+ "viewport",
1104
+ "color_scheme",
1105
+ "timeout"
1106
+ ],
1107
+ "hidden": [
1108
+ "output",
1109
+ "quiet",
1110
+ "user_agent",
1111
+ "ignore_https_errors",
1112
+ "storage_state",
1113
+ "cookies",
1114
+ "browser",
1115
+ "wait_for_timeout"
1116
+ ]
1117
+ }
1118
+ },
968
1119
  {
969
1120
  "id": "web-record",
970
1121
  "toolName": "web_record",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.26",
3
+ "version": "0.9.28",
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",
@@ -21,9 +21,11 @@
21
21
  "smoke": "node scripts/smoke.mjs",
22
22
  "test:cli": "npm run build && node --test test/*.test.mjs",
23
23
  "test:validators": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-validators.py",
24
+ "test:web-read": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-web-read.py",
24
25
  "test:template-pipeline": "PYTHONDONTWRITEBYTECODE=1 node dist/cli.js exec -- python3 scripts/test-template-pipeline.py",
25
26
  "test:props-contract": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-props-contract.py",
26
27
  "test:narration-speed": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-narration-speed.py",
28
+ "test:render-resume": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-resume.py",
27
29
  "test:render-plan": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-plan-snapshot.py",
28
30
  "test:render-plan:update": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-plan-snapshot.py --update",
29
31
  "test:contracts": "PYTHONDONTWRITEBYTECODE=1 python3 skills/template-registry/scripts/check_contracts.py",
@@ -1442,7 +1442,7 @@ Examples:
1442
1442
  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.")
1443
1443
  parser.add_argument("--resolution", default="1080p", help="Resolution (default: 1080p)")
1444
1444
  parser.add_argument("--scenes", type=int, default=None, help="Scene count (default: auto-planned)")
1445
- 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 --local prints the language-tagged catalog.")
1445
+ 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.")
1446
1446
  parser.add_argument(
1447
1447
  "--speed",
1448
1448
  type=float,
@@ -46,13 +46,15 @@ There is no skill-local env file — the executing process inherits the system e
46
46
  remixmate gen-voice --list-voices
47
47
  ```
48
48
 
49
- To inspect the local language-tagged fallback catalog used by the voice resolver (no remote API call), add `--local`:
49
+ The list above is the **authoritative** catalog a voice id is invalid only if it is missing from it.
50
+
51
+ For offline development (no token / no network) there is a `--local` escape hatch. It prints the resolver's two hardcoded fallback ids, **not** the available voices, so never use it to decide whether a voice exists:
50
52
 
51
53
  ```bash
52
54
  remixmate gen-voice --list-voices --local
53
55
  ```
54
56
 
55
- The local catalog prints one voice per line as `<voice-id>\t<lang>\t<display-name>`.
57
+ It prints one line per entry as `<voice-id>\t<lang>\t<display-name>`.
56
58
 
57
59
  ### Default synthesis (URL output)
58
60
 
@@ -83,7 +85,7 @@ remixmate gen-voice --text "<text-to-synthesize>" --json-output
83
85
  | `--voice-id` | Voice id (use `--list-voices` to discover) | `Chinese (Mandarin)_Male_Announcer` |
84
86
  | `--speed` | Speech rate, 0.5–2.0 | `1.0` |
85
87
  | `--list-voices` | List available voices and exit | — |
86
- | `--local` | Used with `--list-voices`: print the local fallback catalog (offline) | off |
88
+ | `--local` | CLI-only. With `--list-voices`: print the 2-entry hardcoded fallback list instead of querying the service (offline dev). Not exposed to the agent — see the handler comment. | off |
87
89
  | `--json-output` | Emit JSON (url, audio_length_ms, subtitles) | off |
88
90
  | `--priv-token` | Override token | env var |
89
91
 
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "voice_id": {
27
27
  "type": "string",
28
- "description": "Voice id. Default 'Chinese (Mandarin)_Male_Announcer'. When unsure, call with list_voices=true first to see what's available — do not invent ids."
28
+ "description": "Voice id. Default 'Chinese (Mandarin)_Male_Announcer'. When unsure, call with list_voices=true first to see what's available — do not invent ids, and do not declare a voice unavailable unless it is missing from that list."
29
29
  },
30
30
  "speed": {
31
31
  "type": "number",
@@ -33,11 +33,7 @@
33
33
  },
34
34
  "list_voices": {
35
35
  "type": "boolean",
36
- "description": "List available voices and exit"
37
- },
38
- "local": {
39
- "type": "boolean",
40
- "description": "Used together with list_voices=true: print the voice-resolver fallback catalog with language tags (no remote /voice/page call). Output is one '<id>\\t<lang>\\t<name>' line per voice."
36
+ "description": "List the available voices and exit. This queries the live voice service and is the authoritative catalog — one call is enough, and its absence from this list is the only evidence that a voice id is invalid."
41
37
  },
42
38
  "json_output": {
43
39
  "type": "boolean",
@@ -56,8 +52,7 @@
56
52
  ],
57
53
  "hidden": [
58
54
  "json_output",
59
- "list_voices",
60
- "local"
55
+ "list_voices"
61
56
  ]
62
57
  }
63
58
  }
@@ -61,6 +61,19 @@ class RemoteRenderError(RuntimeError):
61
61
  """抛出至调用方,由上层写入 render_plan.errors。"""
62
62
 
63
63
 
64
+ class RemoteRenderTimeout(RemoteRenderError):
65
+ """轮询超时 —— 任务**没有失败**,只是我们不等了。
66
+
67
+ 与普通 RemoteRenderError 分开,是因为上层的处置完全相反:
68
+ 真失败 → 可以重新提交一个任务;超时 → 后端还在渲,重新提交等于白烧一遍,
69
+ 必须保留 taskId 让下一次调用续上。
70
+ """
71
+
72
+ def __init__(self, message: str, task_id: str):
73
+ super().__init__(message)
74
+ self.task_id = task_id
75
+
76
+
64
77
  def _build_headers(private_token: str, content_type: str = "application/json", conversation_id: Optional[str] = None) -> dict:
65
78
  if not private_token:
66
79
  raise RemoteRenderError("PrivToken is not set (PRIV_TOKEN or --priv-token)")
@@ -174,9 +187,10 @@ def poll_render(
174
187
  while True:
175
188
  elapsed = time.monotonic() - start
176
189
  if elapsed > timeout:
177
- raise RemoteRenderError(
190
+ raise RemoteRenderTimeout(
178
191
  f"remote render polling timed out (waited {elapsed:.0f}s, taskId={task_id}); "
179
- f"the job may still be running on the backend — query {status_path} later"
192
+ f"the job may still be running on the backend — query {status_path} later",
193
+ task_id,
180
194
  )
181
195
 
182
196
  try: