museav-cli 2.5.0 → 2.8.0
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/AGENTS.md +16 -0
- package/CHANGELOG.md +67 -0
- package/README.md +61 -0
- package/dist/client.d.ts +31 -3
- package/dist/client.js +50 -3
- package/dist/commands/balance.js +5 -2
- package/dist/commands/img-tools.js +2 -1
- package/dist/commands/poster-templates.d.ts +11 -0
- package/dist/commands/poster-templates.js +23 -0
- package/dist/commands/speak.d.ts +9 -0
- package/dist/commands/speak.js +41 -0
- package/dist/commands/stickers.d.ts +10 -0
- package/dist/commands/stickers.js +22 -0
- package/dist/commands/upload.d.ts +4 -1
- package/dist/commands/upload.js +15 -2
- package/dist/compress.js +8 -0
- package/dist/index.js +40 -3
- package/dist/local-bg.d.ts +23 -0
- package/dist/local-bg.js +68 -19
- package/dist/mimo-speech.d.ts +31 -0
- package/dist/mimo-speech.js +127 -0
- package/package.json +1 -1
- package/src/client.ts +64 -5
- package/src/commands/balance.ts +5 -2
- package/src/commands/img-tools.ts +2 -1
- package/src/commands/poster-templates.ts +33 -0
- package/src/commands/speak.ts +54 -0
- package/src/commands/stickers.ts +29 -0
- package/src/commands/upload.ts +18 -2
- package/src/compress.ts +8 -0
- package/src/index.ts +47 -3
- package/src/local-bg.ts +66 -18
- package/src/mimo-speech.ts +139 -0
package/dist/local-bg.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 本地抠图(去背景)—— remove-bg 的核心实现。
|
|
3
|
-
* 模型走 ONNX(ISNet / U2Net,均 Apache-2.0),推理走 onnxruntime-node(MIT),
|
|
3
|
+
* 模型走 ONNX(ISNet / U2Net / BiRefNet-Lite,均 Apache-2.0),推理走 onnxruntime-node(MIT),
|
|
4
4
|
* 前后处理走 sharp —— 整条链路许可证干净(imgly 那个 npm 包是 AGPL,不进依赖),
|
|
5
5
|
* 且三个依赖在 macOS / Windows / Linux 都有预编译,无平台特化代码。
|
|
6
6
|
* 模型文件首次使用时下载到 ~/.museav-models/ 缓存(一次性 ~170MB)。
|
|
@@ -8,21 +8,45 @@
|
|
|
8
8
|
import { mkdir, writeFile, stat } from 'node:fs/promises';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
|
+
/**
|
|
12
|
+
* 每个模型的输入尺寸与归一化参数**都不一样**,必须随模型带着走。
|
|
13
|
+
* 取值对齐 rembg 的 session 定义(sessions/dis_general_use.py、sessions/u2net.py):
|
|
14
|
+
* isnet-general-use → resize 1024,mean (0.5,0.5,0.5) std (1,1,1)
|
|
15
|
+
* u2net → resize 320,mean ImageNet std ImageNet
|
|
16
|
+
* 用错任何一项都不会报错,只会安静地输出一张糊掉的 mask —— 这是最难查的那种 bug。
|
|
17
|
+
*/
|
|
11
18
|
export const BG_MODELS = {
|
|
12
19
|
isnet: {
|
|
13
20
|
file: 'isnet-general-use.onnx',
|
|
14
21
|
// rembg 官方 release 托管的同一份模型(Apache-2.0,源自 xuebinqin/DIS)
|
|
15
22
|
url: 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/isnet-general-use.onnx',
|
|
16
23
|
label: 'ISNet(通用,质量优先)',
|
|
24
|
+
edge: 1024,
|
|
25
|
+
mean: [0.5, 0.5, 0.5],
|
|
26
|
+
std: [1.0, 1.0, 1.0],
|
|
17
27
|
},
|
|
18
28
|
u2net: {
|
|
19
29
|
file: 'u2net.onnx',
|
|
20
30
|
url: 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx',
|
|
21
31
|
label: 'U2Net(经典通用)',
|
|
32
|
+
edge: 320,
|
|
33
|
+
mean: [0.485, 0.456, 0.406],
|
|
34
|
+
std: [0.229, 0.224, 0.225],
|
|
35
|
+
},
|
|
36
|
+
birefnet: {
|
|
37
|
+
file: 'birefnet-general-lite.onnx',
|
|
38
|
+
url: 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/BiRefNet-general-bb_swin_v1_tiny-epoch_232.onnx',
|
|
39
|
+
label: 'BiRefNet-Lite(细节最好,毛发/低对比度主体优先用它)',
|
|
40
|
+
edge: 1024,
|
|
41
|
+
mean: [0.485, 0.456, 0.406],
|
|
42
|
+
std: [0.229, 0.224, 0.225],
|
|
43
|
+
// BiRefNet 输出的是 logits,要先过 sigmoid 才是概率;ISNet/U2Net 的输出已经是 0-1 区间。
|
|
44
|
+
// 漏掉这步不会报错,只会得到一张几乎全是半透明的 mask。
|
|
45
|
+
sigmoid: true,
|
|
46
|
+
size: 214, // MB,下载提示用
|
|
22
47
|
},
|
|
23
48
|
};
|
|
24
49
|
const MODEL_DIR = join(homedir(), '.museav-models');
|
|
25
|
-
const INPUT_EDGE = 1024;
|
|
26
50
|
function modelPath(key) {
|
|
27
51
|
return join(MODEL_DIR, BG_MODELS[key].file);
|
|
28
52
|
}
|
|
@@ -39,7 +63,8 @@ export async function ensureBgModel(key) {
|
|
|
39
63
|
}
|
|
40
64
|
await mkdir(MODEL_DIR, { recursive: true });
|
|
41
65
|
const def = BG_MODELS[key];
|
|
42
|
-
|
|
66
|
+
const mb = 'size' in def ? `~${def.size}MB` : '~170MB';
|
|
67
|
+
process.stderr.write(`↓ 首次使用,下载 ${def.label}(${mb},一次性,缓存到 ${MODEL_DIR})...\n`);
|
|
43
68
|
const resp = await fetch(def.url);
|
|
44
69
|
if (!resp.ok || !resp.body)
|
|
45
70
|
throw new Error(`模型下载失败 HTTP ${resp.status}:${def.url}`);
|
|
@@ -80,30 +105,44 @@ export async function removeBackgroundLocal(inputPath, modelKey) {
|
|
|
80
105
|
const { data: rgb, info } = await sharp(inputPath).rotate().removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
81
106
|
if (info.channels !== 3)
|
|
82
107
|
throw new Error(`预处理得到 ${info.channels} 通道(预期 3)`);
|
|
83
|
-
// ──
|
|
108
|
+
// ── 模型输入:按该模型的 edge 拉伸,(x/max - mean)/std 归一化,HWC → CHW ──
|
|
109
|
+
const def = BG_MODELS[modelKey];
|
|
110
|
+
const edge = def.edge;
|
|
84
111
|
const small = await sharp(rgb, { raw: { width: info.width, height: info.height, channels: 3 } })
|
|
85
|
-
.resize(
|
|
112
|
+
.resize(edge, edge, { fit: 'fill', kernel: 'lanczos3' })
|
|
86
113
|
.raw()
|
|
87
114
|
.toBuffer();
|
|
88
|
-
|
|
89
|
-
|
|
115
|
+
// 分母是「这张图的最大像素值」而不是固定 255 —— 对齐 rembg 的 im_ary / max(im_ary)。
|
|
116
|
+
// 整体偏暗的图用 255 归一化会让输入分布整体偏小,mask 跟着糊。
|
|
117
|
+
let peak = 0;
|
|
118
|
+
for (let i = 0; i < small.length; i++)
|
|
119
|
+
if (small[i] > peak)
|
|
120
|
+
peak = small[i];
|
|
121
|
+
const scale = Math.max(peak, 1e-6);
|
|
122
|
+
const N = edge * edge;
|
|
123
|
+
const f32 = new Float32Array(3 * N);
|
|
90
124
|
for (let i = 0; i < N; i++) {
|
|
91
|
-
f32[i] = (small[i * 3] /
|
|
92
|
-
f32[N + i] = (small[i * 3 + 1] /
|
|
93
|
-
f32[2 * N + i] = (small[i * 3 + 2] /
|
|
125
|
+
f32[i] = (small[i * 3] / scale - def.mean[0]) / def.std[0];
|
|
126
|
+
f32[N + i] = (small[i * 3 + 1] / scale - def.mean[1]) / def.std[1];
|
|
127
|
+
f32[2 * N + i] = (small[i * 3 + 2] / scale - def.mean[2]) / def.std[2];
|
|
94
128
|
}
|
|
95
129
|
const feeds = {};
|
|
96
|
-
feeds[session.inputNames[0]] = new ort.Tensor('float32', f32, [1, 3,
|
|
130
|
+
feeds[session.inputNames[0]] = new ort.Tensor('float32', f32, [1, 3, edge, edge]);
|
|
97
131
|
const results = await session.run(feeds);
|
|
98
132
|
const out = results[session.outputNames[0]];
|
|
99
133
|
const maskFlat = out.data;
|
|
100
134
|
if (maskFlat.length < N)
|
|
101
135
|
throw new Error(`模型输出尺寸异常(${maskFlat.length})`);
|
|
102
|
-
// ──
|
|
136
|
+
// ── 后处理:(按模型)sigmoid → min-max 归一化到 0-255 → 缩回原图尺寸 ──
|
|
137
|
+
const needSigmoid = 'sigmoid' in def && def.sigmoid;
|
|
138
|
+
const prob = new Float32Array(N);
|
|
139
|
+
for (let i = 0; i < N; i++) {
|
|
140
|
+
prob[i] = needSigmoid ? 1 / (1 + Math.exp(-maskFlat[i])) : maskFlat[i];
|
|
141
|
+
}
|
|
103
142
|
let lo = Infinity;
|
|
104
143
|
let hi = -Infinity;
|
|
105
144
|
for (let i = 0; i < N; i++) {
|
|
106
|
-
const v =
|
|
145
|
+
const v = prob[i];
|
|
107
146
|
if (v < lo)
|
|
108
147
|
lo = v;
|
|
109
148
|
if (v > hi)
|
|
@@ -111,12 +150,22 @@ export async function removeBackgroundLocal(inputPath, modelKey) {
|
|
|
111
150
|
}
|
|
112
151
|
const range = hi - lo || 1;
|
|
113
152
|
const mask8 = Buffer.alloc(N);
|
|
114
|
-
for (let i = 0; i < N; i++)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
153
|
+
for (let i = 0; i < N; i++) {
|
|
154
|
+
const v = (prob[i] - lo) / range;
|
|
155
|
+
mask8[i] = Math.round(Math.min(1, Math.max(0, v)) * 255); // clip(0,1) 对齐 rembg
|
|
156
|
+
}
|
|
157
|
+
// ⚠️ sharp 对单通道 raw 做 resize 后会吐出 3 通道(灰度被展开成 RGB)。
|
|
158
|
+
// 按单通道去索引 maskFull[i] 就会以 1/3 的步长错位采样,输出一张隔行残影的图 ——
|
|
159
|
+
// 不报错、不崩,只是抠出来的东西是条纹状的。所以这里既强制灰度色彩空间,
|
|
160
|
+
// 又用实际返回的 channels 来索引,两道保险。
|
|
161
|
+
const { data: maskFull, info: maskInfo } = await sharp(mask8, {
|
|
162
|
+
raw: { width: edge, height: edge, channels: 1 },
|
|
163
|
+
})
|
|
164
|
+
.resize(info.width, info.height, { fit: 'fill', kernel: 'lanczos3' })
|
|
165
|
+
.toColourspace('b-w')
|
|
118
166
|
.raw()
|
|
119
|
-
.toBuffer();
|
|
167
|
+
.toBuffer({ resolveWithObject: true });
|
|
168
|
+
const maskStride = maskInfo.channels || 1;
|
|
120
169
|
// ── alpha 合成:直接构造 RGBA(alpha = mask),不依赖 composite 的混合语义 ──
|
|
121
170
|
const w = info.width;
|
|
122
171
|
const h = info.height;
|
|
@@ -125,7 +174,7 @@ export async function removeBackgroundLocal(inputPath, modelKey) {
|
|
|
125
174
|
rgba[i * 4] = rgb[i * 3];
|
|
126
175
|
rgba[i * 4 + 1] = rgb[i * 3 + 1];
|
|
127
176
|
rgba[i * 4 + 2] = rgb[i * 3 + 2];
|
|
128
|
-
rgba[i * 4 + 3] = maskFull[i];
|
|
177
|
+
rgba[i * 4 + 3] = maskFull[i * maskStride];
|
|
129
178
|
}
|
|
130
179
|
return sharp(rgba, { raw: { width: w, height: h, channels: 4 } }).png().toBuffer();
|
|
131
180
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** 默认预置音色。上游没有「列出音色」的接口,这个是文档给出且实测可用的 */
|
|
2
|
+
export declare const DEFAULT_VOICE = "Chloe";
|
|
3
|
+
export declare function mimoKey(): string;
|
|
4
|
+
export interface SpeakOptions {
|
|
5
|
+
/** 预置音色名(默认 Chloe)。与 design / clonePath 互斥 */
|
|
6
|
+
voice?: string;
|
|
7
|
+
/** 一句话描述音色 → 走音色设计 */
|
|
8
|
+
design?: string;
|
|
9
|
+
/** 音色样本音频路径 → 走音色克隆 */
|
|
10
|
+
clonePath?: string;
|
|
11
|
+
/** 风格/语气指令。三种模式都能用 */
|
|
12
|
+
instruction?: string;
|
|
13
|
+
}
|
|
14
|
+
/** 合成的三种模式。asr 不在里面——它是识别,不由 speechMode 决定 */
|
|
15
|
+
export type SpeechMode = 'tts' | 'design' | 'clone';
|
|
16
|
+
/** 用哪种模式,取决于给了什么参数——克隆 > 设计 > 预置音色 */
|
|
17
|
+
export declare function speechMode(opts: SpeakOptions): SpeechMode;
|
|
18
|
+
/**
|
|
19
|
+
* 合成语音,返回 WAV 数据。
|
|
20
|
+
* @param text 要读出来的文本
|
|
21
|
+
*/
|
|
22
|
+
export declare function synthesize(text: string, opts?: SpeakOptions): Promise<Buffer>;
|
|
23
|
+
/**
|
|
24
|
+
* 识别音频里的文字。
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ 质量有波动:同一段合成音频两次实测,一次「声影成诗,一念成相」(同音字级别),
|
|
27
|
+
* 一次「上庸城失,一面呈象」(整句都错)。别把结果直接当可信文本用在计费或入库口径上。
|
|
28
|
+
*/
|
|
29
|
+
export declare function transcribe(audioPath: string): Promise<string>;
|
|
30
|
+
/** WAV 时长(秒),用于给用户一个「出了多长」的反馈。头部损坏时返回 null 而不是抛错 */
|
|
31
|
+
export declare function wavSeconds(buf: Buffer): number | null;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 小米 MiMo 语音能力(合成 / 音色设计 / 音色克隆 / 识别)—— 直连上游,不经中台。
|
|
3
|
+
*
|
|
4
|
+
* 为什么直连:中台的出音链路还没接完(media_type=audio 的路由与落盘在做),而这批能力
|
|
5
|
+
* 目前只给内部用、不开放给租户。CLI 直连能立刻用上,也天然不会漏给租户——租户手里
|
|
6
|
+
* 没有这把 key。等中台接完再决定要不要把 CLI 切过去。
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ 协议层的真源是 museav-manager 的 `shared/mimo-audio.js`(那边有 11 个单测钉着)。
|
|
9
|
+
* 这里是 TS 副本,**改协议要同步两边**。复制而不是共享的原因:CLI 是独立发布的 npm 包,
|
|
10
|
+
* 跨仓 import 会把中台仓变成它的构建依赖。
|
|
11
|
+
*
|
|
12
|
+
* ## 四个反直觉的点(实测踩出来的,写错不会报错,只是拿不到音频)
|
|
13
|
+
*
|
|
14
|
+
* 1. 合成**不走 /v1/audio/speech**。OpenAI 那套音频端点这边一个都没有(试了七个全 404),
|
|
15
|
+
* 四种能力共用 `/v1/chat/completions`,靠 model 区分。
|
|
16
|
+
* 2. **待合成文本放 assistant 角色**,user 放音色指令。反过来写会得到一段「回答」而不是朗读。
|
|
17
|
+
* 3. 音频是 **base64** 回在 `message.audio.data`,不是二进制流。
|
|
18
|
+
* 4. 识别的输入音频在 user 的 content **数组**里(`type: 'input_audio'`),且要裸 base64。
|
|
19
|
+
*
|
|
20
|
+
* key 走 MIMO_API_KEY 环境变量(跟 OLLAMA_HOST / MUSEAV_LOCAL_VLM 一个路子),不进
|
|
21
|
+
* ~/.museav.json —— 那个文件存的是中台身份,跟这个上游是两回事。
|
|
22
|
+
*/
|
|
23
|
+
import { readFile } from 'node:fs/promises';
|
|
24
|
+
/** 专属 Base URL。换端点用 MIMO_BASE_URL,不用改代码 */
|
|
25
|
+
const BASE = (process.env.MIMO_BASE_URL || 'https://token-plan-cn.xiaomimimo.com/v1').replace(/\/+$/, '');
|
|
26
|
+
const MODELS = {
|
|
27
|
+
tts: 'mimo-v2.5-tts',
|
|
28
|
+
design: 'mimo-v2.5-tts-voicedesign',
|
|
29
|
+
clone: 'mimo-v2.5-tts-voiceclone',
|
|
30
|
+
asr: 'mimo-v2.5-asr',
|
|
31
|
+
};
|
|
32
|
+
/** 默认预置音色。上游没有「列出音色」的接口,这个是文档给出且实测可用的 */
|
|
33
|
+
export const DEFAULT_VOICE = 'Chloe';
|
|
34
|
+
export function mimoKey() {
|
|
35
|
+
const key = process.env.MIMO_API_KEY || '';
|
|
36
|
+
if (!key) {
|
|
37
|
+
throw new Error('缺少 MIMO_API_KEY。语音能力直连小米 MiMo,不走中台身份:\n'
|
|
38
|
+
+ ' export MIMO_API_KEY=... 或\n'
|
|
39
|
+
+ ' cs kyvault run --env MIMO_API_KEY=secret://mimo/api-key -- museav speak ...');
|
|
40
|
+
}
|
|
41
|
+
return key;
|
|
42
|
+
}
|
|
43
|
+
async function call(body) {
|
|
44
|
+
const res = await fetch(`${BASE}/chat/completions`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { Authorization: `Bearer ${mimoKey()}`, 'Content-Type': 'application/json' },
|
|
47
|
+
body: JSON.stringify(body),
|
|
48
|
+
});
|
|
49
|
+
const text = await res.text();
|
|
50
|
+
if (!res.ok)
|
|
51
|
+
throw new Error(`上游 HTTP ${res.status}:${text.slice(0, 200)}`);
|
|
52
|
+
let data;
|
|
53
|
+
try {
|
|
54
|
+
data = JSON.parse(text);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new Error(`上游返回不是 JSON:${text.slice(0, 200)}`);
|
|
58
|
+
}
|
|
59
|
+
if (data?.error)
|
|
60
|
+
throw new Error(data.error.message || String(data.error));
|
|
61
|
+
if (!Array.isArray(data?.choices) || !data.choices.length)
|
|
62
|
+
throw new Error('上游返回里没有 choices');
|
|
63
|
+
return data;
|
|
64
|
+
}
|
|
65
|
+
/** 用哪种模式,取决于给了什么参数——克隆 > 设计 > 预置音色 */
|
|
66
|
+
export function speechMode(opts) {
|
|
67
|
+
if (opts.clonePath)
|
|
68
|
+
return 'clone';
|
|
69
|
+
if (opts.design)
|
|
70
|
+
return 'design';
|
|
71
|
+
return 'tts';
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 合成语音,返回 WAV 数据。
|
|
75
|
+
* @param text 要读出来的文本
|
|
76
|
+
*/
|
|
77
|
+
export async function synthesize(text, opts = {}) {
|
|
78
|
+
const content = String(text || '').trim();
|
|
79
|
+
if (!content)
|
|
80
|
+
throw new Error('要合成的文本是空的');
|
|
81
|
+
const mode = speechMode(opts);
|
|
82
|
+
const messages = [];
|
|
83
|
+
// user 放指令:设计模式靠它定义音色,其余模式靠它调语气
|
|
84
|
+
const instruction = mode === 'design' ? opts.design : opts.instruction;
|
|
85
|
+
if (instruction)
|
|
86
|
+
messages.push({ role: 'user', content: instruction });
|
|
87
|
+
// 待合成文本必须是 assistant,见文件头第 2 条
|
|
88
|
+
messages.push({ role: 'assistant', content });
|
|
89
|
+
const audio = { format: 'wav' };
|
|
90
|
+
if (mode === 'clone') {
|
|
91
|
+
const buf = await readFile(opts.clonePath);
|
|
92
|
+
audio.voice = `data:audio/wav;base64,${buf.toString('base64')}`;
|
|
93
|
+
}
|
|
94
|
+
else if (mode === 'tts') {
|
|
95
|
+
audio.voice = opts.voice || DEFAULT_VOICE;
|
|
96
|
+
}
|
|
97
|
+
const data = await call({ model: MODELS[mode], messages, audio });
|
|
98
|
+
const b64 = data.choices[0]?.message?.audio?.data;
|
|
99
|
+
if (!b64)
|
|
100
|
+
throw new Error('上游没有返回音频(audio.data 为空)');
|
|
101
|
+
return Buffer.from(b64, 'base64');
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* 识别音频里的文字。
|
|
105
|
+
*
|
|
106
|
+
* ⚠️ 质量有波动:同一段合成音频两次实测,一次「声影成诗,一念成相」(同音字级别),
|
|
107
|
+
* 一次「上庸城失,一面呈象」(整句都错)。别把结果直接当可信文本用在计费或入库口径上。
|
|
108
|
+
*/
|
|
109
|
+
export async function transcribe(audioPath) {
|
|
110
|
+
const buf = await readFile(audioPath);
|
|
111
|
+
const format = /\.(wav|mp3|m4a|flac|ogg|pcm)$/i.exec(audioPath)?.[1]?.toLowerCase() || 'wav';
|
|
112
|
+
const data = await call({
|
|
113
|
+
model: MODELS.asr,
|
|
114
|
+
messages: [{ role: 'user', content: [{ type: 'input_audio', input_audio: { data: buf.toString('base64'), format } }] }],
|
|
115
|
+
});
|
|
116
|
+
const text = data.choices[0]?.message?.content;
|
|
117
|
+
if (typeof text !== 'string' || !text.trim())
|
|
118
|
+
throw new Error('上游没有返回识别文本');
|
|
119
|
+
return text.trim();
|
|
120
|
+
}
|
|
121
|
+
/** WAV 时长(秒),用于给用户一个「出了多长」的反馈。头部损坏时返回 null 而不是抛错 */
|
|
122
|
+
export function wavSeconds(buf) {
|
|
123
|
+
if (buf.length < 44 || buf.subarray(0, 4).toString() !== 'RIFF')
|
|
124
|
+
return null;
|
|
125
|
+
const byteRate = buf.readUInt32LE(28);
|
|
126
|
+
return byteRate > 0 ? (buf.length - 44) / byteRate : null;
|
|
127
|
+
}
|
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -320,8 +320,13 @@ export interface ModelOption {
|
|
|
320
320
|
}
|
|
321
321
|
|
|
322
322
|
export interface Balance {
|
|
323
|
-
/**
|
|
324
|
-
|
|
323
|
+
/** 余额(¥)。中台 2026-08-21 起发这个字段名 */
|
|
324
|
+
balance_cny?: number
|
|
325
|
+
/**
|
|
326
|
+
* 同一个数的旧字段名,中台仍在双发。字段名带 usd 纯属历史遗留,值一直是人民币——
|
|
327
|
+
* 中台侧不存在汇率换算。老版本 CLI 只认这个名字,所以中台不会立刻停发。
|
|
328
|
+
*/
|
|
329
|
+
balance_usd?: number
|
|
325
330
|
/** 租户加价率(0.2 = 加价 20%) */
|
|
326
331
|
markup_pct: number
|
|
327
332
|
checked_at: string
|
|
@@ -427,6 +432,47 @@ export class StudioClient {
|
|
|
427
432
|
return r.row
|
|
428
433
|
}
|
|
429
434
|
|
|
435
|
+
/** 贴图素材清单(租户级资产,服务端已按调用者权限过滤)。PNG 装饰图,叠加在海报/封面上 */
|
|
436
|
+
async stickers(): Promise<any[]> {
|
|
437
|
+
const r = await this.request('stickers')
|
|
438
|
+
return Array.isArray(r) ? r : []
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** 上传贴图素材(不压缩——透明 PNG 压缩会破坏透明通道) */
|
|
442
|
+
async createSticker(filePath: string, name: string): Promise<any> {
|
|
443
|
+
const blob = new Blob([new Uint8Array(readFileSync(filePath))])
|
|
444
|
+
const fd = new FormData()
|
|
445
|
+
fd.append('file', blob, basename(filePath))
|
|
446
|
+
fd.append('name', name)
|
|
447
|
+
return this.request('stickers', { method: 'POST', body: fd })
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** 删除贴图素材 */
|
|
451
|
+
async deleteSticker(id: string): Promise<void> {
|
|
452
|
+
await this.request(`stickers?id=${encodeURIComponent(id)}`, { method: 'DELETE' })
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** 版式模板清单(租户级资产):封面底图 + 固定描述({城市}{明星} 占位符) */
|
|
456
|
+
async posterTemplates(): Promise<any[]> {
|
|
457
|
+
const r = await this.request('poster-templates')
|
|
458
|
+
return Array.isArray(r) ? r : []
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** 上传版式模板(不压缩,保留封面底图原样) */
|
|
462
|
+
async createPosterTemplate(filePath: string, name: string, prompt: string): Promise<any> {
|
|
463
|
+
const blob = new Blob([new Uint8Array(readFileSync(filePath))])
|
|
464
|
+
const fd = new FormData()
|
|
465
|
+
fd.append('file', blob, basename(filePath))
|
|
466
|
+
fd.append('name', name)
|
|
467
|
+
fd.append('prompt', prompt)
|
|
468
|
+
return this.request('poster-templates', { method: 'POST', body: fd })
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** 删除版式模板 */
|
|
472
|
+
async deletePosterTemplate(id: string): Promise<void> {
|
|
473
|
+
await this.request(`poster-templates?id=${encodeURIComponent(id)}`, { method: 'DELETE' })
|
|
474
|
+
}
|
|
475
|
+
|
|
430
476
|
/** 提交出图任务,立即返回 jobId */
|
|
431
477
|
async generate(opts: GenerateOptions): Promise<{ jobId: string; trace_id?: string }> {
|
|
432
478
|
// prompt / skill_slug / template_id 三选一:都传时服务端按 prompt > template_id > skill_slug
|
|
@@ -624,9 +670,22 @@ export class StudioClient {
|
|
|
624
670
|
* 上传素材。图片会先压到视觉模型够用的尺寸再传(见 compress.ts)——
|
|
625
671
|
* 参考图是给模型看的,不是留档,原图直传只会拖慢上传和解析。
|
|
626
672
|
*/
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
673
|
+
/**
|
|
674
|
+
* 上传素材。默认只存文件、回直链(参考图/垫图就该这样)。
|
|
675
|
+
*
|
|
676
|
+
* asWork=true 时另外记一条作品:不落库的话文件只存在 R2 里,
|
|
677
|
+
* 作品页、后台画廊、项目归档全都看不见它 —— 「传到我的账户」就没发生。
|
|
678
|
+
* 只对账户身份生效(作品要归到具体某个人头上),租户 key 传了也会被中台忽略。
|
|
679
|
+
*/
|
|
680
|
+
async uploadRef(
|
|
681
|
+
filePath: string,
|
|
682
|
+
opts: { asWork?: boolean; workspaceId?: string } = {},
|
|
683
|
+
): Promise<{ url: string; media_type?: string; mime?: string; job_id?: string | null }> {
|
|
684
|
+
const form = await fileForm(filePath)
|
|
685
|
+
if (opts.asWork) form.append('as_work', '1')
|
|
686
|
+
if (opts.workspaceId) form.append('workspace_id', opts.workspaceId)
|
|
687
|
+
const r = await this.request('upload-ref', { method: 'POST', body: form })
|
|
688
|
+
return { url: r.url, media_type: r.media_type, mime: r.mime, job_id: r.job_id ?? null }
|
|
630
689
|
}
|
|
631
690
|
|
|
632
691
|
/**
|
package/src/commands/balance.ts
CHANGED
|
@@ -3,8 +3,11 @@ import type { StudioClient } from '../client.js'
|
|
|
3
3
|
|
|
4
4
|
export async function balance(client: StudioClient): Promise<void> {
|
|
5
5
|
const r = await client.balance()
|
|
6
|
-
// 单位 ¥ 人民币(后台 2026-08-09
|
|
7
|
-
|
|
6
|
+
// 单位 ¥ 人民币(后台 2026-08-09 起只返回租户自己的余额,不再下发上游供应商聚合数据)。
|
|
7
|
+
// 优先读 balance_cny:中台已改用这个名字,balance_usd 是双发过渡期的旧名,
|
|
8
|
+
// 两个值永远相等,但等中台停发旧名时这里不用再改一次。
|
|
9
|
+
const cny = r.balance_cny ?? r.balance_usd
|
|
10
|
+
process.stderr.write(`余额: ¥${cny?.toFixed(2) ?? '?'}`)
|
|
8
11
|
if (r.markup_pct) process.stderr.write(` 加价率: ${(r.markup_pct * 100).toFixed(0)}%`)
|
|
9
12
|
if (r.checked_at) process.stderr.write(` 校验时间: ${r.checked_at.slice(0, 19).replace('T', ' ')}`)
|
|
10
13
|
process.stderr.write('\n')
|
|
@@ -82,7 +82,8 @@ export interface RemoveBgOpts {
|
|
|
82
82
|
|
|
83
83
|
export async function removeBgCmd(input: string, opts: RemoveBgOpts): Promise<void> {
|
|
84
84
|
if (!(await fileExists(input))) throw new Error(`文件不存在: ${input}`)
|
|
85
|
-
|
|
85
|
+
// 默认 birefnet:实测对毛发、白色主体、低对比度背景的召回远好于 isnet/u2net
|
|
86
|
+
const modelKey = (opts.model || 'birefnet') as BgModelKey
|
|
86
87
|
if (!(modelKey in BG_MODELS)) throw new Error(`--model 只支持 ${Object.keys(BG_MODELS).join(' / ')}`)
|
|
87
88
|
|
|
88
89
|
const start = Date.now()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* museav poster-templates —— 版式模板库(租户级资产)。
|
|
3
|
+
* 封面底图 + 固定描述({城市} {明星} 占位符),选版式时把城市/明星名填进底图。
|
|
4
|
+
* 学员做的封面版式保存后全租户共享。
|
|
5
|
+
*/
|
|
6
|
+
import type { StudioClient } from '../client.js'
|
|
7
|
+
|
|
8
|
+
export async function posterTemplates(client: StudioClient): Promise<void> {
|
|
9
|
+
const list = await client.posterTemplates()
|
|
10
|
+
if (!list.length) {
|
|
11
|
+
process.stderr.write('没有版式模板(可用 museav poster-templates add <底图> --name <名称> --prompt <描述> 保存)\n')
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
process.stderr.write(`版式模板(${list.length} 个):\n`)
|
|
15
|
+
for (const t of list) {
|
|
16
|
+
process.stderr.write(` ${t.id.padEnd(38)} ${String(t.name || '').padEnd(18)} ${String(t.prompt || '').slice(0, 40)}\n`)
|
|
17
|
+
}
|
|
18
|
+
process.stderr.write(`\n保存: museav poster-templates add <底图路径> --name <名称> --prompt <描述>\n`)
|
|
19
|
+
console.log(list.map((t) => t.id).join('\n'))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function createPosterTemplate(
|
|
23
|
+
client: StudioClient,
|
|
24
|
+
filePath: string,
|
|
25
|
+
opts: { name: string; prompt: string },
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
if (!opts.name?.trim()) throw new Error('--name 必填')
|
|
28
|
+
if (!opts.prompt?.trim()) throw new Error('--prompt 必填,{城市} {明星} 占位符会被替换')
|
|
29
|
+
const row = await client.createPosterTemplate(filePath, opts.name, opts.prompt)
|
|
30
|
+
const id = row?.template?.id || row?.id || ''
|
|
31
|
+
process.stderr.write(`✅ 版式模板已保存:${id}\n`)
|
|
32
|
+
console.log(id)
|
|
33
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* museav speak —— 文本转语音,stdout 输出生成的文件路径。
|
|
3
|
+
* museav transcribe —— 语音转文本,stdout 输出识别结果。
|
|
4
|
+
*
|
|
5
|
+
* 三种音色来源,给了什么参数就走哪条:
|
|
6
|
+
* 默认 预置音色(--voice Chloe)
|
|
7
|
+
* --design 一句话描述音色,当场造一个
|
|
8
|
+
* --clone 拿一段音频当样本,克隆它的音色
|
|
9
|
+
*
|
|
10
|
+
* 直连小米 MiMo,不经中台,需要 MIMO_API_KEY —— 原因见 src/mimo-speech.ts 的文件头。
|
|
11
|
+
*/
|
|
12
|
+
import { writeFile } from 'node:fs/promises'
|
|
13
|
+
import { basename, resolve } from 'node:path'
|
|
14
|
+
import { synthesize, transcribe, speechMode, wavSeconds, DEFAULT_VOICE } from '../mimo-speech.js'
|
|
15
|
+
|
|
16
|
+
const MODE_LABEL = { tts: '预置音色', design: '音色设计', clone: '音色克隆' } as const
|
|
17
|
+
|
|
18
|
+
export interface SpeakCliOptions {
|
|
19
|
+
out?: string
|
|
20
|
+
voice?: string
|
|
21
|
+
design?: string
|
|
22
|
+
clone?: string
|
|
23
|
+
instruction?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function speak(text: string, opts: SpeakCliOptions = {}): Promise<void> {
|
|
27
|
+
const mode = speechMode({ clonePath: opts.clone, design: opts.design })
|
|
28
|
+
// 克隆模式下 opts.clone 是整条路径,进度行里只留文件名——绝对路径会把这行顶到换行
|
|
29
|
+
const detail = mode === 'tts' ? (opts.voice || DEFAULT_VOICE)
|
|
30
|
+
: mode === 'clone' ? basename(opts.clone || '') : (opts.design || '')
|
|
31
|
+
process.stderr.write(`合成中(${MODE_LABEL[mode]}${detail ? ` · ${detail}` : ''})...\n`)
|
|
32
|
+
|
|
33
|
+
const buf = await synthesize(text, {
|
|
34
|
+
voice: opts.voice,
|
|
35
|
+
design: opts.design,
|
|
36
|
+
clonePath: opts.clone,
|
|
37
|
+
instruction: opts.instruction,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// 默认落在当前目录,文件名带时间戳避免连续合成互相覆盖
|
|
41
|
+
const out = resolve(opts.out || `speech-${Date.now()}.wav`)
|
|
42
|
+
await writeFile(out, buf)
|
|
43
|
+
const secs = wavSeconds(buf)
|
|
44
|
+
process.stderr.write(`✅ ${(buf.length / 1024).toFixed(0)}KB${secs ? ` · ${secs.toFixed(2)}s` : ''}\n`)
|
|
45
|
+
console.log(out)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function transcribeCmd(audioPath: string): Promise<void> {
|
|
49
|
+
process.stderr.write(`识别中 ${audioPath} ...\n`)
|
|
50
|
+
const text = await transcribe(audioPath)
|
|
51
|
+
// 质量有波动(见 mimo-speech.ts 的注释),提醒一句,但不影响 stdout 的机器可读性
|
|
52
|
+
process.stderr.write('✅ 识别完成(同音字可能有误,重要场景请核对)\n')
|
|
53
|
+
console.log(text)
|
|
54
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* museav stickers —— 贴图素材库(租户级资产)。
|
|
3
|
+
* PNG 透明装饰图(logo/花纹/边框),叠加在海报/封面上。
|
|
4
|
+
* 列出的都是自己租户的贴图;上传用账户 Key 或租户 Key 均可(落到本租户)。
|
|
5
|
+
*/
|
|
6
|
+
import type { StudioClient } from '../client.js'
|
|
7
|
+
|
|
8
|
+
export async function stickers(client: StudioClient): Promise<void> {
|
|
9
|
+
const list = await client.stickers()
|
|
10
|
+
if (!list.length) {
|
|
11
|
+
process.stderr.write('没有贴图素材(可用 museav stickers add <图片> --name <名称> 上传)\n')
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
process.stderr.write(`贴图素材(${list.length} 个):\n`)
|
|
15
|
+
for (const s of list) {
|
|
16
|
+
process.stderr.write(` ${s.id.padEnd(38)} ${String(s.name || '').padEnd(20)} ${s.url || ''}\n`)
|
|
17
|
+
}
|
|
18
|
+
process.stderr.write(`\n上传: museav stickers add <图片路径> --name <名称>\n`)
|
|
19
|
+
// stdout 只出 id,便于脚本与 agent 解析
|
|
20
|
+
console.log(list.map((s) => s.id).join('\n'))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function createSticker(client: StudioClient, filePath: string, opts: { name: string }): Promise<void> {
|
|
24
|
+
if (!opts.name?.trim()) throw new Error('--name 必填')
|
|
25
|
+
const row = await client.createSticker(filePath, opts.name)
|
|
26
|
+
const id = row?.sticker?.id || row?.id || ''
|
|
27
|
+
process.stderr.write(`✅ 贴图素材已上传:${id}\n`)
|
|
28
|
+
console.log(id)
|
|
29
|
+
}
|
package/src/commands/upload.ts
CHANGED
|
@@ -9,10 +9,26 @@ import type { StudioClient } from '../client.js'
|
|
|
9
9
|
|
|
10
10
|
const KIND_LABEL: Record<string, string> = { image: '图片', audio: '音频', video: '视频' }
|
|
11
11
|
|
|
12
|
-
export async function upload(
|
|
12
|
+
export async function upload(
|
|
13
|
+
client: StudioClient,
|
|
14
|
+
filePath: string,
|
|
15
|
+
opts: { toWorks?: boolean; workspace?: string } = {},
|
|
16
|
+
): Promise<void> {
|
|
13
17
|
process.stderr.write(`上传 ${filePath} ...\n`)
|
|
14
|
-
const { url, media_type, mime } = await client.uploadRef(filePath
|
|
18
|
+
const { url, media_type, mime, job_id } = await client.uploadRef(filePath, {
|
|
19
|
+
asWork: opts.toWorks,
|
|
20
|
+
workspaceId: opts.workspace,
|
|
21
|
+
})
|
|
15
22
|
const kind = media_type ? `${KIND_LABEL[media_type] || media_type}${mime ? ` · ${mime}` : ''}` : ''
|
|
16
23
|
process.stderr.write(`✅ 上传成功${kind ? `(${kind})` : ''}\n`)
|
|
24
|
+
if (opts.toWorks) {
|
|
25
|
+
// 说清有没有真的进作品库:租户 key 调用时中台不记作品,只提示「已上传」会让人以为进去了
|
|
26
|
+
process.stderr.write(job_id
|
|
27
|
+
? '📁 已收进你的作品库,在「我的作品」里能看到\n'
|
|
28
|
+
: '⚠️ 文件已上传,但没能记进作品库(租户 Key 调用不记作品,作品要归到具体账户)\n')
|
|
29
|
+
} else if (media_type === 'video') {
|
|
30
|
+
// 传视频十有八九是想收成品,顺手提一句 —— 但不擅自替他决定
|
|
31
|
+
process.stderr.write('提示:加 --to-works 可以把它收进「我的作品」\n')
|
|
32
|
+
}
|
|
17
33
|
console.log(url)
|
|
18
34
|
}
|
package/src/compress.ts
CHANGED
|
@@ -59,6 +59,14 @@ export async function compressForVision(filePath: string): Promise<CompressResul
|
|
|
59
59
|
try {
|
|
60
60
|
meta = await sharp(filePath).metadata()
|
|
61
61
|
} catch {
|
|
62
|
+
// HEIC 解码失败会走到这里(libheif 安全检查拒绝),但它不是「视频/音频」——
|
|
63
|
+
// 原样上传会被中台按魔数判成 image/heic,而 gpt-image-2 上游解码不了 HEIC
|
|
64
|
+
// 字节 → 400 invalid_image_input。与其等到出图时才炸,不如上传前就报清楚。
|
|
65
|
+
// 判断依据:文件名扩展名 .heic/.heif/.avif 最可靠(HEIC 与 MP4 共享 ftyp 容器头,
|
|
66
|
+
// 不能靠字节区分;sharp 读不了恰恰说明它是图不是视频——视频不喂 sharp 走这里)。
|
|
67
|
+
if (/\.(heic|heif|avif)$/i.test(name)) {
|
|
68
|
+
throw new Error(`HEIC 图片 ${name} 无法直接上传:上游模型认不得 HEIC 字节。请先转成 JPG/PNG 再上传(macOS 可用「预览」打开后另存为 JPEG,或用 museav img min 转换)`)
|
|
69
|
+
}
|
|
62
70
|
return { buffer: null, filename: name, note: '' } // 不是 sharp 认识的图(视频/音频)→ 原样传
|
|
63
71
|
}
|
|
64
72
|
|