yuanflow-cli 0.1.44 → 0.1.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -4
- package/package.json +1 -1
- package/skills/yuanflow-skill//345/243/260/351/237/263/345/205/213/351/232/206/SKILL.md +146 -0
- package/skills/yuanflow-skill//345/243/260/351/237/263/345/244/215/345/210/273/SKILL.md +103 -0
- package/src/agent-protocol.js +3 -0
- package/src/cli.js +24 -0
- package/src/voice-tools.js +471 -0
package/README.md
CHANGED
|
@@ -6,6 +6,7 @@ YuanFlow 的 npm 包,包含两个命令入口:
|
|
|
6
6
|
- `yuanflow-skill`:把 `YuanFlow-skill` 注入到本机支持的 AI Agent skills 目录。
|
|
7
7
|
- `飞书官方技能`:YuanFlow-main 内置环境通过受控工具托管安装并调用飞书官方 `@larksuite/cli`,npm 包只提供 Skill 说明,不复制官方 CLI 源码。
|
|
8
8
|
- `视觉卡片生成`:作为 Skill、HTML 模板、参考文档和检查脚本随包分发,不新增独立 `yuanflow-cli visual-card` 命令。
|
|
9
|
+
- `声音克隆 / 声音复刻`:通过 `yuanflow-cli voice` 命令创建、查询、激活 `voice_xxx`,并把文本复刻生成音频文件。
|
|
9
10
|
|
|
10
11
|
## 安装
|
|
11
12
|
|
|
@@ -53,6 +54,10 @@ yuanflow-cli video plan --project "D:\素材\yuanflow-video-edit" --timeline-pla
|
|
|
53
54
|
yuanflow-cli ai qwen3-vl-plus --prompt "描述这张图" --image-url "https://example.com/image.png" --format agent-json
|
|
54
55
|
yuanflow-cli ai qwen3-vl-plus --prompt "总结这个视频画面" --video-url "https://example.com/video.mp4" --format agent-json
|
|
55
56
|
yuanflow-cli ai qwen3-vl-plus --prompt "描述本地视频" --video-file "D:\素材\demo.mp4" --format agent-json
|
|
57
|
+
yuanflow-cli voice clone --file-transfer "D:\voice\sample.wav" --name "我的声音" --activate --format agent-json
|
|
58
|
+
yuanflow-cli voice list --format agent-json
|
|
59
|
+
yuanflow-cli voice activate --voice voice_xxx --format agent-json
|
|
60
|
+
yuanflow-cli voice replicate --text "你好,这是声音复刻测试。" --voice voice_xxx --output "D:\voice\replicate.mp3" --format agent-json
|
|
56
61
|
yuanflow-cli ai doubao-tts voices --format agent-json
|
|
57
62
|
yuanflow-cli ai doubao-tts voice --voice zh_female_xiaohe_uranus_bigtts --format agent-json
|
|
58
63
|
yuanflow-cli ai doubao-tts voice-download --voice zh_female_xiaohe_uranus_bigtts --output "D:\voice\preview.mp3" --format agent-json
|
|
@@ -69,6 +74,33 @@ YUANCHUANG_API_TOKEN=<你的令牌>
|
|
|
69
74
|
|
|
70
75
|
token 优先级:`--token` > `YUANCHUANG_API_TOKEN` > 本地 `config.token`。独立 CLI 用户可以使用环境变量或 `config set-token`;在 YuanFlow-main 内置环境使用时,token 由 YuanFlow-main 内置环境注入,不需要手动配置。本地图片/视频上传统一使用 YuanFlow 文件中转,不需要用户配置第三方平台 Key。
|
|
71
76
|
|
|
77
|
+
### 声音克隆与声音复刻
|
|
78
|
+
|
|
79
|
+
`voice` 命令组用于创建声音克隆、查询已有克隆音色 ID、激活默认音色,并使用 `voice_xxx` 把文本复刻为音频文件。声音复刻前必须先有 `voice_xxx`,或已激活默认音色。
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# 创建声音克隆:本地音频先走 YuanFlow 文件中转
|
|
83
|
+
yuanflow-cli voice clone --file-transfer "D:\voice\sample.wav" --name "我的声音" --activate --format agent-json
|
|
84
|
+
|
|
85
|
+
# 查询已有声音克隆 ID
|
|
86
|
+
yuanflow-cli voice list --format agent-json
|
|
87
|
+
|
|
88
|
+
# 激活某个克隆音色为默认音色
|
|
89
|
+
yuanflow-cli voice activate --voice voice_xxx --format agent-json
|
|
90
|
+
|
|
91
|
+
# 使用克隆音色生成复刻音频
|
|
92
|
+
yuanflow-cli voice replicate --text "你好,这是声音复刻测试。" --voice voice_xxx --output "D:\voice\replicate.mp3" --format agent-json
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
命令清单:
|
|
96
|
+
|
|
97
|
+
- `voice clone`:`POST /v1/audio/voices`,创建声音克隆,常用参数 `--file`、`--file-transfer`、`--audio-url`、`--name`、`--activate`。
|
|
98
|
+
- `voice list`:`GET /v1/audio/voices`,查询当前用户已有声音克隆音色 ID。
|
|
99
|
+
- `voice activate`:`POST /v1/audio/voices/{voice_xxx}/activate`,把已有声音克隆设为默认音色。
|
|
100
|
+
- `voice replicate`:`POST /v1/audio/speech`,使用 `voice_xxx` 或 `default` 生成复刻音频,真实调用时必须传 `--output`。
|
|
101
|
+
|
|
102
|
+
`--file-transfer` 会先通过 YuanFlow 文件中转上传本地音频,再把临时访问链接提交给 YuanFlow API。`--file` 保留为直接提交本地音频的兼容方式。`--file`、`--file-transfer` 和 `--audio-url` 只能选择一个。
|
|
103
|
+
|
|
72
104
|
### AI 模型命令
|
|
73
105
|
|
|
74
106
|
`ai` 命令用于调用 YuanFlow API 的 OpenAI 兼容端点。CLI 对外只使用 YuanFlow API 模型参数,不暴露底层供应商内部模型名。
|
|
@@ -79,8 +111,6 @@ yuanflow-cli ai qwen3-vl-plus --prompt "描述这张图" --image-url "https://ex
|
|
|
79
111
|
yuanflow-cli ai qwen3-vl-plus --prompt "总结这个视频画面" --video-url "https://example.com/video.mp4" --format agent-json
|
|
80
112
|
yuanflow-cli ai qwen3-vl-plus --prompt "描述本地图片" --image-file "D:\素材\cover.png" --format agent-json
|
|
81
113
|
yuanflow-cli ai qwen3-vl-plus --prompt "描述本地视频" --video-file "D:\素材\demo.mp4" --format agent-json
|
|
82
|
-
yuanflow-cli ai qwen-voice-enrollment --file "D:\voice\sample.wav" --name demo --activate --format agent-json
|
|
83
|
-
yuanflow-cli ai qwen3-tts-vc-realtime-2026-01-15 --text "你好" --voice voice_xxx --output "D:\voice\qwen.mp3" --format agent-json
|
|
84
114
|
yuanflow-cli ai fun-asr --audio-url "https://example.com/audio.wav" --response-format verbose_json --format agent-json
|
|
85
115
|
yuanflow-cli ai doubao-tts voices --format agent-json
|
|
86
116
|
yuanflow-cli ai doubao-tts voice --voice zh_female_xiaohe_uranus_bigtts --format agent-json
|
|
@@ -122,8 +152,6 @@ yuanflow-cli ai qwen3-vl-plus --prompt "描述本地视频" --video-file "D:\素
|
|
|
122
152
|
命令清单:
|
|
123
153
|
|
|
124
154
|
- `ai qwen3-vl-plus`:`POST /v1/chat/completions`,文本/图片/视频理解,常用参数 `--prompt`、`--image-url`、`--video-url`、`--image-file`、`--video-file`。
|
|
125
|
-
- `ai qwen-voice-enrollment`:`POST /v1/audio/voices`,音色复刻,常用参数 `--file` 或 `--audio-url`、`--name`、`--activate`。
|
|
126
|
-
- `ai qwen3-tts-vc-realtime-2026-01-15`:`POST /v1/audio/speech`,复刻音色合成,`--voice` 使用 `voice_xxx` 或 `default`。
|
|
127
155
|
- `ai fun-asr`:`POST /v1/audio/transcriptions`,语音识别,`--audio-url` 适合远程音频,`--file` 适合本地音频直传。
|
|
128
156
|
- `ai doubao-tts`:`POST /v1/audio/speech`,豆包语音合成,`--voice` 直接传豆包官方音色 ID。
|
|
129
157
|
- `ai doubao-tts voices`:`GET /api/voice-assets/doubao/voices`,查询 doubao-tts 可用音色列表。
|
package/package.json
CHANGED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 声音克隆
|
|
3
|
+
description: 当用户需要创建声音克隆、查询已有声音克隆音色 ID、激活默认克隆音色,或为后续声音复刻准备 voice_xxx 时使用。本 Skill 通过 YuanFlow API 和 yuanflow-cli voice 命令完成。
|
|
4
|
+
emoji: 🎙️
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 声音克隆
|
|
8
|
+
|
|
9
|
+
本 Skill 用于把参考音频注册成可复用的声音克隆音色,并返回 `voice_xxx` 音色 ID。后续要生成复刻音频时,交给 `声音复刻` Skill。
|
|
10
|
+
|
|
11
|
+
## 外部 CLI 主流程
|
|
12
|
+
|
|
13
|
+
外部 Agent 或用户直接使用时,优先使用 `yuanflow-cli voice ...` 命令。
|
|
14
|
+
|
|
15
|
+
1. 先确认本机可执行 `yuanflow-cli --help`。
|
|
16
|
+
2. 外部 CLI 使用 `YUANCHUANG_API_TOKEN` 或 `yuanflow-cli config set-token <你的令牌>` 完成鉴权。
|
|
17
|
+
3. 选择音频输入方式:
|
|
18
|
+
- 本地音频直接提交:`--file`
|
|
19
|
+
- 本地音频先走 YuanFlow 文件中转:`--file-transfer`
|
|
20
|
+
- 已有公网音频链接:`--audio-url`
|
|
21
|
+
4. 创建成功后,保存返回的 `voice_xxx`。这是后续声音复刻必须使用的音色 ID。
|
|
22
|
+
|
|
23
|
+
不要在回复、日志或文件中暴露 token。用户主流程统一称为 YuanFlow API 和 YuanFlow 文件中转,不要求用户配置第三方平台 Key。
|
|
24
|
+
|
|
25
|
+
## 创建声音克隆
|
|
26
|
+
|
|
27
|
+
推荐本地文件使用 YuanFlow 文件中转:
|
|
28
|
+
|
|
29
|
+
```powershell
|
|
30
|
+
yuanflow-cli voice clone --file-transfer "D:\voice\sample.wav" --name "我的声音" --activate --format agent-json
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
如果用户明确希望直接提交本地音频:
|
|
34
|
+
|
|
35
|
+
```powershell
|
|
36
|
+
yuanflow-cli voice clone --file "D:\voice\sample.wav" --name "我的声音" --activate --format agent-json
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
如果已经有可访问音频 URL:
|
|
40
|
+
|
|
41
|
+
```powershell
|
|
42
|
+
yuanflow-cli voice clone --audio-url "https://example.com/sample.wav" --name "我的声音" --activate --format agent-json
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
返回里重点读取:
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
data.response.id
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
如果响应结构被 Agent JSON 包裹,优先找 `voice_xxx` 格式的 `id` 字段。不要把内部原始音色参数展示给用户。
|
|
52
|
+
|
|
53
|
+
## 查询已有声音克隆 ID
|
|
54
|
+
|
|
55
|
+
当用户已经做过声音克隆但忘记 ID,先查询:
|
|
56
|
+
|
|
57
|
+
```powershell
|
|
58
|
+
yuanflow-cli voice list --format agent-json
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
输出时只需要整理这些字段:
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
声音名称:
|
|
65
|
+
音色 ID:voice_xxx
|
|
66
|
+
状态:
|
|
67
|
+
是否默认:
|
|
68
|
+
创建时间:
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
如果没有可用音色,提示用户先提供参考音频创建声音克隆。
|
|
72
|
+
|
|
73
|
+
## 激活默认音色
|
|
74
|
+
|
|
75
|
+
如果用户希望后续用 `default` 复刻,或指定某个克隆音色为默认:
|
|
76
|
+
|
|
77
|
+
```powershell
|
|
78
|
+
yuanflow-cli voice activate --voice voice_xxx --format agent-json
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## 音频要求
|
|
82
|
+
|
|
83
|
+
- 优先使用清晰、无背景音乐、无明显混响的人声音频。
|
|
84
|
+
- 建议使用 wav、mp3、m4a、flac 等常见格式。
|
|
85
|
+
- 文件过大、噪音明显或多人混说时,先让用户换音频或裁剪。
|
|
86
|
+
- 不要上传身份证、合同、私密通话、未授权人物声音等敏感内容,除非用户明确确认且任务确实需要。
|
|
87
|
+
|
|
88
|
+
## YuanFlow-main 内置环境
|
|
89
|
+
|
|
90
|
+
只有在 YuanFlow-main 内置环境中,才使用受控工具 `yuanflow_cli_call`。token、受管包路径和输出目录由 YuanFlow-main 管理,不写成外部用户必备步骤。
|
|
91
|
+
|
|
92
|
+
创建声音克隆:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"args": [
|
|
97
|
+
"voice",
|
|
98
|
+
"clone",
|
|
99
|
+
"--file-transfer",
|
|
100
|
+
"D:\\voice\\sample.wav",
|
|
101
|
+
"--name",
|
|
102
|
+
"我的声音",
|
|
103
|
+
"--activate",
|
|
104
|
+
"--format",
|
|
105
|
+
"agent-json"
|
|
106
|
+
],
|
|
107
|
+
"timeout": 300
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
查询已有声音克隆:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"args": [
|
|
116
|
+
"voice",
|
|
117
|
+
"list",
|
|
118
|
+
"--format",
|
|
119
|
+
"agent-json"
|
|
120
|
+
],
|
|
121
|
+
"timeout": 120
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
激活默认音色:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"args": [
|
|
130
|
+
"voice",
|
|
131
|
+
"activate",
|
|
132
|
+
"--voice",
|
|
133
|
+
"voice_xxx",
|
|
134
|
+
"--format",
|
|
135
|
+
"agent-json"
|
|
136
|
+
],
|
|
137
|
+
"timeout": 120
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## 失败处理
|
|
142
|
+
|
|
143
|
+
- token 缺失:说明需要配置 YuanFlow API token,或在 YuanFlow-main 受控环境中运行。
|
|
144
|
+
- 没有返回 `voice_xxx`:说明声音克隆未完成,不要继续声音复刻。
|
|
145
|
+
- 已存在克隆限制:先用 `voice list` 查询已有音色,必要时让用户选择已有 `voice_xxx` 继续。
|
|
146
|
+
- 音频不可访问:如果使用 URL,让用户换成可访问链接;如果是本地文件,改用 `--file-transfer`。
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 声音复刻
|
|
3
|
+
description: 当用户需要使用已有声音克隆 ID 生成复刻语音、把文字合成为用户克隆声音,或要求根据 voice_xxx 输出音频文件时使用。使用前必须先完成声音克隆或查询到已有 voice_xxx。
|
|
4
|
+
emoji: 🔊
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 声音复刻
|
|
8
|
+
|
|
9
|
+
本 Skill 用于把文本合成为已有克隆音色的音频。使用前必须满足其中一个条件:
|
|
10
|
+
|
|
11
|
+
- 已通过 `声音克隆` Skill 创建过声音克隆,并拿到 `voice_xxx`。
|
|
12
|
+
- 已通过 `yuanflow-cli voice list` 查询到可用 `voice_xxx`。
|
|
13
|
+
- 用户明确指定使用已激活默认音色 `default`。
|
|
14
|
+
|
|
15
|
+
如果没有声音克隆 ID,先调用 `声音克隆`,不要直接复刻。
|
|
16
|
+
|
|
17
|
+
## 外部 CLI 主流程
|
|
18
|
+
|
|
19
|
+
外部 Agent 或用户直接使用时,优先使用 `yuanflow-cli voice replicate`。
|
|
20
|
+
|
|
21
|
+
1. 先确认本机可执行 `yuanflow-cli --help`。
|
|
22
|
+
2. 外部 CLI 使用 `YUANCHUANG_API_TOKEN` 或 `yuanflow-cli config set-token <你的令牌>` 完成鉴权。
|
|
23
|
+
3. 确认 `--voice` 是 `voice_xxx` 或 `default`。
|
|
24
|
+
4. 用 `--output` 指定音频保存路径。
|
|
25
|
+
|
|
26
|
+
不要在回复、日志或文件中暴露 token。用户主流程统一称为 YuanFlow API,不要求用户配置第三方平台 Key。
|
|
27
|
+
|
|
28
|
+
## 查询或确认音色 ID
|
|
29
|
+
|
|
30
|
+
如果用户没有提供 `voice_xxx`,先查询:
|
|
31
|
+
|
|
32
|
+
```powershell
|
|
33
|
+
yuanflow-cli voice list --format agent-json
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
如果列表中没有可用音色,转到 `声音克隆` Skill,先创建声音克隆。
|
|
37
|
+
|
|
38
|
+
## 生成复刻音频
|
|
39
|
+
|
|
40
|
+
```powershell
|
|
41
|
+
yuanflow-cli voice replicate --text "你好,这是声音复刻测试。" --voice voice_xxx --output "D:\voice\replicate.mp3" --format agent-json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
如果用户要求使用默认音色:
|
|
45
|
+
|
|
46
|
+
```powershell
|
|
47
|
+
yuanflow-cli voice replicate --text "你好,这是声音复刻测试。" --voice default --output "D:\voice\replicate.mp3" --format agent-json
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
默认输出 `mp3`。用户要求 wav 或 pcm 时,增加 `--response-format`:
|
|
51
|
+
|
|
52
|
+
```powershell
|
|
53
|
+
yuanflow-cli voice replicate --text "测试内容" --voice voice_xxx --response-format wav --output "D:\voice\replicate.wav" --format agent-json
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 输出要求
|
|
57
|
+
|
|
58
|
+
最终回复给用户时说明:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
复刻音频已生成:
|
|
62
|
+
文件路径:D:\voice\replicate.mp3
|
|
63
|
+
使用音色:voice_xxx
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
如果命令返回 agent-json,优先读取:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
data.response.output
|
|
70
|
+
data.response.bytes
|
|
71
|
+
data.response.content_type
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## YuanFlow-main 内置环境
|
|
75
|
+
|
|
76
|
+
只有在 YuanFlow-main 内置环境中,才使用受控工具 `yuanflow_cli_call`。token、受管包路径和输出目录由 YuanFlow-main 管理,不写成外部用户必备步骤。
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"args": [
|
|
81
|
+
"voice",
|
|
82
|
+
"replicate",
|
|
83
|
+
"--text",
|
|
84
|
+
"你好,这是声音复刻测试。",
|
|
85
|
+
"--voice",
|
|
86
|
+
"voice_xxx",
|
|
87
|
+
"--output",
|
|
88
|
+
"replicate.mp3",
|
|
89
|
+
"--format",
|
|
90
|
+
"agent-json"
|
|
91
|
+
],
|
|
92
|
+
"timeout": 300
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
在 YuanFlow-main 内置环境里,`--output` 会被限制到受控输出目录。不要要求用户手动传程序数据目录,也不要绕过 `yuanflow_cli_call` 直接写本地文件。
|
|
97
|
+
|
|
98
|
+
## 失败处理
|
|
99
|
+
|
|
100
|
+
- 缺少 `voice_xxx`:先调用 `声音克隆` 的查询流程或创建流程。
|
|
101
|
+
- 音色不存在或无权限:提示用户重新查询 `voice list`,不要猜测 ID。
|
|
102
|
+
- 输出路径被拒绝:在 YuanFlow-main 内置环境中改用相对文件名,让受控工具自动放入输出目录。
|
|
103
|
+
- 生成失败:报告 YuanFlow API 返回的简短错误,不暴露 token、Authorization header 或完整敏感链接。
|
package/src/agent-protocol.js
CHANGED
|
@@ -8,6 +8,7 @@ import { listSearchCommands, listWorkCommands } from './work-tools.js';
|
|
|
8
8
|
import { listVideoCommands } from './video-tools.js';
|
|
9
9
|
import { listTrendingCommands } from './trending-tools.js';
|
|
10
10
|
import { listAiCommands } from './ai-tools.js';
|
|
11
|
+
import { listVoiceCommands } from './voice-tools.js';
|
|
11
12
|
|
|
12
13
|
const ERROR_MAP = [
|
|
13
14
|
{
|
|
@@ -113,6 +114,7 @@ export function buildCommandRegistry() {
|
|
|
113
114
|
const videoCommands = listVideoCommands();
|
|
114
115
|
const trendingCommands = listTrendingCommands();
|
|
115
116
|
const aiCommands = listAiCommands();
|
|
117
|
+
const voiceCommands = listVoiceCommands();
|
|
116
118
|
return [
|
|
117
119
|
...shortcuts,
|
|
118
120
|
...endpoints,
|
|
@@ -125,6 +127,7 @@ export function buildCommandRegistry() {
|
|
|
125
127
|
...videoCommands,
|
|
126
128
|
...trendingCommands,
|
|
127
129
|
...aiCommands,
|
|
130
|
+
...voiceCommands,
|
|
128
131
|
].sort((left, right) => left.key.localeCompare(right.key));
|
|
129
132
|
}
|
|
130
133
|
|
package/src/cli.js
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from './work-tools.js';
|
|
30
30
|
import { fetchVideoHotList } from './trending-tools.js';
|
|
31
31
|
import { formatAiHelp, runAiCommand } from './ai-tools.js';
|
|
32
|
+
import { formatVoiceHelp, runVoiceCommand } from './voice-tools.js';
|
|
32
33
|
|
|
33
34
|
export async function main(argv) {
|
|
34
35
|
const args = argv.slice(2);
|
|
@@ -104,6 +105,11 @@ export async function main(argv) {
|
|
|
104
105
|
return;
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
if (command === 'voice') {
|
|
109
|
+
await handleVoice(rest);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
107
113
|
if (command === 'schema') {
|
|
108
114
|
await handleSchema(rest);
|
|
109
115
|
return;
|
|
@@ -451,6 +457,20 @@ async function handleAi(args) {
|
|
|
451
457
|
});
|
|
452
458
|
}
|
|
453
459
|
|
|
460
|
+
async function handleVoice(args) {
|
|
461
|
+
const { positionals, options } = parseOptions(args);
|
|
462
|
+
const [action = 'help'] = positionals;
|
|
463
|
+
if (action === 'help' && !isAgentJsonFormat(options)) {
|
|
464
|
+
console.log(formatVoiceHelp());
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const result = await runVoiceCommand({ action, options });
|
|
468
|
+
await outputResult(result, { ...options, output: undefined }, {
|
|
469
|
+
command: `voice ${action}`,
|
|
470
|
+
meta: { endpoint: result.endpoint?.path, kind: result.endpoint?.kind || 'voice' },
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
454
474
|
async function handleGeneratedCommand(platform, args) {
|
|
455
475
|
if (!getPlatforms().includes(platform)) {
|
|
456
476
|
throw new Error(`未知平台:${platform}。可用平台:${getPlatforms().join(', ')}`);
|
|
@@ -633,6 +653,10 @@ function printHelp() {
|
|
|
633
653
|
yuanflow-cli ai qwen3-vl-plus --prompt "总结这个视频画面" --video-url "https://example.com/video.mp4" --dry-run
|
|
634
654
|
yuanflow-cli ai qwen3-vl-plus --prompt "描述本地图片" --image-file "D:\\素材\\cover.png" --dry-run
|
|
635
655
|
yuanflow-cli ai qwen3-vl-plus --prompt "描述本地视频" --video-file "D:\\素材\\demo.mp4" --dry-run
|
|
656
|
+
yuanflow-cli voice clone --file-transfer "D:\\voice\\sample.wav" --name demo --activate --dry-run
|
|
657
|
+
yuanflow-cli voice list --dry-run
|
|
658
|
+
yuanflow-cli voice activate --voice voice_xxx --dry-run
|
|
659
|
+
yuanflow-cli voice replicate --text "你好" --voice voice_xxx --output "D:\\voice\\replicate.mp3" --dry-run
|
|
636
660
|
yuanflow-cli ai qwen-voice-enrollment --file "D:\\voice\\sample.wav" --name demo --activate --dry-run
|
|
637
661
|
yuanflow-cli ai qwen3-tts-vc-realtime-2026-01-15 --text "你好" --voice voice_xxx --output "D:\\voice\\qwen.mp3" --dry-run
|
|
638
662
|
yuanflow-cli ai fun-asr --audio-url "https://example.com/audio.wav" --response-format verbose_json --dry-run
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readConfig } from './config.js';
|
|
4
|
+
import { callAtomic, cleanBaseUrl } from './atomic-request.js';
|
|
5
|
+
|
|
6
|
+
const AUDIO_SPEECH_PATH = '/v1/audio/speech';
|
|
7
|
+
const AUDIO_VOICES_PATH = '/v1/audio/voices';
|
|
8
|
+
const YUANFLOW_FILE_TRANSFER_PATH = '/atomic/oss/temp-upload';
|
|
9
|
+
|
|
10
|
+
const MODEL_VOICE_CLONE = 'qwen-voice-enrollment';
|
|
11
|
+
const MODEL_VOICE_REPLICATE = 'qwen3-tts-vc-realtime-2026-01-15';
|
|
12
|
+
|
|
13
|
+
export function listVoiceCommands() {
|
|
14
|
+
return [
|
|
15
|
+
voiceCommand({
|
|
16
|
+
key: 'voice.clone',
|
|
17
|
+
command: 'voice clone',
|
|
18
|
+
description: '通过 YuanFlow API 创建声音克隆,返回可复用的 voice_xxx 音色 ID。',
|
|
19
|
+
method: 'POST',
|
|
20
|
+
apiPath: AUDIO_VOICES_PATH,
|
|
21
|
+
options: [
|
|
22
|
+
option('--file', 'file', false, '本地音频文件;与 --file-transfer、--audio-url 三选一。'),
|
|
23
|
+
option('--file-transfer', 'fileTransfer', false, '本地音频文件;先通过 YuanFlow 文件中转生成临时 URL,再创建声音克隆。'),
|
|
24
|
+
option('--audio-url', 'audioUrl', false, '公网可访问音频 URL;与 --file、--file-transfer 三选一。'),
|
|
25
|
+
option('--name', 'name', false, '声音克隆展示名。'),
|
|
26
|
+
option('--preferred-name', 'preferredName', false, '偏好音色名,默认跟随 --name。'),
|
|
27
|
+
option('--text', 'text', false, '参考音频对应文本,可选。'),
|
|
28
|
+
option('--language', 'language', false, '语言代码,可选。'),
|
|
29
|
+
option('--activate', 'activate', false, '创建后设为当前默认音色。'),
|
|
30
|
+
...commonOptions(),
|
|
31
|
+
],
|
|
32
|
+
requestBody: {
|
|
33
|
+
model: MODEL_VOICE_CLONE,
|
|
34
|
+
audio: '<本地音频 data URI,或通过 audio_url 传入 YuanFlow 文件中转 URL>',
|
|
35
|
+
},
|
|
36
|
+
returns: '返回 voice_xxx 音色对象;后续 voice replicate 可通过 --voice voice_xxx 复刻声音。',
|
|
37
|
+
}),
|
|
38
|
+
voiceCommand({
|
|
39
|
+
key: 'voice.list',
|
|
40
|
+
command: 'voice list',
|
|
41
|
+
description: '查询当前 YuanFlow API 令牌下已有的声音克隆音色 ID。',
|
|
42
|
+
method: 'GET',
|
|
43
|
+
apiPath: AUDIO_VOICES_PATH,
|
|
44
|
+
options: commonOptions(),
|
|
45
|
+
requestBody: null,
|
|
46
|
+
returns: '返回当前用户可用的 voice_xxx 音色列表、名称、状态和是否默认激活。',
|
|
47
|
+
}),
|
|
48
|
+
voiceCommand({
|
|
49
|
+
key: 'voice.activate',
|
|
50
|
+
command: 'voice activate',
|
|
51
|
+
description: '把已有声音克隆音色设为默认音色,方便 voice replicate 使用 default 复刻。',
|
|
52
|
+
method: 'POST',
|
|
53
|
+
apiPath: `${AUDIO_VOICES_PATH}/{voice_xxx}/activate`,
|
|
54
|
+
options: [
|
|
55
|
+
option('--voice', 'voice', true, '声音克隆 ID,例如 voice_xxx。'),
|
|
56
|
+
...commonOptions(),
|
|
57
|
+
],
|
|
58
|
+
requestBody: null,
|
|
59
|
+
returns: '返回已激活的 voice_xxx 音色对象。',
|
|
60
|
+
}),
|
|
61
|
+
voiceCommand({
|
|
62
|
+
key: 'voice.replicate',
|
|
63
|
+
command: 'voice replicate',
|
|
64
|
+
description: '使用已有声音克隆音色 ID 复刻生成音频文件。',
|
|
65
|
+
method: 'POST',
|
|
66
|
+
apiPath: AUDIO_SPEECH_PATH,
|
|
67
|
+
options: [
|
|
68
|
+
option('--text', 'text', true, '待复刻合成文本。'),
|
|
69
|
+
option('--voice', 'voice', true, '声音克隆 ID:voice_xxx;也可传 default 使用已激活默认音色。'),
|
|
70
|
+
option('--output', 'output', true, '音频保存路径;dry-run 时可不传。'),
|
|
71
|
+
option('--response-format', 'responseFormat', false, 'mp3、wav、pcm 等,默认 mp3。'),
|
|
72
|
+
option('--speed', 'speed', false, '语速控制。'),
|
|
73
|
+
option('--sample-rate', 'sampleRate', false, '采样率。'),
|
|
74
|
+
option('--metadata', 'metadata', false, '透传给 YuanFlow API 的 metadata JSON。'),
|
|
75
|
+
...commonOptions(),
|
|
76
|
+
],
|
|
77
|
+
requestBody: {
|
|
78
|
+
model: MODEL_VOICE_REPLICATE,
|
|
79
|
+
input: '<text>',
|
|
80
|
+
voice: '<voice_xxx|default>',
|
|
81
|
+
},
|
|
82
|
+
returns: '返回音频二进制;CLI 通过 --output 保存到本地文件。',
|
|
83
|
+
}),
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function formatVoiceHelp() {
|
|
88
|
+
return listVoiceCommands()
|
|
89
|
+
.map((command) => {
|
|
90
|
+
const options = command.options.map((item) => ` ${item.flag} ${item.label}`).join('\n');
|
|
91
|
+
return `${command.command}\n ${command.description}\n 接口:${command.method} ${command.apiPath}\n 参数:\n${options}\n 返回:${command.returns}`;
|
|
92
|
+
})
|
|
93
|
+
.join('\n\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function runVoiceCommand({ action = 'help', options }) {
|
|
97
|
+
switch (action) {
|
|
98
|
+
case 'help':
|
|
99
|
+
case 'list-commands':
|
|
100
|
+
return { ok: true, commands: listVoiceCommands() };
|
|
101
|
+
case 'clone':
|
|
102
|
+
return cloneVoice(options);
|
|
103
|
+
case 'list':
|
|
104
|
+
return listVoices(options);
|
|
105
|
+
case 'activate':
|
|
106
|
+
return activateVoice(options);
|
|
107
|
+
case 'replicate':
|
|
108
|
+
return replicateVoice(options);
|
|
109
|
+
default:
|
|
110
|
+
throw new Error(`未知 voice 命令:${action}。可执行 yuanflow-cli voice help 查看用法。`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function cloneVoice(options) {
|
|
115
|
+
const body = await buildVoiceCloneBody(options);
|
|
116
|
+
const response = await callJson(AUDIO_VOICES_PATH, options, body);
|
|
117
|
+
return result('voice clone', AUDIO_VOICES_PATH, body, response, { kind: 'voice-clone' });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function listVoices(options) {
|
|
121
|
+
const response = await callGetJson(AUDIO_VOICES_PATH, options);
|
|
122
|
+
return result('voice list', AUDIO_VOICES_PATH, undefined, response, {
|
|
123
|
+
method: 'GET',
|
|
124
|
+
kind: 'voice-clone',
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function activateVoice(options) {
|
|
129
|
+
const voice = requiredVoice(options);
|
|
130
|
+
const endpointPath = `${AUDIO_VOICES_PATH}/${encodeURIComponent(voice)}/activate`;
|
|
131
|
+
const response = await callJson(endpointPath, options, {});
|
|
132
|
+
return result('voice activate', endpointPath, undefined, response, { kind: 'voice-clone' });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function replicateVoice(options) {
|
|
136
|
+
const body = buildVoiceReplicateBody(options);
|
|
137
|
+
const response = await callBinary(AUDIO_SPEECH_PATH, options, body);
|
|
138
|
+
return result('voice replicate', AUDIO_SPEECH_PATH, body, response, { kind: 'voice-replicate' });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function buildVoiceCloneBody(options) {
|
|
142
|
+
if (options.json) {
|
|
143
|
+
return JSON.parse(options.json);
|
|
144
|
+
}
|
|
145
|
+
const filePath = cleanOptional(options.file);
|
|
146
|
+
const fileTransferPath = cleanOptional(options.named?.['file-transfer']);
|
|
147
|
+
const audioUrl = cleanOptional(options.named?.['audio-url']);
|
|
148
|
+
const sources = [filePath, fileTransferPath, audioUrl].filter(Boolean);
|
|
149
|
+
if (sources.length === 0) {
|
|
150
|
+
throw new Error('缺少 --file、--file-transfer 或 --audio-url。');
|
|
151
|
+
}
|
|
152
|
+
if (sources.length > 1) {
|
|
153
|
+
throw new Error('--file、--file-transfer 和 --audio-url 只能选择一个。');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const body = {
|
|
157
|
+
model: MODEL_VOICE_CLONE,
|
|
158
|
+
...optionalField('name', options.named?.name),
|
|
159
|
+
...optionalField('preferred_name', options.named?.['preferred-name']),
|
|
160
|
+
...optionalField('text', options.named?.text),
|
|
161
|
+
...optionalField('language', options.named?.language),
|
|
162
|
+
...optionalBooleanField('activate', options.named?.activate),
|
|
163
|
+
};
|
|
164
|
+
if (audioUrl) {
|
|
165
|
+
body.audio_url = audioUrl;
|
|
166
|
+
} else if (fileTransferPath) {
|
|
167
|
+
body.audio_url = await resolveYuanFlowAudioFile(fileTransferPath, options);
|
|
168
|
+
} else {
|
|
169
|
+
body.audio = options.dryRun ? '<data URI omitted in dry-run>' : await fileToDataUri(filePath);
|
|
170
|
+
}
|
|
171
|
+
return body;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function buildVoiceReplicateBody(options) {
|
|
175
|
+
if (options.json) {
|
|
176
|
+
return JSON.parse(options.json);
|
|
177
|
+
}
|
|
178
|
+
const text = cleanOptional(options.named?.text || options.named?.input);
|
|
179
|
+
if (!text) {
|
|
180
|
+
throw new Error('缺少 --text。');
|
|
181
|
+
}
|
|
182
|
+
const voice = requiredVoice(options);
|
|
183
|
+
const body = {
|
|
184
|
+
model: MODEL_VOICE_REPLICATE,
|
|
185
|
+
input: text,
|
|
186
|
+
voice,
|
|
187
|
+
response_format: cleanOptional(options.named?.['response-format']) || 'mp3',
|
|
188
|
+
...optionalField('instructions', options.named?.instructions),
|
|
189
|
+
};
|
|
190
|
+
addNumber(body, 'speed', options.named?.speed);
|
|
191
|
+
const metadata = parseJsonObject(options.named?.metadata);
|
|
192
|
+
addNumber(metadata, 'sample_rate', options.named?.['sample-rate']);
|
|
193
|
+
if (Object.keys(metadata).length > 0) {
|
|
194
|
+
body.metadata = metadata;
|
|
195
|
+
}
|
|
196
|
+
return body;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function resolveYuanFlowAudioFile(filePath, options) {
|
|
200
|
+
const filename = path.basename(filePath);
|
|
201
|
+
if (options.dryRun) {
|
|
202
|
+
return `<YuanFlow 文件中转 signed_url:${filename}>`;
|
|
203
|
+
}
|
|
204
|
+
const response = await callAtomic(YUANFLOW_FILE_TRANSFER_PATH, {
|
|
205
|
+
...options,
|
|
206
|
+
json: undefined,
|
|
207
|
+
method: 'POST',
|
|
208
|
+
body: {
|
|
209
|
+
filename,
|
|
210
|
+
content_base64: (await readFile(filePath)).toString('base64'),
|
|
211
|
+
content_type: inferAudioMimeType(filePath),
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
const data = response?.data && typeof response.data === 'object' ? response.data : response;
|
|
215
|
+
const url = cleanOptional(data?.signed_url) || cleanOptional(data?.url);
|
|
216
|
+
if (!url) {
|
|
217
|
+
throw new Error('YuanFlow 文件中转未返回 signed_url 或 url。');
|
|
218
|
+
}
|
|
219
|
+
return url;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function callJson(apiPath, options, body) {
|
|
223
|
+
const request = await buildRequest(apiPath, options, 'POST', body);
|
|
224
|
+
if (request.dryRun) {
|
|
225
|
+
return request;
|
|
226
|
+
}
|
|
227
|
+
const response = await fetch(request.url, {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: {
|
|
230
|
+
...request.headers,
|
|
231
|
+
Accept: 'application/json',
|
|
232
|
+
'Content-Type': 'application/json',
|
|
233
|
+
},
|
|
234
|
+
body: JSON.stringify(body || {}),
|
|
235
|
+
});
|
|
236
|
+
return readJsonResponse(response);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function callGetJson(apiPath, options) {
|
|
240
|
+
const request = await buildRequest(apiPath, options, 'GET');
|
|
241
|
+
if (request.dryRun) {
|
|
242
|
+
return request;
|
|
243
|
+
}
|
|
244
|
+
const response = await fetch(request.url, {
|
|
245
|
+
method: 'GET',
|
|
246
|
+
headers: {
|
|
247
|
+
...request.headers,
|
|
248
|
+
Accept: 'application/json',
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
return readJsonResponse(response);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function callBinary(apiPath, options, body) {
|
|
255
|
+
const request = await buildRequest(apiPath, options, 'POST', body);
|
|
256
|
+
if (request.dryRun) {
|
|
257
|
+
return request;
|
|
258
|
+
}
|
|
259
|
+
if (!options.output) {
|
|
260
|
+
throw new Error('声音复刻需要 --output 指定保存路径。');
|
|
261
|
+
}
|
|
262
|
+
const response = await fetch(request.url, {
|
|
263
|
+
method: 'POST',
|
|
264
|
+
headers: {
|
|
265
|
+
...request.headers,
|
|
266
|
+
Accept: '*/*',
|
|
267
|
+
'Content-Type': 'application/json',
|
|
268
|
+
},
|
|
269
|
+
body: JSON.stringify(body || {}),
|
|
270
|
+
});
|
|
271
|
+
if (!response.ok) {
|
|
272
|
+
const text = await response.text();
|
|
273
|
+
throw new Error(`请求失败:HTTP ${response.status} ${text}`);
|
|
274
|
+
}
|
|
275
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
276
|
+
await writeFile(options.output, bytes);
|
|
277
|
+
return {
|
|
278
|
+
ok: true,
|
|
279
|
+
output: options.output,
|
|
280
|
+
bytes: bytes.length,
|
|
281
|
+
content_type: response.headers.get('content-type') || '',
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function buildRequest(apiPath, options, method, body) {
|
|
286
|
+
const config = await readConfig();
|
|
287
|
+
const baseUrl = cleanBaseUrl(options.baseUrl || config.baseUrl);
|
|
288
|
+
const token = options.token || process.env.YUANCHUANG_API_TOKEN || config.token || '';
|
|
289
|
+
const url = new URL(apiPath, baseUrl);
|
|
290
|
+
if (options.dryRun) {
|
|
291
|
+
return {
|
|
292
|
+
dryRun: true,
|
|
293
|
+
method,
|
|
294
|
+
url: url.toString(),
|
|
295
|
+
headers: token ? { Authorization: `Bearer ${maskToken(token)}` } : {},
|
|
296
|
+
body: redactBody(body),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
if (!token) {
|
|
300
|
+
throw new Error('缺少 token。请设置 YUANCHUANG_API_TOKEN,或执行 yuanflow-cli config set-token <你的令牌>');
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
method,
|
|
304
|
+
url: url.toString(),
|
|
305
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
306
|
+
body,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function readJsonResponse(response) {
|
|
311
|
+
const text = await response.text();
|
|
312
|
+
const payload = parseMaybeJson(text);
|
|
313
|
+
if (!response.ok) {
|
|
314
|
+
const message = typeof payload === 'object' ? JSON.stringify(payload) : text;
|
|
315
|
+
throw new Error(`请求失败:HTTP ${response.status} ${message}`);
|
|
316
|
+
}
|
|
317
|
+
return payload;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function result(action, endpointPath, body, response, endpoint = {}) {
|
|
321
|
+
return {
|
|
322
|
+
ok: true,
|
|
323
|
+
action,
|
|
324
|
+
endpoint: { method: endpoint.method || 'POST', path: endpointPath, kind: endpoint.kind || 'voice' },
|
|
325
|
+
request: { body: redactBody(body) },
|
|
326
|
+
response,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function voiceCommand({ key, command, description, method, apiPath, options, requestBody, returns }) {
|
|
331
|
+
return {
|
|
332
|
+
key,
|
|
333
|
+
command,
|
|
334
|
+
kind: 'voice',
|
|
335
|
+
description,
|
|
336
|
+
method,
|
|
337
|
+
apiPath,
|
|
338
|
+
positionals: [],
|
|
339
|
+
options,
|
|
340
|
+
requestBody,
|
|
341
|
+
returns,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function requiredVoice(options) {
|
|
346
|
+
const voice = cleanOptional(options.named?.voice || options.named?.['voice-id']);
|
|
347
|
+
if (!voice) {
|
|
348
|
+
throw new Error('缺少 --voice。');
|
|
349
|
+
}
|
|
350
|
+
return voice;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function commonOptions() {
|
|
354
|
+
return [
|
|
355
|
+
option('--json', 'json', false, '直接传完整 YuanFlow API 请求 JSON。'),
|
|
356
|
+
option('--token', 'token', false, '临时 token。'),
|
|
357
|
+
option('--base-url', 'baseUrl', false, 'YuanFlow API 地址。'),
|
|
358
|
+
option('--format', 'format', false, 'Agent 调用时建议使用 agent-json。'),
|
|
359
|
+
option('--dry-run', 'dryRun', false, '仅预览请求映射,不发起真实请求,也不要求 token。'),
|
|
360
|
+
];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function option(flag, name, required, label) {
|
|
364
|
+
return { flag, name, required, label };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function fileToDataUri(filePath) {
|
|
368
|
+
const data = await readFile(filePath);
|
|
369
|
+
return `data:${inferAudioMimeType(filePath)};base64,${data.toString('base64')}`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function inferAudioMimeType(filePath) {
|
|
373
|
+
switch (path.extname(filePath).toLowerCase()) {
|
|
374
|
+
case '.mp3':
|
|
375
|
+
return 'audio/mpeg';
|
|
376
|
+
case '.wav':
|
|
377
|
+
return 'audio/wav';
|
|
378
|
+
case '.m4a':
|
|
379
|
+
return 'audio/mp4';
|
|
380
|
+
case '.ogg':
|
|
381
|
+
return 'audio/ogg';
|
|
382
|
+
case '.flac':
|
|
383
|
+
return 'audio/flac';
|
|
384
|
+
case '.pcm':
|
|
385
|
+
return 'audio/pcm';
|
|
386
|
+
default:
|
|
387
|
+
return 'application/octet-stream';
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function parseJsonObject(value) {
|
|
392
|
+
const cleaned = cleanOptional(value);
|
|
393
|
+
if (!cleaned) {
|
|
394
|
+
return {};
|
|
395
|
+
}
|
|
396
|
+
const parsed = JSON.parse(cleaned);
|
|
397
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
398
|
+
throw new Error('--metadata 必须是 JSON 对象。');
|
|
399
|
+
}
|
|
400
|
+
return parsed;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function optionalField(name, value) {
|
|
404
|
+
const cleaned = cleanOptional(value);
|
|
405
|
+
return cleaned === undefined ? {} : { [name]: cleaned };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function optionalBooleanField(name, value) {
|
|
409
|
+
const parsed = parseBoolean(value);
|
|
410
|
+
return parsed === undefined ? {} : { [name]: parsed };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function addNumber(target, name, value) {
|
|
414
|
+
const cleaned = cleanOptional(value);
|
|
415
|
+
if (cleaned !== undefined) {
|
|
416
|
+
const number = Number(cleaned);
|
|
417
|
+
target[name] = Number.isFinite(number) ? number : cleaned;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function parseBoolean(value) {
|
|
422
|
+
const cleaned = cleanOptional(value);
|
|
423
|
+
if (cleaned === undefined) {
|
|
424
|
+
return undefined;
|
|
425
|
+
}
|
|
426
|
+
if (typeof cleaned === 'boolean') {
|
|
427
|
+
return cleaned;
|
|
428
|
+
}
|
|
429
|
+
return ['1', 'true', 'yes', 'on'].includes(String(cleaned).toLowerCase());
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function cleanOptional(value) {
|
|
433
|
+
if (value === undefined || value === null) return undefined;
|
|
434
|
+
if (typeof value === 'string') {
|
|
435
|
+
const trimmed = value.trim();
|
|
436
|
+
return trimmed ? trimmed : undefined;
|
|
437
|
+
}
|
|
438
|
+
return value;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function redactBody(body) {
|
|
442
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
443
|
+
return body;
|
|
444
|
+
}
|
|
445
|
+
const redacted = { ...body };
|
|
446
|
+
if ('audio' in redacted) {
|
|
447
|
+
redacted.audio = '<data URI omitted>';
|
|
448
|
+
}
|
|
449
|
+
return redacted;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function parseMaybeJson(text) {
|
|
453
|
+
if (!text) {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
return JSON.parse(text);
|
|
458
|
+
} catch {
|
|
459
|
+
return text;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function maskToken(token) {
|
|
464
|
+
if (!token) {
|
|
465
|
+
return '';
|
|
466
|
+
}
|
|
467
|
+
if (token.length <= 10) {
|
|
468
|
+
return '***';
|
|
469
|
+
}
|
|
470
|
+
return `${token.slice(0, 6)}...${token.slice(-4)}`;
|
|
471
|
+
}
|