yuanflow-cli 0.1.47 → 0.1.48
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 +25 -22
- package/package.json +1 -1
- package/skills/yuanflow-skill//345/243/260/351/237/263/345/205/213/351/232/206/SKILL.md +53 -11
- package/skills/yuanflow-skill//345/243/260/351/237/263/345/244/215/345/210/273/SKILL.md +39 -0
- package/skills/yuanflow-skill//350/247/206/351/242/221/346/213/206/350/247/243/SKILL.md +132 -21
- package/src/agent-protocol.js +15 -2
- package/src/ai-tools.js +75 -39
- package/src/cli.js +9 -9
- package/src/voice-tools.js +92 -21
package/README.md
CHANGED
|
@@ -50,15 +50,15 @@ yuanflow-cli browser profile-path --platform douyin --account main --format agen
|
|
|
50
50
|
yuanflow-cli browser task-plan --platform xiaohongshu --task publish --account main --format agent-json
|
|
51
51
|
yuanflow-cli ai qwen3-vl-plus --prompt "描述这张图" --image-url "https://example.com/image.png" --format agent-json
|
|
52
52
|
yuanflow-cli ai qwen3-vl-plus --prompt "总结这个视频画面" --video-url "https://example.com/video.mp4" --format agent-json
|
|
53
|
-
yuanflow-cli ai qwen3-vl-plus --prompt "描述本地视频" --video-file "
|
|
54
|
-
yuanflow-cli voice clone --file-transfer "
|
|
53
|
+
yuanflow-cli ai qwen3-vl-plus --prompt "描述本地视频" --video-file "<本地视频路径>" --format agent-json
|
|
54
|
+
yuanflow-cli voice clone --file-transfer "<本地音频路径>" --name "我的声音" --activate --format agent-json
|
|
55
55
|
yuanflow-cli voice list --format agent-json
|
|
56
56
|
yuanflow-cli voice activate --voice voice_xxx --format agent-json
|
|
57
|
-
yuanflow-cli voice replicate --text "你好,这是声音复刻测试。" --voice voice_xxx --output "
|
|
57
|
+
yuanflow-cli voice replicate --text "你好,这是声音复刻测试。" --voice voice_xxx --output "<输出音频路径>" --format agent-json
|
|
58
58
|
yuanflow-cli ai doubao-tts voices --format agent-json
|
|
59
59
|
yuanflow-cli ai doubao-tts voice --voice zh_female_xiaohe_uranus_bigtts --format agent-json
|
|
60
|
-
yuanflow-cli ai doubao-tts voice-download --voice zh_female_xiaohe_uranus_bigtts --output "
|
|
61
|
-
yuanflow-cli ai doubao-tts --text "你好" --voice zh_female_xiaohe_uranus_bigtts --output "
|
|
60
|
+
yuanflow-cli ai doubao-tts voice-download --voice zh_female_xiaohe_uranus_bigtts --output "<输出音频路径>" --format agent-json
|
|
61
|
+
yuanflow-cli ai doubao-tts --text "你好" --voice zh_female_xiaohe_uranus_bigtts --output "<输出音频路径>" --format agent-json
|
|
62
62
|
yuanflow-cli ai fun-asr --audio-url "https://example.com/audio.wav" --response-format verbose_json --format agent-json
|
|
63
63
|
yuanflow-cli list douyin
|
|
64
64
|
```
|
|
@@ -77,7 +77,10 @@ token 优先级:`--token` > `YUANCHUANG_API_TOKEN` > 本地 `config.token`。
|
|
|
77
77
|
|
|
78
78
|
```bash
|
|
79
79
|
# 创建声音克隆:本地音频先走 YuanFlow 文件中转
|
|
80
|
-
yuanflow-cli voice clone --file-transfer "
|
|
80
|
+
yuanflow-cli voice clone --file-transfer "<本地音频路径>" --name "我的声音" --activate --format agent-json
|
|
81
|
+
|
|
82
|
+
# 创建声音克隆:本地音频 multipart 直接提交给 YuanFlow API
|
|
83
|
+
yuanflow-cli voice clone --file "<本地音频路径>" --name "我的声音" --activate --format agent-json
|
|
81
84
|
|
|
82
85
|
# 查询已有声音克隆 ID
|
|
83
86
|
yuanflow-cli voice list --format agent-json
|
|
@@ -86,17 +89,17 @@ yuanflow-cli voice list --format agent-json
|
|
|
86
89
|
yuanflow-cli voice activate --voice voice_xxx --format agent-json
|
|
87
90
|
|
|
88
91
|
# 使用克隆音色生成复刻音频
|
|
89
|
-
yuanflow-cli voice replicate --text "你好,这是声音复刻测试。" --voice voice_xxx --output "
|
|
92
|
+
yuanflow-cli voice replicate --text "你好,这是声音复刻测试。" --voice voice_xxx --output "<输出音频路径>" --format agent-json
|
|
90
93
|
```
|
|
91
94
|
|
|
92
95
|
命令清单:
|
|
93
96
|
|
|
94
|
-
- `voice clone`:`POST /v1/audio/voices
|
|
97
|
+
- `voice clone`:`POST /v1/audio/voices`,创建声音克隆,默认使用 `voice-enrollment` 并绑定后续合成模型 `cosyvoice-v3-flash`,常用参数 `--file`、`--file-transfer`、`--audio-url`、`--name`、`--language-hints`、`--activate`。
|
|
95
98
|
- `voice list`:`GET /v1/audio/voices`,查询当前用户已有声音克隆音色 ID。
|
|
96
99
|
- `voice activate`:`POST /v1/audio/voices/{voice_xxx}/activate`,把已有声音克隆设为默认音色。
|
|
97
100
|
- `voice replicate`:`POST /v1/audio/speech`,使用 `voice_xxx` 或 `default` 生成复刻音频,真实调用时必须传 `--output`。
|
|
98
101
|
|
|
99
|
-
`--file-transfer` 会先通过 YuanFlow
|
|
102
|
+
`--file-transfer` 会先通过 YuanFlow 文件中转上传本地音频,再把临时访问链接作为 `audio_url` 提交给 YuanFlow API。`--file` 会通过 multipart 直接提交本地音频,由 YuanFlow API 处理临时访问链接。`--file`、`--file-transfer` 和 `--audio-url` 只能选择一个。
|
|
100
103
|
|
|
101
104
|
### AI 模型命令
|
|
102
105
|
|
|
@@ -106,13 +109,15 @@ yuanflow-cli voice replicate --text "你好,这是声音复刻测试。" --voi
|
|
|
106
109
|
yuanflow-cli ai help
|
|
107
110
|
yuanflow-cli ai qwen3-vl-plus --prompt "描述这张图" --image-url "https://example.com/image.png" --format agent-json
|
|
108
111
|
yuanflow-cli ai qwen3-vl-plus --prompt "总结这个视频画面" --video-url "https://example.com/video.mp4" --format agent-json
|
|
109
|
-
yuanflow-cli ai qwen3-vl-plus --prompt "描述本地图片" --image-file "
|
|
110
|
-
yuanflow-cli ai qwen3-vl-plus --prompt "描述本地视频" --video-file "
|
|
112
|
+
yuanflow-cli ai qwen3-vl-plus --prompt "描述本地图片" --image-file "<本地图片路径>" --format agent-json
|
|
113
|
+
yuanflow-cli ai qwen3-vl-plus --prompt "描述本地视频" --video-file "<本地视频路径>" --format agent-json
|
|
114
|
+
yuanflow-cli ai voice-enrollment --file "<本地音频路径>" --name "我的声音" --activate --format agent-json
|
|
115
|
+
yuanflow-cli ai cosyvoice-v3-flash --text "你好,这是声音复刻测试。" --voice voice_xxx --output "<输出音频路径>" --format agent-json
|
|
111
116
|
yuanflow-cli ai fun-asr --audio-url "https://example.com/audio.wav" --response-format verbose_json --format agent-json
|
|
112
117
|
yuanflow-cli ai doubao-tts voices --format agent-json
|
|
113
118
|
yuanflow-cli ai doubao-tts voice --voice zh_female_xiaohe_uranus_bigtts --format agent-json
|
|
114
|
-
yuanflow-cli ai doubao-tts voice-download --voice zh_female_xiaohe_uranus_bigtts --output "
|
|
115
|
-
yuanflow-cli ai doubao-tts --text "你好" --voice zh_female_xiaohe_uranus_bigtts --output "
|
|
119
|
+
yuanflow-cli ai doubao-tts voice-download --voice zh_female_xiaohe_uranus_bigtts --output "<输出音频路径>" --format agent-json
|
|
120
|
+
yuanflow-cli ai doubao-tts --text "你好" --voice zh_female_xiaohe_uranus_bigtts --output "<输出音频路径>" --format agent-json
|
|
116
121
|
```
|
|
117
122
|
|
|
118
123
|
#### qwen3-vl-plus 视觉理解
|
|
@@ -127,10 +132,10 @@ yuanflow-cli ai qwen3-vl-plus --prompt "描述这张图" --image-url "https://ex
|
|
|
127
132
|
yuanflow-cli ai qwen3-vl-plus --prompt "总结这个视频画面" --video-url "https://example.com/video.mp4" --format agent-json
|
|
128
133
|
|
|
129
134
|
# 本地图片:先上传到 YuanFlow 文件中转,再把临时访问链接传给 YuanFlow API
|
|
130
|
-
yuanflow-cli ai qwen3-vl-plus --prompt "描述本地图片" --image-file "
|
|
135
|
+
yuanflow-cli ai qwen3-vl-plus --prompt "描述本地图片" --image-file "<本地图片路径>" --format agent-json
|
|
131
136
|
|
|
132
137
|
# 本地视频:先上传到 YuanFlow 文件中转,再把临时访问链接传给 YuanFlow API
|
|
133
|
-
yuanflow-cli ai qwen3-vl-plus --prompt "描述本地视频" --video-file "
|
|
138
|
+
yuanflow-cli ai qwen3-vl-plus --prompt "描述本地视频" --video-file "<本地视频路径>" --format agent-json
|
|
134
139
|
```
|
|
135
140
|
|
|
136
141
|
本地文件上传要求:
|
|
@@ -149,6 +154,8 @@ yuanflow-cli ai qwen3-vl-plus --prompt "描述本地视频" --video-file "D:\素
|
|
|
149
154
|
命令清单:
|
|
150
155
|
|
|
151
156
|
- `ai qwen3-vl-plus`:`POST /v1/chat/completions`,文本/图片/视频理解,常用参数 `--prompt`、`--image-url`、`--video-url`、`--image-file`、`--video-file`。
|
|
157
|
+
- `ai voice-enrollment`:`POST /v1/audio/voices`,创建声音克隆音色,返回 `voice_xxx`。
|
|
158
|
+
- `ai cosyvoice-v3-flash`:`POST /v1/audio/speech`,使用 `voice_xxx` 或 `default` 合成复刻音频。
|
|
152
159
|
- `ai fun-asr`:`POST /v1/audio/transcriptions`,语音识别,`--audio-url` 适合远程音频,`--file` 适合本地音频直传。
|
|
153
160
|
- `ai doubao-tts`:`POST /v1/audio/speech`,豆包语音合成,`--voice` 直接传豆包官方音色 ID。
|
|
154
161
|
- `ai doubao-tts voices`:`GET /api/voice-assets/doubao/voices`,查询 doubao-tts 可用音色列表。
|
|
@@ -171,10 +178,10 @@ yuanflow-cli ai doubao-tts voices --format agent-json
|
|
|
171
178
|
yuanflow-cli ai doubao-tts voice --voice zh_female_xiaohe_uranus_bigtts --format agent-json
|
|
172
179
|
|
|
173
180
|
# 3. 下载该音色的试听音频
|
|
174
|
-
yuanflow-cli ai doubao-tts voice-download --voice zh_female_xiaohe_uranus_bigtts --output "
|
|
181
|
+
yuanflow-cli ai doubao-tts voice-download --voice zh_female_xiaohe_uranus_bigtts --output "<输出音频路径>" --format agent-json
|
|
175
182
|
|
|
176
183
|
# 4. 使用同一个 voice_type 进行真实语音合成
|
|
177
|
-
yuanflow-cli ai doubao-tts --text "你好,这是豆包语音合成测试。" --voice zh_female_xiaohe_uranus_bigtts --output "
|
|
184
|
+
yuanflow-cli ai doubao-tts --text "你好,这是豆包语音合成测试。" --voice zh_female_xiaohe_uranus_bigtts --output "<输出音频路径>" --format agent-json
|
|
178
185
|
```
|
|
179
186
|
|
|
180
187
|
音色列表返回的关键字段:
|
|
@@ -298,11 +305,7 @@ Agent 应先查看 `knowledge docs`,再把用户需求整理成 `task_frame`
|
|
|
298
305
|
|
|
299
306
|
### 视频制作能力迁移
|
|
300
307
|
|
|
301
|
-
视频智能剪辑、主音频+B-roll、EDL 校验和基础渲染链路已迁移到独立项目维护,不再作为 `yuanflow-cli video`
|
|
302
|
-
|
|
303
|
-
```text
|
|
304
|
-
D:\AI_project\视频制作
|
|
305
|
-
```
|
|
308
|
+
视频智能剪辑、主音频+B-roll、EDL 校验和基础渲染链路已迁移到独立项目维护,不再作为 `yuanflow-cli video` 命令提供。
|
|
306
309
|
|
|
307
310
|
`YuanFlow-cli` 仍保留通用视频理解、视频拆解、音视频在线转文字、作品下载和知识库查询等通用能力;视频制作项目后续按需调用这些通用能力。
|
|
308
311
|
|
package/package.json
CHANGED
|
@@ -15,33 +15,54 @@ emoji: 🎙️
|
|
|
15
15
|
1. 先确认本机可执行 `yuanflow-cli --help`。
|
|
16
16
|
2. 外部 CLI 使用 `YUANCHUANG_API_TOKEN` 或 `yuanflow-cli config set-token <你的令牌>` 完成鉴权。
|
|
17
17
|
3. 选择音频输入方式:
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
18
|
+
- 本地音频优先先调用 `YuanFlow文件中转工具`,用 `oss temp-upload` 上传并取得临时访问 URL,再通过 `--audio-url` 提交。
|
|
19
|
+
- 已有公网音频链接或上一步取得的临时访问 URL:`--audio-url`
|
|
20
|
+
- 兼容快捷方式:`--file-transfer` 会在声音克隆命令内部先走 YuanFlow 文件中转,再把临时访问链接作为 `audio_url` 提交。
|
|
21
|
+
- 只有用户明确要求直接传本地文件时,才使用 multipart:`--file`
|
|
21
22
|
4. 创建成功后,保存返回的 `voice_xxx`。这是后续声音复刻必须使用的音色 ID。
|
|
22
23
|
|
|
23
24
|
不要在回复、日志或文件中暴露 token。用户主流程统一称为 YuanFlow API 和 YuanFlow 文件中转,不要求用户配置第三方平台 Key。
|
|
24
25
|
|
|
25
26
|
## 创建声音克隆
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
本地文件优先先使用 `YuanFlow文件中转工具` 获取临时 URL,再创建声音克隆:
|
|
28
29
|
|
|
29
30
|
```powershell
|
|
30
|
-
yuanflow-cli
|
|
31
|
+
yuanflow-cli oss temp-upload --file "<本地声音样本路径>" --content-type audio/wav --format agent-json
|
|
31
32
|
```
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
从返回结果中读取临时访问链接:
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
data.response.data.signed_url
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
再把临时访问链接作为 `audio_url` 提交:
|
|
34
41
|
|
|
35
42
|
```powershell
|
|
36
|
-
yuanflow-cli voice clone --
|
|
43
|
+
yuanflow-cli voice clone --audio-url "<临时访问URL>" --name "我的声音" --language-hints zh --activate --format agent-json
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
如果当前执行环境需要一步完成,也可以使用兼容快捷方式:
|
|
47
|
+
|
|
48
|
+
```powershell
|
|
49
|
+
yuanflow-cli voice clone --file-transfer "<本地声音样本路径>" --name "我的声音" --language-hints zh --activate --format agent-json
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
如果用户明确希望直接提交本地音频,使用 multipart:
|
|
53
|
+
|
|
54
|
+
```powershell
|
|
55
|
+
yuanflow-cli voice clone --file "<本地声音样本路径>" --name "我的声音" --language-hints zh --activate --format agent-json
|
|
37
56
|
```
|
|
38
57
|
|
|
39
58
|
如果已经有可访问音频 URL:
|
|
40
59
|
|
|
41
60
|
```powershell
|
|
42
|
-
yuanflow-cli voice clone --audio-url "https://example.com/sample.wav" --name "我的声音" --activate --format agent-json
|
|
61
|
+
yuanflow-cli voice clone --audio-url "https://example.com/sample.wav" --name "我的声音" --language-hints zh --activate --format agent-json
|
|
43
62
|
```
|
|
44
63
|
|
|
64
|
+
默认创建模型为 `voice-enrollment`,默认绑定后续合成模型 `cosyvoice-v3-flash`。除非用户明确要求其它 YuanFlow API 对外模型参数,一般不要改 `--target-model`。
|
|
65
|
+
|
|
45
66
|
返回里重点读取:
|
|
46
67
|
|
|
47
68
|
```text
|
|
@@ -83,6 +104,7 @@ yuanflow-cli voice activate --voice voice_xxx --format agent-json
|
|
|
83
104
|
- 优先使用清晰、无背景音乐、无明显混响的人声音频。
|
|
84
105
|
- 建议使用 wav、mp3、m4a、flac 等常见格式。
|
|
85
106
|
- 文件过大、噪音明显或多人混说时,先让用户换音频或裁剪。
|
|
107
|
+
- 样本语言建议用 `--language-hints zh` 这类提示传入;多语种用逗号分隔。
|
|
86
108
|
- 不要上传身份证、合同、私密通话、未授权人物声音等敏感内容,除非用户明确确认且任务确实需要。
|
|
87
109
|
|
|
88
110
|
## YuanFlow-main 内置环境
|
|
@@ -91,15 +113,35 @@ yuanflow-cli voice activate --voice voice_xxx --format agent-json
|
|
|
91
113
|
|
|
92
114
|
创建声音克隆:
|
|
93
115
|
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"args": [
|
|
119
|
+
"oss",
|
|
120
|
+
"temp-upload",
|
|
121
|
+
"--file",
|
|
122
|
+
"<本地声音样本路径>",
|
|
123
|
+
"--content-type",
|
|
124
|
+
"audio/wav",
|
|
125
|
+
"--format",
|
|
126
|
+
"agent-json"
|
|
127
|
+
],
|
|
128
|
+
"timeout": 300
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
拿到 `data.response.data.signed_url` 后,再提交声音克隆:
|
|
133
|
+
|
|
94
134
|
```json
|
|
95
135
|
{
|
|
96
136
|
"args": [
|
|
97
137
|
"voice",
|
|
98
138
|
"clone",
|
|
99
|
-
"--
|
|
100
|
-
"
|
|
139
|
+
"--audio-url",
|
|
140
|
+
"<临时访问URL>",
|
|
101
141
|
"--name",
|
|
102
142
|
"我的声音",
|
|
143
|
+
"--language-hints",
|
|
144
|
+
"zh",
|
|
103
145
|
"--activate",
|
|
104
146
|
"--format",
|
|
105
147
|
"agent-json"
|
|
@@ -143,4 +185,4 @@ yuanflow-cli voice activate --voice voice_xxx --format agent-json
|
|
|
143
185
|
- token 缺失:说明需要配置 YuanFlow API token,或在 YuanFlow-main 受控环境中运行。
|
|
144
186
|
- 没有返回 `voice_xxx`:说明声音克隆未完成,不要继续声音复刻。
|
|
145
187
|
- 已存在克隆限制:先用 `voice list` 查询已有音色,必要时让用户选择已有 `voice_xxx` 继续。
|
|
146
|
-
- 音频不可访问:如果使用 URL
|
|
188
|
+
- 音频不可访问:如果使用 URL,让用户换成可访问链接;如果是本地文件,优先调用 `YuanFlow文件中转工具` 获取新的临时 URL,再用 `--audio-url` 重试。
|
|
@@ -14,6 +14,8 @@ emoji: 🔊
|
|
|
14
14
|
|
|
15
15
|
如果没有声音克隆 ID,先调用 `声音克隆`,不要直接复刻。
|
|
16
16
|
|
|
17
|
+
当前声音复刻使用 YuanFlow API 对外模型参数 `cosyvoice-v3-flash`。Agent 日常优先使用聚合命令 `yuanflow-cli voice replicate`,不要在公共流程里要求用户理解底层接口细节。
|
|
18
|
+
|
|
17
19
|
## 外部 CLI 主流程
|
|
18
20
|
|
|
19
21
|
外部 Agent 或用户直接使用时,优先使用 `yuanflow-cli voice replicate`。
|
|
@@ -22,6 +24,7 @@ emoji: 🔊
|
|
|
22
24
|
2. 外部 CLI 使用 `YUANCHUANG_API_TOKEN` 或 `yuanflow-cli config set-token <你的令牌>` 完成鉴权。
|
|
23
25
|
3. 确认 `--voice` 是 `voice_xxx` 或 `default`。
|
|
24
26
|
4. 用 `--output` 指定音频保存路径。
|
|
27
|
+
5. 如果链路里需要上传本地参考音频,不要直接交给复刻命令;先调用 `YuanFlow文件中转工具` 的 `oss temp-upload` 获取临时 URL,再转到 `声音克隆` 使用 `--audio-url` 创建或确认 `voice_xxx`。
|
|
25
28
|
|
|
26
29
|
不要在回复、日志或文件中暴露 token。用户主流程统一称为 YuanFlow API,不要求用户配置第三方平台 Key。
|
|
27
30
|
|
|
@@ -35,6 +38,28 @@ yuanflow-cli voice list --format agent-json
|
|
|
35
38
|
|
|
36
39
|
如果列表中没有可用音色,转到 `声音克隆` Skill,先创建声音克隆。
|
|
37
40
|
|
|
41
|
+
## 本地文件处理
|
|
42
|
+
|
|
43
|
+
`voice replicate` 只接收文本和已有音色 ID,不接收本地参考音频。遇到本地音频文件时,优先按下面链路处理:
|
|
44
|
+
|
|
45
|
+
```powershell
|
|
46
|
+
yuanflow-cli oss temp-upload --file "<本地声音样本路径>" --content-type audio/wav --format agent-json
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
从返回结果读取临时访问链接:
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
data.response.data.signed_url
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
再调用 `声音克隆` 创建或确认音色:
|
|
56
|
+
|
|
57
|
+
```powershell
|
|
58
|
+
yuanflow-cli voice clone --audio-url "<临时访问URL>" --name "我的声音" --language-hints zh --activate --format agent-json
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
拿到 `voice_xxx` 后,再执行下面的声音复刻。
|
|
62
|
+
|
|
38
63
|
## 生成复刻音频
|
|
39
64
|
|
|
40
65
|
```powershell
|
|
@@ -53,6 +78,18 @@ yuanflow-cli voice replicate --text "你好,这是声音复刻测试。" --voi
|
|
|
53
78
|
yuanflow-cli voice replicate --text "测试内容" --voice voice_xxx --response-format wav --output "<输出音频路径>" --format agent-json
|
|
54
79
|
```
|
|
55
80
|
|
|
81
|
+
常用控制参数:
|
|
82
|
+
|
|
83
|
+
- `--instructions`:语气、情绪、角色等朗读要求。
|
|
84
|
+
- `--sample-rate`:采样率,默认按 YuanFlow API 处理,常用 `24000`。
|
|
85
|
+
- `--rate`:语速。
|
|
86
|
+
- `--volume`:音量。
|
|
87
|
+
- `--pitch`:音调。
|
|
88
|
+
- `--language`:目标合成语言,例如 `zh`。
|
|
89
|
+
- `--language-hints`:目标合成语言提示,例如 `zh`。
|
|
90
|
+
- `--enable-ssml`:需要 SSML 时开启。
|
|
91
|
+
- `--word-timestamp-enabled`:需要字级时间戳时开启。
|
|
92
|
+
|
|
56
93
|
## 输出要求
|
|
57
94
|
|
|
58
95
|
最终回复给用户时说明:
|
|
@@ -84,6 +121,8 @@ data.response.content_type
|
|
|
84
121
|
"你好,这是声音复刻测试。",
|
|
85
122
|
"--voice",
|
|
86
123
|
"voice_xxx",
|
|
124
|
+
"--language",
|
|
125
|
+
"zh",
|
|
87
126
|
"--output",
|
|
88
127
|
"replicate.mp3",
|
|
89
128
|
"--format",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: 视频拆解
|
|
3
|
-
description: 当用户提交对标视频、本地视频、视频 URL
|
|
3
|
+
description: 当用户提交对标视频、本地视频、视频 URL,要求做自媒体创作方向的视频拆解、爆款结构分析、内容复盘、脚本拆解、镜头节奏拆解、视频风格解析、账号/竞品内容学习时使用。开始前必须先查自媒体知识库;平台 URL 还要尽量获取作品详情和视频文件,再用 qwen3-vl-plus 规则化拆解。
|
|
4
4
|
emoji: 🎬
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -11,19 +11,20 @@ emoji: 🎬
|
|
|
11
11
|
核心链路:
|
|
12
12
|
|
|
13
13
|
1. 先查 `自媒体知识库`,获取与“对标拆解、视频拆解、内容创作、脚本结构、镜头节奏、爆款复盘”相关的规则摘要。
|
|
14
|
-
2.
|
|
15
|
-
3.
|
|
16
|
-
4.
|
|
17
|
-
5.
|
|
14
|
+
2. 如果用户给的是平台视频 URL,先获取作品详情,尽量拿到标题、文案、封面、作者、发布时间、点赞/评论/转发/收藏等互动数据。
|
|
15
|
+
3. 如果需要分析视频画面,再用下载/播放相关工具解析并保存到本地。
|
|
16
|
+
4. 使用 `yuanflow-cli ai qwen3-vl-plus --video-file` 提交本地视频。
|
|
17
|
+
5. 把知识库规则、作品详情摘要和用户目标写进 prompt,让模型按规则拆解视频。
|
|
18
|
+
6. 合并“平台元数据 + 视觉理解结果 + 知识库规则”,整理成适合自媒体创作复用的详细拆解报告。
|
|
18
19
|
|
|
19
20
|
## 外部 CLI 主流程
|
|
20
21
|
|
|
21
|
-
外部 Agent 或用户直接使用时,先用 `yuanflow-cli knowledge ...`
|
|
22
|
+
外部 Agent 或用户直接使用时,先用 `yuanflow-cli knowledge ...` 查询拆解规则;如果输入是平台作品 URL,再用 `yuanflow-cli works detail` 获取作品详情,用 `yuanflow-cli works download` 获取可播放/下载地址;最后用 `yuanflow-cli ai qwen3-vl-plus` 处理本地视频。
|
|
22
23
|
|
|
23
24
|
1. 先确认本机可执行 `yuanflow-cli --help`。
|
|
24
25
|
2. 外部 CLI 使用 `YUANCHUANG_API_TOKEN` 或 `yuanflow-cli config set-token <你的令牌>` 完成鉴权。
|
|
25
26
|
3. 本地视频会先经过 YuanFlow 文件中转,再提交给 YuanFlow API。
|
|
26
|
-
4. 如果是平台视频 URL
|
|
27
|
+
4. 如果是平台视频 URL,先获取作品详情;需要画面拆解时,再解析并在用户授权后保存到本地文件。
|
|
27
28
|
|
|
28
29
|
本地视频上传依赖 qwen3-vl-plus 的 YuanFlow 文件中转能力。外部 Agent 只需要配置 YuanFlow API token;不要要求用户提供第三方平台 Key。
|
|
29
30
|
|
|
@@ -74,7 +75,42 @@ YuanFlow-main 内置环境调用见后文专属小节。
|
|
|
74
75
|
- 账号定位
|
|
75
76
|
- 带货/转化结构
|
|
76
77
|
|
|
77
|
-
### 3.
|
|
78
|
+
### 3. 平台视频 URL 先获取作品详情
|
|
79
|
+
|
|
80
|
+
如果用户提交的是平台视频 URL,先用 `作品详情获取工具` 或 `yuanflow-cli works detail` 获取平台元数据。它负责标题、文案/描述、封面、作者、发布时间、互动统计和媒体信息;这些内容不要让视觉模型凭画面猜。
|
|
81
|
+
|
|
82
|
+
先按链接判断平台:
|
|
83
|
+
|
|
84
|
+
- `douyin.com`、`v.douyin.com`:`--platform douyin`。
|
|
85
|
+
- `xiaohongshu.com`、`xhslink.com`:`--platform xiaohongshu`,如详情接口要求 `xsec_token`,按返回或用户提供信息补充 `--xsec-token`。
|
|
86
|
+
- `bilibili.com`、`b23.tv`、`BV`:`--platform bilibili`。
|
|
87
|
+
- `youtube.com`、`youtu.be`:`--platform youtube`。
|
|
88
|
+
- `tiktok.com`、`vm.tiktok.com`:`--platform tiktok`。
|
|
89
|
+
- `kuaishou.com`:`--platform kuaishou`。
|
|
90
|
+
- `xigua.com`:`--platform xigua`。
|
|
91
|
+
|
|
92
|
+
外部 CLI 示例:
|
|
93
|
+
|
|
94
|
+
```powershell
|
|
95
|
+
yuanflow-cli works detail --platform douyin --target "https://v.douyin.com/xxx/" --format agent-json
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
拿到详情后,先提炼成“作品详情摘要”,供后续 prompt 使用:
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
【作品详情摘要】
|
|
102
|
+
- 标题:
|
|
103
|
+
- 文案/描述:
|
|
104
|
+
- 作者:
|
|
105
|
+
- 发布时间:
|
|
106
|
+
- 封面:
|
|
107
|
+
- 点赞/评论/转发/收藏/播放:
|
|
108
|
+
- 其它可确认媒体信息:
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
如果详情接口没有返回某些字段,最终报告里写“未返回/未确认”,不要编造。
|
|
112
|
+
|
|
113
|
+
### 4. 视频 URL 再解析并保存本地
|
|
78
114
|
|
|
79
115
|
如果用户提交的是平台视频 URL,先用 `作品下载综合工具` 或 `yuanflow-cli works download` 解析可播放/下载地址候选。
|
|
80
116
|
|
|
@@ -85,6 +121,8 @@ YuanFlow-main 内置环境调用见后文专属小节。
|
|
|
85
121
|
- `youtube.com`、`youtu.be`:`--platform youtube`。
|
|
86
122
|
- `ixigua.com`:`--platform xigua`。
|
|
87
123
|
|
|
124
|
+
注意:`works detail` 支持的平台更多,`works download` 当前只覆盖可播放/下载地址已接入的平台。详情能拿到不代表一定能直接下载视频;下载失败时,让用户提供本地视频或可直接访问的视频文件。
|
|
125
|
+
|
|
88
126
|
外部 CLI 示例:
|
|
89
127
|
|
|
90
128
|
```powershell
|
|
@@ -121,21 +159,66 @@ ffmpeg -y -i "<解析得到的 m3u8 地址>" -c copy "<视频拆解工作目录>
|
|
|
121
159
|
|
|
122
160
|
如果平台解析只返回播放候选而没有稳定下载 URL,说明当前链接需要用户提供可下载文件,或先让用户使用其它授权方式保存视频。
|
|
123
161
|
|
|
124
|
-
###
|
|
162
|
+
### 5. 调用 qwen3-vl-plus 做规则化拆解
|
|
125
163
|
|
|
126
|
-
|
|
164
|
+
把知识库查询到的规则摘要、作品详情摘要和用户目标压缩进 prompt。不要只让模型“分析视频”,必须告诉它按规则拆解,也要明确哪些字段来自平台详情、哪些内容来自视频画面理解。
|
|
127
165
|
|
|
128
166
|
外部 CLI 示例:
|
|
129
167
|
|
|
130
168
|
```powershell
|
|
131
|
-
yuanflow-cli ai qwen3-vl-plus --prompt "
|
|
169
|
+
yuanflow-cli ai qwen3-vl-plus --prompt "你是自媒体视频拆解助手。请按知识库规则和作品详情摘要拆解这个视频:..." --video-file "<本地视频路径>" --format agent-json
|
|
132
170
|
```
|
|
133
171
|
|
|
134
172
|
## YuanFlow-main 内置环境
|
|
135
173
|
|
|
136
174
|
在 YuanFlow-main 内置环境,优先调用受控工具 `yuanflow_cli_call`。token、受管包路径和输出目录由 YuanFlow-main 管理,不要要求用户手动提供 YuanFlow token。
|
|
137
175
|
|
|
138
|
-
|
|
176
|
+
内置环境应按阶段调用:
|
|
177
|
+
|
|
178
|
+
1. `knowledge entry/packs/rules`:查询拆解规则。
|
|
179
|
+
2. `works detail`:平台 URL 获取标题、文案、封面和互动数据。
|
|
180
|
+
3. `works download`:需要画面拆解时获取可播放/下载地址并保存本地。
|
|
181
|
+
4. `ai qwen3-vl-plus`:提交本地视频做视觉与风格拆解。
|
|
182
|
+
|
|
183
|
+
YuanFlow-main 作品详情调用示例:
|
|
184
|
+
|
|
185
|
+
```json
|
|
186
|
+
{
|
|
187
|
+
"args": [
|
|
188
|
+
"works",
|
|
189
|
+
"detail",
|
|
190
|
+
"--platform",
|
|
191
|
+
"douyin",
|
|
192
|
+
"--target",
|
|
193
|
+
"https://v.douyin.com/xxx/",
|
|
194
|
+
"--format",
|
|
195
|
+
"agent-json"
|
|
196
|
+
],
|
|
197
|
+
"timeout": 180
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
YuanFlow-main 下载/播放地址调用示例:
|
|
202
|
+
|
|
203
|
+
```json
|
|
204
|
+
{
|
|
205
|
+
"args": [
|
|
206
|
+
"works",
|
|
207
|
+
"download",
|
|
208
|
+
"--platform",
|
|
209
|
+
"douyin",
|
|
210
|
+
"--target",
|
|
211
|
+
"https://v.douyin.com/xxx/",
|
|
212
|
+
"--region",
|
|
213
|
+
"CN",
|
|
214
|
+
"--format",
|
|
215
|
+
"agent-json"
|
|
216
|
+
],
|
|
217
|
+
"timeout": 300
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
YuanFlow-main 视频拆解调用示例:
|
|
139
222
|
|
|
140
223
|
```json
|
|
141
224
|
{
|
|
@@ -143,7 +226,7 @@ YuanFlow-main 内置工具示例:
|
|
|
143
226
|
"ai",
|
|
144
227
|
"qwen3-vl-plus",
|
|
145
228
|
"--prompt",
|
|
146
|
-
"
|
|
229
|
+
"你是自媒体视频拆解助手。请严格按以下知识库规则和作品详情摘要拆解这个对标视频:\\n【知识库规则摘要】...\\n【作品详情摘要】标题、文案、封面、作者、发布时间、互动数据...\\n【用户目标】学习该视频的选题、开头、脚本结构、镜头节奏、视频风格和可复用创作方法。\\n请区分平台详情可确认字段和视频画面分析结论,不要编造未返回的数据。",
|
|
147
230
|
"--video-file",
|
|
148
231
|
"<本地视频路径>",
|
|
149
232
|
"--format",
|
|
@@ -169,43 +252,71 @@ YuanFlow-main 内置工具示例:
|
|
|
169
252
|
|
|
170
253
|
## 1. 一句话结论
|
|
171
254
|
|
|
172
|
-
## 2.
|
|
255
|
+
## 2. 视频基础信息与数据表现
|
|
256
|
+
- 标题:
|
|
257
|
+
- 文案/描述:
|
|
258
|
+
- 作者/账号:
|
|
259
|
+
- 发布时间:
|
|
260
|
+
- 封面:
|
|
261
|
+
- 点赞/评论/转发/收藏/播放:
|
|
262
|
+
- 数据初步判断:
|
|
263
|
+
- 未返回或不确定字段:
|
|
264
|
+
|
|
265
|
+
## 3. 视频基础判断
|
|
173
266
|
- 内容类型:
|
|
174
267
|
- 目标受众:
|
|
175
268
|
- 核心卖点/观点:
|
|
176
269
|
- 适用平台:
|
|
177
270
|
|
|
178
|
-
##
|
|
271
|
+
## 4. 封面、标题与文案拆解
|
|
272
|
+
- 封面吸引点:
|
|
273
|
+
- 标题钩子:
|
|
274
|
+
- 文案结构:
|
|
275
|
+
- 标题/文案/封面的匹配度:
|
|
276
|
+
|
|
277
|
+
## 5. 开头钩子拆解
|
|
179
278
|
- 前 3 秒:
|
|
180
279
|
- 冲突/利益点:
|
|
181
280
|
- 留人方式:
|
|
182
281
|
|
|
183
|
-
##
|
|
282
|
+
## 6. 内容结构拆解
|
|
184
283
|
- 段落 1:
|
|
185
284
|
- 段落 2:
|
|
186
285
|
- 段落 3:
|
|
187
286
|
- 结尾:
|
|
188
287
|
|
|
189
|
-
##
|
|
288
|
+
## 7. 画面与镜头节奏
|
|
190
289
|
- 场景变化:
|
|
191
290
|
- 人物/产品/字幕:
|
|
192
291
|
- 节奏特点:
|
|
193
292
|
|
|
194
|
-
##
|
|
293
|
+
## 8. 视频风格解析拆解
|
|
294
|
+
- 整体风格标签:
|
|
295
|
+
- 镜头语言:
|
|
296
|
+
- 剪辑节奏:
|
|
297
|
+
- 字幕与包装:
|
|
298
|
+
- 音乐/音效:
|
|
299
|
+
- 叙事语气:
|
|
300
|
+
- 人设或账号气质:
|
|
301
|
+
- 平台适配特点:
|
|
302
|
+
- 可复用风格公式:
|
|
303
|
+
|
|
304
|
+
## 9. 情绪、信任和转化设计
|
|
195
305
|
|
|
196
|
-
##
|
|
306
|
+
## 10. 可复用创作模板
|
|
197
307
|
|
|
198
|
-
##
|
|
308
|
+
## 11. 可借鉴点与风险
|
|
199
309
|
- 可借鉴:
|
|
200
310
|
- 不建议照搬:
|
|
201
311
|
- 需要二次原创:
|
|
202
312
|
|
|
203
|
-
##
|
|
313
|
+
## 12. 给用户的下一步建议
|
|
204
314
|
```
|
|
205
315
|
|
|
206
316
|
## 失败处理
|
|
207
317
|
|
|
208
318
|
- 知识库查询失败:说明无法取得拆解规则,不要跳过规则直接当普通视觉理解;可询问用户是否改为通用视觉理解。
|
|
319
|
+
- 作品详情获取失败:说明无法补齐标题、文案、封面和互动数据;可继续做视频画面拆解,但最终报告必须标注平台元数据缺失。
|
|
209
320
|
- 视频 URL 解析失败:说明需要用户提供本地视频或可直接访问的下载链接。
|
|
210
321
|
- YuanFlow token 缺失:说明需要在受管环境或 CLI 配置 YuanFlow API token,不要让用户在聊天里粘贴敏感 Key。
|
|
211
322
|
- 视频超限:提示最大 2GB、2 秒到 1 小时,让用户裁剪或压缩。
|
package/src/agent-protocol.js
CHANGED
|
@@ -25,7 +25,8 @@ const ERROR_MAP = [
|
|
|
25
25
|
message.includes('未知平台') ||
|
|
26
26
|
message.includes('未找到命令') ||
|
|
27
27
|
message.includes('未知命令') ||
|
|
28
|
-
message.includes('不支持')
|
|
28
|
+
message.includes('不支持') ||
|
|
29
|
+
message.includes('已有未归档音色'),
|
|
29
30
|
},
|
|
30
31
|
{
|
|
31
32
|
code: 'AUTH_INVALID',
|
|
@@ -78,7 +79,7 @@ export function createAgentSuccess(command, data, meta = {}) {
|
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
export function createAgentError(command, error) {
|
|
81
|
-
const message = error?.message || String(error);
|
|
82
|
+
const message = sanitizeErrorMessage(error?.message || String(error));
|
|
82
83
|
const mapped = mapError(message);
|
|
83
84
|
return {
|
|
84
85
|
payload: {
|
|
@@ -97,6 +98,18 @@ export function createAgentError(command, error) {
|
|
|
97
98
|
};
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
function sanitizeErrorMessage(message) {
|
|
102
|
+
const text = String(message || '');
|
|
103
|
+
if (text.includes('one username can only have one voice id')) {
|
|
104
|
+
return '请求失败:HTTP 409 当前用户已有未归档音色。请先使用 voice list 查询已有 voice_xxx,或使用已有音色复刻。';
|
|
105
|
+
}
|
|
106
|
+
const internalErrorType = ['new', 'api', 'error'].join('_');
|
|
107
|
+
const internalProductName = ['New', 'Api'].join('');
|
|
108
|
+
return text
|
|
109
|
+
.replace(new RegExp(internalErrorType, 'gi'), 'platform_service_error')
|
|
110
|
+
.replace(new RegExp(internalProductName, 'g'), 'YuanFlow API');
|
|
111
|
+
}
|
|
112
|
+
|
|
100
113
|
export function getCommandName(platform, command) {
|
|
101
114
|
return [platform, command].filter(Boolean).join(' ') || 'unknown';
|
|
102
115
|
}
|
package/src/ai-tools.js
CHANGED
|
@@ -11,8 +11,8 @@ const DOUBAO_TTS_VOICE_ASSETS_PATH = '/api/voice-assets/doubao/voices';
|
|
|
11
11
|
const YUANFLOW_FILE_TRANSFER_PATH = '/atomic/oss/temp-upload';
|
|
12
12
|
|
|
13
13
|
const MODEL_QWEN_VL = 'qwen3-vl-plus';
|
|
14
|
-
const
|
|
15
|
-
const
|
|
14
|
+
const MODEL_VOICE_ENROLLMENT = 'voice-enrollment';
|
|
15
|
+
const MODEL_COSYVOICE_FLASH = 'cosyvoice-v3-flash';
|
|
16
16
|
const MODEL_FUN_ASR = 'fun-asr';
|
|
17
17
|
const MODEL_DOUBAO_TTS = 'doubao-tts';
|
|
18
18
|
|
|
@@ -41,34 +41,36 @@ export function listAiCommands() {
|
|
|
41
41
|
returns: '返回 OpenAI chat.completion 兼容 JSON。',
|
|
42
42
|
}),
|
|
43
43
|
aiCommand({
|
|
44
|
-
key: 'ai.
|
|
45
|
-
command: 'ai
|
|
46
|
-
description: '调用 YuanFlow API 对外模型
|
|
44
|
+
key: 'ai.voice-enrollment',
|
|
45
|
+
command: 'ai voice-enrollment',
|
|
46
|
+
description: '调用 YuanFlow API 对外模型 voice-enrollment,创建音色复刻记录。',
|
|
47
47
|
apiPath: AUDIO_VOICES_PATH,
|
|
48
48
|
options: [
|
|
49
|
-
option('--file', 'file', false, '
|
|
49
|
+
option('--file', 'file', false, '本地音频文件;通过 multipart 直接提交给 YuanFlow API,与 --audio-url 二选一。'),
|
|
50
50
|
option('--audio-url', 'audioUrl', false, '公网可访问音频 URL;与 --file 二选一。'),
|
|
51
51
|
option('--name', 'name', false, '音色展示名。'),
|
|
52
52
|
option('--preferred-name', 'preferredName', false, '偏好音色名,默认跟随 --name。'),
|
|
53
|
-
option('--
|
|
54
|
-
option('--language', '
|
|
53
|
+
option('--target-model', 'targetModel', false, `后续合成模型,默认 ${MODEL_COSYVOICE_FLASH}。`),
|
|
54
|
+
option('--language-hints', 'languageHints', false, '逗号分隔的样本音频语种提示,例如 zh。'),
|
|
55
|
+
option('--language', 'language', false, '兼容别名;会映射为 language_hints。'),
|
|
55
56
|
option('--activate', 'activate', false, '创建后设为当前默认音色。'),
|
|
56
57
|
...commonOptions(),
|
|
57
58
|
],
|
|
58
59
|
requestBody: {
|
|
59
|
-
model:
|
|
60
|
-
|
|
60
|
+
model: MODEL_VOICE_ENROLLMENT,
|
|
61
|
+
target_model: MODEL_COSYVOICE_FLASH,
|
|
62
|
+
file: '<multipart 本地音频,或通过 audio_url 传入公网音频 URL>',
|
|
61
63
|
},
|
|
62
|
-
returns: '返回 voice_xxx 音色对象;后续
|
|
64
|
+
returns: '返回 voice_xxx 音色对象;后续 cosyvoice-v3-flash 可用 --voice voice_xxx 调用。',
|
|
63
65
|
}),
|
|
64
66
|
aiCommand({
|
|
65
|
-
key: 'ai.
|
|
66
|
-
command: 'ai
|
|
67
|
-
description: '调用 YuanFlow API 对外模型
|
|
67
|
+
key: 'ai.cosyvoice-v3-flash',
|
|
68
|
+
command: 'ai cosyvoice-v3-flash',
|
|
69
|
+
description: '调用 YuanFlow API 对外模型 cosyvoice-v3-flash,使用 voice_xxx 或 default 合成音频。',
|
|
68
70
|
apiPath: AUDIO_SPEECH_PATH,
|
|
69
71
|
options: speechOptions('音色 ID:voice_xxx 或 default。', false),
|
|
70
72
|
requestBody: {
|
|
71
|
-
model:
|
|
73
|
+
model: MODEL_COSYVOICE_FLASH,
|
|
72
74
|
input: '<text>',
|
|
73
75
|
voice: '<voice_xxx|default>',
|
|
74
76
|
},
|
|
@@ -166,10 +168,10 @@ export async function runAiCommand({ action = 'help', rest = [], options }) {
|
|
|
166
168
|
return { ok: true, commands: listAiCommands() };
|
|
167
169
|
case MODEL_QWEN_VL:
|
|
168
170
|
return callJson(CHAT_COMPLETIONS_PATH, options, await buildQwenVLBody(options));
|
|
169
|
-
case
|
|
170
|
-
return
|
|
171
|
-
case
|
|
172
|
-
return callSpeech(
|
|
171
|
+
case MODEL_VOICE_ENROLLMENT:
|
|
172
|
+
return callVoiceEnrollment(options);
|
|
173
|
+
case MODEL_COSYVOICE_FLASH:
|
|
174
|
+
return callSpeech(MODEL_COSYVOICE_FLASH, options, false);
|
|
173
175
|
case MODEL_FUN_ASR:
|
|
174
176
|
return callFunASR(options);
|
|
175
177
|
case MODEL_DOUBAO_TTS:
|
|
@@ -308,21 +310,38 @@ async function buildVoiceEnrollmentBody(options) {
|
|
|
308
310
|
throw new Error('--file 和 --audio-url 不能同时使用。');
|
|
309
311
|
}
|
|
310
312
|
const body = {
|
|
311
|
-
model:
|
|
313
|
+
model: MODEL_VOICE_ENROLLMENT,
|
|
314
|
+
target_model: cleanOptional(options.named?.['target-model']) || MODEL_COSYVOICE_FLASH,
|
|
312
315
|
...optionalField('name', options.named?.name),
|
|
313
316
|
...optionalField('preferred_name', options.named?.['preferred-name']),
|
|
314
|
-
...optionalField('text', options.named?.text),
|
|
315
|
-
...optionalField('language', options.named?.language),
|
|
316
317
|
...optionalBooleanField('activate', options.named?.activate),
|
|
317
318
|
};
|
|
319
|
+
const languageHints = splitList(options.named?.['language-hints'] || options.named?.language);
|
|
320
|
+
if (languageHints.length > 0) {
|
|
321
|
+
body.language_hints = languageHints;
|
|
322
|
+
}
|
|
318
323
|
if (audioUrl) {
|
|
319
324
|
body.audio_url = audioUrl;
|
|
320
325
|
} else {
|
|
321
|
-
body.
|
|
326
|
+
body.file = '<file omitted>';
|
|
322
327
|
}
|
|
323
328
|
return body;
|
|
324
329
|
}
|
|
325
330
|
|
|
331
|
+
async function callVoiceEnrollment(options) {
|
|
332
|
+
const body = await buildVoiceEnrollmentBody(options);
|
|
333
|
+
const filePath = cleanOptional(options.file);
|
|
334
|
+
const audioUrl = cleanOptional(options.named?.['audio-url']);
|
|
335
|
+
if (filePath && !audioUrl && !options.json) {
|
|
336
|
+
const response = await callMultipartJson(AUDIO_VOICES_PATH, options, {
|
|
337
|
+
...body,
|
|
338
|
+
filePath,
|
|
339
|
+
});
|
|
340
|
+
return result(MODEL_VOICE_ENROLLMENT, AUDIO_VOICES_PATH, body, response);
|
|
341
|
+
}
|
|
342
|
+
return callJson(AUDIO_VOICES_PATH, options, body);
|
|
343
|
+
}
|
|
344
|
+
|
|
326
345
|
async function callSpeech(model, options, requiresVoice) {
|
|
327
346
|
const body = buildSpeechBody(model, options, requiresVoice);
|
|
328
347
|
const response = await callBinary(AUDIO_SPEECH_PATH, options, body);
|
|
@@ -352,10 +371,18 @@ function buildSpeechBody(model, options, requiresVoice) {
|
|
|
352
371
|
const metadata = parseJsonObject(options.named?.metadata);
|
|
353
372
|
addNumber(metadata, 'sample_rate', options.named?.['sample-rate']);
|
|
354
373
|
addNumber(metadata, 'volume', options.named?.volume);
|
|
355
|
-
addNumber(metadata, '
|
|
374
|
+
addNumber(metadata, 'rate', options.named?.rate || options.named?.speed);
|
|
375
|
+
addNumber(metadata, 'pitch', options.named?.pitch || options.named?.['pitch-rate']);
|
|
356
376
|
addNumber(metadata, 'bit_rate', options.named?.['bit-rate']);
|
|
357
377
|
addString(metadata, 'mode', options.named?.mode);
|
|
358
378
|
addString(metadata, 'language', options.named?.language);
|
|
379
|
+
addNumber(metadata, 'seed', options.named?.seed);
|
|
380
|
+
addBoolean(metadata, 'enable_ssml', options.named?.['enable-ssml']);
|
|
381
|
+
addBoolean(metadata, 'word_timestamp_enabled', options.named?.['word-timestamp-enabled']);
|
|
382
|
+
const hints = splitList(options.named?.['language-hints']);
|
|
383
|
+
if (hints.length > 0) {
|
|
384
|
+
metadata.language_hints = hints;
|
|
385
|
+
}
|
|
359
386
|
if (Object.keys(metadata).length > 0) {
|
|
360
387
|
body.metadata = metadata;
|
|
361
388
|
}
|
|
@@ -489,19 +516,25 @@ async function callGetJson(apiPath, options) {
|
|
|
489
516
|
}
|
|
490
517
|
|
|
491
518
|
async function callMultipartJson(apiPath, options, payload) {
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
metadata: payload.metadata,
|
|
496
|
-
file: '<file omitted>',
|
|
497
|
-
});
|
|
519
|
+
const requestBody = { ...payload, file: '<file omitted>' };
|
|
520
|
+
delete requestBody.filePath;
|
|
521
|
+
const request = await buildRequest(apiPath, options, 'POST', requestBody);
|
|
498
522
|
if (request.dryRun) {
|
|
499
523
|
return request;
|
|
500
524
|
}
|
|
501
525
|
const form = new FormData();
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
526
|
+
for (const [key, value] of Object.entries(payload || {})) {
|
|
527
|
+
if (key === 'filePath' || key === 'file' || value === undefined || value === null) {
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (Array.isArray(value)) {
|
|
531
|
+
form.set(key, value.join(','));
|
|
532
|
+
} else if (typeof value === 'object') {
|
|
533
|
+
form.set(key, JSON.stringify(value));
|
|
534
|
+
} else {
|
|
535
|
+
form.set(key, String(value));
|
|
536
|
+
}
|
|
537
|
+
}
|
|
505
538
|
const file = new Blob([await readFile(payload.filePath)], { type: inferAudioMimeType(payload.filePath) });
|
|
506
539
|
form.set('file', file, path.basename(payload.filePath));
|
|
507
540
|
const response = await fetch(request.url, {
|
|
@@ -617,8 +650,16 @@ function speechOptions(voiceLabel, voiceRequired) {
|
|
|
617
650
|
option('--voice', 'voice', voiceRequired, voiceLabel),
|
|
618
651
|
option('--output', 'output', true, '音频保存路径;dry-run 时可不传。'),
|
|
619
652
|
option('--response-format', 'responseFormat', false, 'mp3、wav、pcm 等,默认 mp3。'),
|
|
620
|
-
option('--
|
|
653
|
+
option('--rate', 'rate', false, '语速控制,会写入 metadata.rate。'),
|
|
654
|
+
option('--speed', 'speed', false, '兼容别名;会写入 metadata.rate。'),
|
|
655
|
+
option('--volume', 'volume', false, '音量控制,会写入 metadata.volume。'),
|
|
656
|
+
option('--pitch', 'pitch', false, '音调控制,会写入 metadata.pitch。'),
|
|
621
657
|
option('--sample-rate', 'sampleRate', false, '采样率。'),
|
|
658
|
+
option('--language', 'language', false, '目标合成语言,例如 zh。'),
|
|
659
|
+
option('--language-hints', 'languageHints', false, '逗号分隔的目标合成语种提示,例如 zh。'),
|
|
660
|
+
option('--enable-ssml', 'enableSsml', false, '是否开启 SSML。'),
|
|
661
|
+
option('--word-timestamp-enabled', 'wordTimestampEnabled', false, '是否开启字级时间戳。'),
|
|
662
|
+
option('--seed', 'seed', false, '随机种子。'),
|
|
622
663
|
option('--metadata', 'metadata', false, '透传给 YuanFlow API 的 metadata JSON。'),
|
|
623
664
|
...commonOptions(),
|
|
624
665
|
];
|
|
@@ -638,11 +679,6 @@ function option(flag, name, required, label) {
|
|
|
638
679
|
return { flag, name, required, label };
|
|
639
680
|
}
|
|
640
681
|
|
|
641
|
-
async function fileToDataUri(filePath) {
|
|
642
|
-
const data = await readFile(filePath);
|
|
643
|
-
return `data:${inferAudioMimeType(filePath)};base64,${data.toString('base64')}`;
|
|
644
|
-
}
|
|
645
|
-
|
|
646
682
|
function inferAudioMimeType(filePath) {
|
|
647
683
|
switch (path.extname(filePath).toLowerCase()) {
|
|
648
684
|
case '.mp3':
|
package/src/cli.js
CHANGED
|
@@ -629,19 +629,19 @@ function printHelp() {
|
|
|
629
629
|
yuanflow-cli browser task-plan --platform xiaohongshu --task publish --account main --format agent-json
|
|
630
630
|
yuanflow-cli ai qwen3-vl-plus --prompt "描述这张图" --image-url "https://example.com/image.png" --dry-run
|
|
631
631
|
yuanflow-cli ai qwen3-vl-plus --prompt "总结这个视频画面" --video-url "https://example.com/video.mp4" --dry-run
|
|
632
|
-
yuanflow-cli ai qwen3-vl-plus --prompt "描述本地图片" --image-file "
|
|
633
|
-
yuanflow-cli ai qwen3-vl-plus --prompt "描述本地视频" --video-file "
|
|
634
|
-
yuanflow-cli voice clone --file-transfer "
|
|
632
|
+
yuanflow-cli ai qwen3-vl-plus --prompt "描述本地图片" --image-file "<本地图片路径>" --dry-run
|
|
633
|
+
yuanflow-cli ai qwen3-vl-plus --prompt "描述本地视频" --video-file "<本地视频路径>" --dry-run
|
|
634
|
+
yuanflow-cli voice clone --file-transfer "<本地音频路径>" --name demo --activate --dry-run
|
|
635
635
|
yuanflow-cli voice list --dry-run
|
|
636
636
|
yuanflow-cli voice activate --voice voice_xxx --dry-run
|
|
637
|
-
yuanflow-cli voice replicate --text "你好" --voice voice_xxx --output "
|
|
638
|
-
yuanflow-cli ai
|
|
639
|
-
yuanflow-cli ai
|
|
637
|
+
yuanflow-cli voice replicate --text "你好" --voice voice_xxx --output "<输出音频路径>" --dry-run
|
|
638
|
+
yuanflow-cli ai voice-enrollment --file "<本地音频路径>" --name demo --activate --dry-run
|
|
639
|
+
yuanflow-cli ai cosyvoice-v3-flash --text "你好" --voice voice_xxx --output "<输出音频路径>" --dry-run
|
|
640
640
|
yuanflow-cli ai fun-asr --audio-url "https://example.com/audio.wav" --response-format verbose_json --dry-run
|
|
641
641
|
yuanflow-cli ai doubao-tts voices --dry-run
|
|
642
642
|
yuanflow-cli ai doubao-tts voice --voice zh_female_xiaohe_uranus_bigtts --dry-run
|
|
643
|
-
yuanflow-cli ai doubao-tts voice-download --voice zh_female_xiaohe_uranus_bigtts --output "
|
|
644
|
-
yuanflow-cli ai doubao-tts --text "你好" --voice zh_female_xiaohe_uranus_bigtts --output "
|
|
643
|
+
yuanflow-cli ai doubao-tts voice-download --voice zh_female_xiaohe_uranus_bigtts --output "<输出音频路径>" --dry-run
|
|
644
|
+
yuanflow-cli ai doubao-tts --text "你好" --voice zh_female_xiaohe_uranus_bigtts --output "<输出音频路径>" --dry-run
|
|
645
645
|
yuanflow-cli list douyin
|
|
646
646
|
|
|
647
647
|
说明:
|
|
@@ -650,7 +650,7 @@ function printHelp() {
|
|
|
650
650
|
qwen3-vl-plus 支持 --image-url、--video-url、--image-file、--video-file 四选一;视频建议最大 2GB、时长 2 秒到 1 小时。
|
|
651
651
|
qwen3-vl-plus 本地图片/视频会先走 YuanFlow 文件中转,内部调用 /atomic/oss/temp-upload 后把 signed_url 提交给模型。
|
|
652
652
|
browser 命令是自媒体平台专用浏览器自动化协议,只返回受控 profile/cookie/任务路径与执行计划,不用于普通网页搜索。
|
|
653
|
-
|
|
653
|
+
视频智能剪辑和视频制作执行链已迁移到独立项目,不再由 yuanflow-cli video 命令提供;视频拆解仍由 yuanflow-skill 的“视频拆解”提供。
|
|
654
654
|
需要鉴权的请求都会使用 Authorization: Bearer <token>。
|
|
655
655
|
token 优先级:--token > YUANCHUANG_API_TOKEN > 本地 config.token。
|
|
656
656
|
YuanFlow-main 内置环境使用时,token 由 YuanFlow-main 内置环境注入,不需要手动配置。
|
package/src/voice-tools.js
CHANGED
|
@@ -7,8 +7,8 @@ const AUDIO_SPEECH_PATH = '/v1/audio/speech';
|
|
|
7
7
|
const AUDIO_VOICES_PATH = '/v1/audio/voices';
|
|
8
8
|
const YUANFLOW_FILE_TRANSFER_PATH = '/atomic/oss/temp-upload';
|
|
9
9
|
|
|
10
|
-
const MODEL_VOICE_CLONE = '
|
|
11
|
-
const MODEL_VOICE_REPLICATE = '
|
|
10
|
+
const MODEL_VOICE_CLONE = 'voice-enrollment';
|
|
11
|
+
const MODEL_VOICE_REPLICATE = 'cosyvoice-v3-flash';
|
|
12
12
|
|
|
13
13
|
export function listVoiceCommands() {
|
|
14
14
|
return [
|
|
@@ -19,19 +19,21 @@ export function listVoiceCommands() {
|
|
|
19
19
|
method: 'POST',
|
|
20
20
|
apiPath: AUDIO_VOICES_PATH,
|
|
21
21
|
options: [
|
|
22
|
-
option('--file', 'file', false, '
|
|
22
|
+
option('--file', 'file', false, '本地音频文件;通过 multipart 直接提交给 YuanFlow API,与 --file-transfer、--audio-url 三选一。'),
|
|
23
23
|
option('--file-transfer', 'fileTransfer', false, '本地音频文件;先通过 YuanFlow 文件中转生成临时 URL,再创建声音克隆。'),
|
|
24
24
|
option('--audio-url', 'audioUrl', false, '公网可访问音频 URL;与 --file、--file-transfer 三选一。'),
|
|
25
25
|
option('--name', 'name', false, '声音克隆展示名。'),
|
|
26
26
|
option('--preferred-name', 'preferredName', false, '偏好音色名,默认跟随 --name。'),
|
|
27
|
-
option('--
|
|
28
|
-
option('--language', '
|
|
27
|
+
option('--target-model', 'targetModel', false, `后续合成模型,默认 ${MODEL_VOICE_REPLICATE}。`),
|
|
28
|
+
option('--language-hints', 'languageHints', false, '逗号分隔的样本音频语种提示,例如 zh。'),
|
|
29
|
+
option('--language', 'language', false, '兼容别名;会映射为 language_hints。'),
|
|
29
30
|
option('--activate', 'activate', false, '创建后设为当前默认音色。'),
|
|
30
31
|
...commonOptions(),
|
|
31
32
|
],
|
|
32
33
|
requestBody: {
|
|
33
34
|
model: MODEL_VOICE_CLONE,
|
|
34
|
-
|
|
35
|
+
target_model: MODEL_VOICE_REPLICATE,
|
|
36
|
+
file: '<multipart 本地音频,或通过 audio_url 传入公网音频 URL>',
|
|
35
37
|
},
|
|
36
38
|
returns: '返回 voice_xxx 音色对象;后续 voice replicate 可通过 --voice voice_xxx 复刻声音。',
|
|
37
39
|
}),
|
|
@@ -69,8 +71,16 @@ export function listVoiceCommands() {
|
|
|
69
71
|
option('--voice', 'voice', true, '声音克隆 ID:voice_xxx;也可传 default 使用已激活默认音色。'),
|
|
70
72
|
option('--output', 'output', true, '音频保存路径;dry-run 时可不传。'),
|
|
71
73
|
option('--response-format', 'responseFormat', false, 'mp3、wav、pcm 等,默认 mp3。'),
|
|
72
|
-
option('--
|
|
74
|
+
option('--rate', 'rate', false, '语速控制,会写入 metadata.rate。'),
|
|
75
|
+
option('--speed', 'speed', false, '兼容别名;会写入 metadata.rate。'),
|
|
76
|
+
option('--volume', 'volume', false, '音量控制,会写入 metadata.volume。'),
|
|
77
|
+
option('--pitch', 'pitch', false, '音调控制,会写入 metadata.pitch。'),
|
|
73
78
|
option('--sample-rate', 'sampleRate', false, '采样率。'),
|
|
79
|
+
option('--language', 'language', false, '目标合成语言,例如 zh。'),
|
|
80
|
+
option('--language-hints', 'languageHints', false, '逗号分隔的目标合成语种提示,例如 zh。'),
|
|
81
|
+
option('--enable-ssml', 'enableSsml', false, '是否开启 SSML。'),
|
|
82
|
+
option('--word-timestamp-enabled', 'wordTimestampEnabled', false, '是否开启字级时间戳。'),
|
|
83
|
+
option('--seed', 'seed', false, '随机种子。'),
|
|
74
84
|
option('--metadata', 'metadata', false, '透传给 YuanFlow API 的 metadata JSON。'),
|
|
75
85
|
...commonOptions(),
|
|
76
86
|
],
|
|
@@ -112,8 +122,10 @@ export async function runVoiceCommand({ action = 'help', options }) {
|
|
|
112
122
|
}
|
|
113
123
|
|
|
114
124
|
async function cloneVoice(options) {
|
|
115
|
-
const body = await
|
|
116
|
-
const response =
|
|
125
|
+
const { body, filePath } = await buildVoiceClonePayload(options);
|
|
126
|
+
const response = filePath
|
|
127
|
+
? await callMultipartJson(AUDIO_VOICES_PATH, options, body, filePath)
|
|
128
|
+
: await callJson(AUDIO_VOICES_PATH, options, body);
|
|
117
129
|
return result('voice clone', AUDIO_VOICES_PATH, body, response, { kind: 'voice-clone' });
|
|
118
130
|
}
|
|
119
131
|
|
|
@@ -138,9 +150,9 @@ async function replicateVoice(options) {
|
|
|
138
150
|
return result('voice replicate', AUDIO_SPEECH_PATH, body, response, { kind: 'voice-replicate' });
|
|
139
151
|
}
|
|
140
152
|
|
|
141
|
-
async function
|
|
153
|
+
async function buildVoiceClonePayload(options) {
|
|
142
154
|
if (options.json) {
|
|
143
|
-
return JSON.parse(options.json);
|
|
155
|
+
return { body: JSON.parse(options.json) };
|
|
144
156
|
}
|
|
145
157
|
const filePath = cleanOptional(options.file);
|
|
146
158
|
const fileTransferPath = cleanOptional(options.named?.['file-transfer']);
|
|
@@ -155,20 +167,24 @@ async function buildVoiceCloneBody(options) {
|
|
|
155
167
|
|
|
156
168
|
const body = {
|
|
157
169
|
model: MODEL_VOICE_CLONE,
|
|
170
|
+
target_model: cleanOptional(options.named?.['target-model']) || MODEL_VOICE_REPLICATE,
|
|
158
171
|
...optionalField('name', options.named?.name),
|
|
159
172
|
...optionalField('preferred_name', options.named?.['preferred-name']),
|
|
160
|
-
...optionalField('text', options.named?.text),
|
|
161
|
-
...optionalField('language', options.named?.language),
|
|
162
173
|
...optionalBooleanField('activate', options.named?.activate),
|
|
163
174
|
};
|
|
175
|
+
const languageHints = splitList(options.named?.['language-hints'] || options.named?.language);
|
|
176
|
+
if (languageHints.length > 0) {
|
|
177
|
+
body.language_hints = languageHints;
|
|
178
|
+
}
|
|
164
179
|
if (audioUrl) {
|
|
165
180
|
body.audio_url = audioUrl;
|
|
166
181
|
} else if (fileTransferPath) {
|
|
167
182
|
body.audio_url = await resolveYuanFlowAudioFile(fileTransferPath, options);
|
|
168
183
|
} else {
|
|
169
|
-
body.
|
|
184
|
+
body.file = '<file omitted>';
|
|
185
|
+
return { body, filePath };
|
|
170
186
|
}
|
|
171
|
-
return body;
|
|
187
|
+
return { body };
|
|
172
188
|
}
|
|
173
189
|
|
|
174
190
|
function buildVoiceReplicateBody(options) {
|
|
@@ -187,9 +203,19 @@ function buildVoiceReplicateBody(options) {
|
|
|
187
203
|
response_format: cleanOptional(options.named?.['response-format']) || 'mp3',
|
|
188
204
|
...optionalField('instructions', options.named?.instructions),
|
|
189
205
|
};
|
|
190
|
-
addNumber(body, 'speed', options.named?.speed);
|
|
191
206
|
const metadata = parseJsonObject(options.named?.metadata);
|
|
192
207
|
addNumber(metadata, 'sample_rate', options.named?.['sample-rate']);
|
|
208
|
+
addNumber(metadata, 'volume', options.named?.volume);
|
|
209
|
+
addNumber(metadata, 'rate', options.named?.rate || options.named?.speed);
|
|
210
|
+
addNumber(metadata, 'pitch', options.named?.pitch);
|
|
211
|
+
addString(metadata, 'language', options.named?.language);
|
|
212
|
+
addNumber(metadata, 'seed', options.named?.seed);
|
|
213
|
+
addBoolean(metadata, 'enable_ssml', options.named?.['enable-ssml']);
|
|
214
|
+
addBoolean(metadata, 'word_timestamp_enabled', options.named?.['word-timestamp-enabled']);
|
|
215
|
+
const hints = splitList(options.named?.['language-hints']);
|
|
216
|
+
if (hints.length > 0) {
|
|
217
|
+
metadata.language_hints = hints;
|
|
218
|
+
}
|
|
193
219
|
if (Object.keys(metadata).length > 0) {
|
|
194
220
|
body.metadata = metadata;
|
|
195
221
|
}
|
|
@@ -236,6 +262,28 @@ async function callJson(apiPath, options, body) {
|
|
|
236
262
|
return readJsonResponse(response);
|
|
237
263
|
}
|
|
238
264
|
|
|
265
|
+
async function callMultipartJson(apiPath, options, body, filePath) {
|
|
266
|
+
const request = await buildRequest(apiPath, options, 'POST', body);
|
|
267
|
+
if (request.dryRun) {
|
|
268
|
+
return request;
|
|
269
|
+
}
|
|
270
|
+
const form = new FormData();
|
|
271
|
+
for (const [key, value] of Object.entries(body || {})) {
|
|
272
|
+
if (key === 'file') {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
form.set(key, Array.isArray(value) ? value.join(',') : String(value));
|
|
276
|
+
}
|
|
277
|
+
const file = new Blob([await readFile(filePath)], { type: inferAudioMimeType(filePath) });
|
|
278
|
+
form.set('file', file, path.basename(filePath));
|
|
279
|
+
const response = await fetch(request.url, {
|
|
280
|
+
method: 'POST',
|
|
281
|
+
headers: request.headers,
|
|
282
|
+
body: form,
|
|
283
|
+
});
|
|
284
|
+
return readJsonResponse(response);
|
|
285
|
+
}
|
|
286
|
+
|
|
239
287
|
async function callGetJson(apiPath, options) {
|
|
240
288
|
const request = await buildRequest(apiPath, options, 'GET');
|
|
241
289
|
if (request.dryRun) {
|
|
@@ -364,11 +412,6 @@ function option(flag, name, required, label) {
|
|
|
364
412
|
return { flag, name, required, label };
|
|
365
413
|
}
|
|
366
414
|
|
|
367
|
-
async function fileToDataUri(filePath) {
|
|
368
|
-
const data = await readFile(filePath);
|
|
369
|
-
return `data:${inferAudioMimeType(filePath)};base64,${data.toString('base64')}`;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
415
|
function inferAudioMimeType(filePath) {
|
|
373
416
|
switch (path.extname(filePath).toLowerCase()) {
|
|
374
417
|
case '.mp3':
|
|
@@ -418,6 +461,20 @@ function addNumber(target, name, value) {
|
|
|
418
461
|
}
|
|
419
462
|
}
|
|
420
463
|
|
|
464
|
+
function addString(target, name, value) {
|
|
465
|
+
const cleaned = cleanOptional(value);
|
|
466
|
+
if (cleaned !== undefined) {
|
|
467
|
+
target[name] = cleaned;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function addBoolean(target, name, value) {
|
|
472
|
+
const parsed = parseBoolean(value);
|
|
473
|
+
if (parsed !== undefined) {
|
|
474
|
+
target[name] = parsed;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
421
478
|
function parseBoolean(value) {
|
|
422
479
|
const cleaned = cleanOptional(value);
|
|
423
480
|
if (cleaned === undefined) {
|
|
@@ -429,6 +486,20 @@ function parseBoolean(value) {
|
|
|
429
486
|
return ['1', 'true', 'yes', 'on'].includes(String(cleaned).toLowerCase());
|
|
430
487
|
}
|
|
431
488
|
|
|
489
|
+
function splitList(value) {
|
|
490
|
+
const cleaned = cleanOptional(value);
|
|
491
|
+
if (!cleaned) {
|
|
492
|
+
return [];
|
|
493
|
+
}
|
|
494
|
+
if (Array.isArray(cleaned)) {
|
|
495
|
+
return cleaned.map((item) => String(item).trim()).filter(Boolean);
|
|
496
|
+
}
|
|
497
|
+
return String(cleaned)
|
|
498
|
+
.split(',')
|
|
499
|
+
.map((item) => item.trim())
|
|
500
|
+
.filter(Boolean);
|
|
501
|
+
}
|
|
502
|
+
|
|
432
503
|
function cleanOptional(value) {
|
|
433
504
|
if (value === undefined || value === null) return undefined;
|
|
434
505
|
if (typeof value === 'string') {
|