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/src/index.ts CHANGED
@@ -28,6 +28,9 @@ import { jobs } from './commands/jobs.js'
28
28
  import { whoami } from './commands/whoami.js'
29
29
  import { products } from './commands/products.js'
30
30
  import { assets } from './commands/assets.js'
31
+ import { stickers, createSticker } from './commands/stickers.js'
32
+ import { posterTemplates, createPosterTemplate } from './commands/poster-templates.js'
33
+ import { speak, transcribeCmd } from './commands/speak.js'
31
34
 
32
35
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')) as { name: string; version: string }
33
36
  // 每 12 小时最多查一次 npm registry,过期才提示,不拖慢日常调用
@@ -184,9 +187,9 @@ program
184
187
 
185
188
  program
186
189
  .command('remove-bg <file>')
187
- .description('本地抠图去背景(ISNet/U2Net + onnxruntime,免登录):输出带 alpha 的 PNG。首次使用自动下载模型(~170MB,缓存 ~/.museav-models)')
190
+ .description('本地抠图去背景(BiRefNet/ISNet/U2Net + onnxruntime,免登录):输出带 alpha 的 PNG。首次使用自动下载模型(缓存 ~/.museav-models)')
188
191
  .option('--out <path>', '输出路径(默认 <名>-nobg.png)')
189
- .option('--model <name>', 'isnet(默认,质量优先)/ u2net')
192
+ .option('--model <name>', 'birefnet(默认,细节最好,~214MB)/ isnet / u2net')
190
193
  .option('--overwrite', '允许覆盖已存在的输出文件')
191
194
  .action(asyncRun((input: string, opts: any) => removeBgCmd(input, opts)))
192
195
 
@@ -263,7 +266,25 @@ program
263
266
  program
264
267
  .command('upload <file>')
265
268
  .description('上传素材(图片/音频/视频,按字节内容判类型;图片 8MB / 音频 20MB / 视频 50MB),stdout 输出公网直链')
266
- .action(withClient((client: StudioClient, file: string) => upload(client, file)))
269
+ .option('--to-works', '同时收进「我的作品」(在外面做好的成品视频/图片用这个;参考图不用)')
270
+ .option('--workspace <id>', '归档到指定项目')
271
+ .action(withClient((client: StudioClient, file: string, opts: { toWorks?: boolean; workspace?: string }) =>
272
+ upload(client, file, { toWorks: opts.toWorks, workspace: opts.workspace })))
273
+
274
+ program
275
+ .command('speak <text>')
276
+ .description('文本转语音(小米 MiMo,直连上游需 MIMO_API_KEY,不走中台身份):stdout 输出 wav 路径')
277
+ .option('--out <path>', '输出路径(默认 speech-<时间戳>.wav)')
278
+ .option('--voice <name>', '预置音色,默认 Chloe')
279
+ .option('--design <desc>', '一句话描述音色,当场造一个(如「低沉沙哑的中年男声」)')
280
+ .option('--clone <file>', '拿这段音频当样本,克隆它的音色')
281
+ .option('--instruction <text>', '语气/风格指令(三种模式都可用)')
282
+ .action(asyncRun((text: string, opts: any) => speak(text, opts)))
283
+
284
+ program
285
+ .command('transcribe <audio>')
286
+ .description('语音转文本(小米 MiMo,需 MIMO_API_KEY):stdout 输出识别结果。同音字可能有误,重要场景请核对')
287
+ .action(asyncRun((audio: string) => transcribeCmd(audio)))
267
288
 
268
289
  program
269
290
  .command('models')
@@ -326,6 +347,29 @@ templatesCmd
326
347
  .option('--type <type>', '模板类型:image(图片,默认) / article(文字)')
327
348
  .action(withClient((client: StudioClient, opts: any) => createTemplate(client, opts)))
328
349
 
350
+ const stickersCmd = program
351
+ .command('stickers')
352
+ .description('查贴图素材库(租户级资产:PNG 装饰图,叠加在海报/封面上)')
353
+ .action(withClient((client: StudioClient) => stickers(client)))
354
+
355
+ stickersCmd
356
+ .command('add <file>')
357
+ .description('上传贴图素材(PNG 透明装饰图,不压缩保留透明通道)')
358
+ .requiredOption('--name <名称>', '贴图名称')
359
+ .action(withClient((client: StudioClient, file: string, opts: any) => createSticker(client, file, opts)))
360
+
361
+ const posterTemplatesCmd = program
362
+ .command('poster-templates')
363
+ .description('查版式模板库(租户级资产:封面底图 + 固定描述,选版式把城市/明星名填进底图)')
364
+ .action(withClient((client: StudioClient) => posterTemplates(client)))
365
+
366
+ posterTemplatesCmd
367
+ .command('add <file>')
368
+ .description('保存版式模板(封面底图 + 描述,描述里用 {城市} {明星} 占位)')
369
+ .requiredOption('--name <名称>', '版式名称')
370
+ .requiredOption('--prompt <描述>', '版式固定描述,{城市} {明星} 会自动替换')
371
+ .action(withClient((client: StudioClient, file: string, opts: any) => createPosterTemplate(client, file, opts)))
372
+
329
373
  program
330
374
  .command('products')
331
375
  .description('查所属租户自己的产品目录(数据在租户自己的后台,不在 Studio 中台;只支持已开通该接口的租户,仅租户 apiKey 身份可用)')
package/src/local-bg.ts 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)。
@@ -9,24 +9,48 @@ import { mkdir, writeFile, stat } from 'node:fs/promises'
9
9
  import { join } from 'node:path'
10
10
  import { homedir } from 'node:os'
11
11
 
12
+ /**
13
+ * 每个模型的输入尺寸与归一化参数**都不一样**,必须随模型带着走。
14
+ * 取值对齐 rembg 的 session 定义(sessions/dis_general_use.py、sessions/u2net.py):
15
+ * isnet-general-use → resize 1024,mean (0.5,0.5,0.5) std (1,1,1)
16
+ * u2net → resize 320,mean ImageNet std ImageNet
17
+ * 用错任何一项都不会报错,只会安静地输出一张糊掉的 mask —— 这是最难查的那种 bug。
18
+ */
12
19
  export const BG_MODELS = {
13
20
  isnet: {
14
21
  file: 'isnet-general-use.onnx',
15
22
  // rembg 官方 release 托管的同一份模型(Apache-2.0,源自 xuebinqin/DIS)
16
23
  url: 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/isnet-general-use.onnx',
17
24
  label: 'ISNet(通用,质量优先)',
25
+ edge: 1024,
26
+ mean: [0.5, 0.5, 0.5] as const,
27
+ std: [1.0, 1.0, 1.0] as const,
18
28
  },
19
29
  u2net: {
20
30
  file: 'u2net.onnx',
21
31
  url: 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx',
22
32
  label: 'U2Net(经典通用)',
33
+ edge: 320,
34
+ mean: [0.485, 0.456, 0.406] as const,
35
+ std: [0.229, 0.224, 0.225] as const,
36
+ },
37
+ birefnet: {
38
+ file: 'birefnet-general-lite.onnx',
39
+ url: 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/BiRefNet-general-bb_swin_v1_tiny-epoch_232.onnx',
40
+ label: 'BiRefNet-Lite(细节最好,毛发/低对比度主体优先用它)',
41
+ edge: 1024,
42
+ mean: [0.485, 0.456, 0.406] as const,
43
+ std: [0.229, 0.224, 0.225] as const,
44
+ // BiRefNet 输出的是 logits,要先过 sigmoid 才是概率;ISNet/U2Net 的输出已经是 0-1 区间。
45
+ // 漏掉这步不会报错,只会得到一张几乎全是半透明的 mask。
46
+ sigmoid: true,
47
+ size: 214, // MB,下载提示用
23
48
  },
24
49
  } as const
25
50
 
26
51
  export type BgModelKey = keyof typeof BG_MODELS
27
52
 
28
53
  const MODEL_DIR = join(homedir(), '.museav-models')
29
- const INPUT_EDGE = 1024
30
54
 
31
55
  function modelPath(key: BgModelKey): string {
32
56
  return join(MODEL_DIR, BG_MODELS[key].file)
@@ -43,7 +67,8 @@ export async function ensureBgModel(key: BgModelKey): Promise<string> {
43
67
  }
44
68
  await mkdir(MODEL_DIR, { recursive: true })
45
69
  const def = BG_MODELS[key]
46
- process.stderr.write(`↓ 首次使用,下载 ${def.label}(~170MB,一次性,缓存到 ${MODEL_DIR})...\n`)
70
+ const mb = 'size' in def ? `~${def.size}MB` : '~170MB'
71
+ process.stderr.write(`↓ 首次使用,下载 ${def.label}(${mb},一次性,缓存到 ${MODEL_DIR})...\n`)
47
72
  const resp = await fetch(def.url)
48
73
  if (!resp.ok || !resp.body) throw new Error(`模型下载失败 HTTP ${resp.status}:${def.url}`)
49
74
  const total = Number(resp.headers.get('content-length') || 0)
@@ -81,40 +106,63 @@ export async function removeBackgroundLocal(inputPath: string, modelKey: BgModel
81
106
  const { data: rgb, info } = await sharp(inputPath).rotate().removeAlpha().raw().toBuffer({ resolveWithObject: true })
82
107
  if (info.channels !== 3) throw new Error(`预处理得到 ${info.channels} 通道(预期 3)`)
83
108
 
84
- // ── 模型输入:拉伸到 1024×1024,(x/255 - 0.5)/0.5 归一化,HWC → CHW ──
109
+ // ── 模型输入:按该模型的 edge 拉伸,(x/max - mean)/std 归一化,HWC → CHW ──
110
+ const def = BG_MODELS[modelKey]
111
+ const edge = def.edge
85
112
  const small = await sharp(rgb, { raw: { width: info.width, height: info.height, channels: 3 } })
86
- .resize(INPUT_EDGE, INPUT_EDGE, { fit: 'fill' })
113
+ .resize(edge, edge, { fit: 'fill', kernel: 'lanczos3' })
87
114
  .raw()
88
115
  .toBuffer()
89
- const f32 = new Float32Array(3 * INPUT_EDGE * INPUT_EDGE)
90
- const N = INPUT_EDGE * INPUT_EDGE
116
+ // 分母是「这张图的最大像素值」而不是固定 255 —— 对齐 rembg im_ary / max(im_ary)
117
+ // 整体偏暗的图用 255 归一化会让输入分布整体偏小,mask 跟着糊。
118
+ let peak = 0
119
+ for (let i = 0; i < small.length; i++) if (small[i] > peak) peak = small[i]
120
+ const scale = Math.max(peak, 1e-6)
121
+ const N = edge * edge
122
+ const f32 = new Float32Array(3 * N)
91
123
  for (let i = 0; i < N; i++) {
92
- f32[i] = (small[i * 3] / 255 - 0.5) / 0.5
93
- f32[N + i] = (small[i * 3 + 1] / 255 - 0.5) / 0.5
94
- f32[2 * N + i] = (small[i * 3 + 2] / 255 - 0.5) / 0.5
124
+ f32[i] = (small[i * 3] / scale - def.mean[0]) / def.std[0]
125
+ f32[N + i] = (small[i * 3 + 1] / scale - def.mean[1]) / def.std[1]
126
+ f32[2 * N + i] = (small[i * 3 + 2] / scale - def.mean[2]) / def.std[2]
95
127
  }
96
128
  const feeds: Record<string, import('onnxruntime-node').Tensor> = {}
97
- feeds[session.inputNames[0]] = new ort.Tensor('float32', f32, [1, 3, INPUT_EDGE, INPUT_EDGE])
129
+ feeds[session.inputNames[0]] = new ort.Tensor('float32', f32, [1, 3, edge, edge])
98
130
  const results = await session.run(feeds)
99
131
  const out = results[session.outputNames[0]]
100
132
  const maskFlat = out.data as Float32Array
101
133
  if (maskFlat.length < N) throw new Error(`模型输出尺寸异常(${maskFlat.length})`)
102
134
 
103
- // ── 后处理:min-max 归一化到 0-255,再缩回原图尺寸 ──
135
+ // ── 后处理:(按模型)sigmoid → min-max 归一化到 0-255 → 缩回原图尺寸 ──
136
+ const needSigmoid = 'sigmoid' in def && def.sigmoid
137
+ const prob = new Float32Array(N)
138
+ for (let i = 0; i < N; i++) {
139
+ prob[i] = needSigmoid ? 1 / (1 + Math.exp(-maskFlat[i])) : maskFlat[i]
140
+ }
104
141
  let lo = Infinity
105
142
  let hi = -Infinity
106
143
  for (let i = 0; i < N; i++) {
107
- const v = maskFlat[i]
144
+ const v = prob[i]
108
145
  if (v < lo) lo = v
109
146
  if (v > hi) hi = v
110
147
  }
111
148
  const range = hi - lo || 1
112
149
  const mask8 = Buffer.alloc(N)
113
- for (let i = 0; i < N; i++) mask8[i] = Math.round(((maskFlat[i] - lo) / range) * 255)
114
- const maskFull = await sharp(mask8, { raw: { width: INPUT_EDGE, height: INPUT_EDGE, channels: 1 } })
115
- .resize(info.width, info.height, { fit: 'fill' })
150
+ for (let i = 0; i < N; i++) {
151
+ const v = (prob[i] - lo) / range
152
+ mask8[i] = Math.round(Math.min(1, Math.max(0, v)) * 255) // clip(0,1) 对齐 rembg
153
+ }
154
+ // ⚠️ sharp 对单通道 raw 做 resize 后会吐出 3 通道(灰度被展开成 RGB)。
155
+ // 按单通道去索引 maskFull[i] 就会以 1/3 的步长错位采样,输出一张隔行残影的图 ——
156
+ // 不报错、不崩,只是抠出来的东西是条纹状的。所以这里既强制灰度色彩空间,
157
+ // 又用实际返回的 channels 来索引,两道保险。
158
+ const { data: maskFull, info: maskInfo } = await sharp(mask8, {
159
+ raw: { width: edge, height: edge, channels: 1 },
160
+ })
161
+ .resize(info.width, info.height, { fit: 'fill', kernel: 'lanczos3' })
162
+ .toColourspace('b-w')
116
163
  .raw()
117
- .toBuffer()
164
+ .toBuffer({ resolveWithObject: true })
165
+ const maskStride = maskInfo.channels || 1
118
166
 
119
167
  // ── alpha 合成:直接构造 RGBA(alpha = mask),不依赖 composite 的混合语义 ──
120
168
  const w = info.width
@@ -124,7 +172,7 @@ export async function removeBackgroundLocal(inputPath: string, modelKey: BgModel
124
172
  rgba[i * 4] = rgb[i * 3]
125
173
  rgba[i * 4 + 1] = rgb[i * 3 + 1]
126
174
  rgba[i * 4 + 2] = rgb[i * 3 + 2]
127
- rgba[i * 4 + 3] = maskFull[i]
175
+ rgba[i * 4 + 3] = maskFull[i * maskStride]
128
176
  }
129
177
  return sharp(rgba, { raw: { width: w, height: h, channels: 4 } }).png().toBuffer()
130
178
  }
@@ -0,0 +1,139 @@
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
+
25
+ /** 专属 Base URL。换端点用 MIMO_BASE_URL,不用改代码 */
26
+ const BASE = (process.env.MIMO_BASE_URL || 'https://token-plan-cn.xiaomimimo.com/v1').replace(/\/+$/, '')
27
+
28
+ const MODELS = {
29
+ tts: 'mimo-v2.5-tts',
30
+ design: 'mimo-v2.5-tts-voicedesign',
31
+ clone: 'mimo-v2.5-tts-voiceclone',
32
+ asr: 'mimo-v2.5-asr',
33
+ } as const
34
+
35
+ /** 默认预置音色。上游没有「列出音色」的接口,这个是文档给出且实测可用的 */
36
+ export const DEFAULT_VOICE = 'Chloe'
37
+
38
+ export function mimoKey(): string {
39
+ const key = process.env.MIMO_API_KEY || ''
40
+ if (!key) {
41
+ throw new Error(
42
+ '缺少 MIMO_API_KEY。语音能力直连小米 MiMo,不走中台身份:\n'
43
+ + ' export MIMO_API_KEY=... 或\n'
44
+ + ' cs kyvault run --env MIMO_API_KEY=secret://mimo/api-key -- museav speak ...',
45
+ )
46
+ }
47
+ return key
48
+ }
49
+
50
+ async function call(body: unknown): Promise<any> {
51
+ const res = await fetch(`${BASE}/chat/completions`, {
52
+ method: 'POST',
53
+ headers: { Authorization: `Bearer ${mimoKey()}`, 'Content-Type': 'application/json' },
54
+ body: JSON.stringify(body),
55
+ })
56
+ const text = await res.text()
57
+ if (!res.ok) throw new Error(`上游 HTTP ${res.status}:${text.slice(0, 200)}`)
58
+ let data: any
59
+ try { data = JSON.parse(text) } catch { throw new Error(`上游返回不是 JSON:${text.slice(0, 200)}`) }
60
+ if (data?.error) throw new Error(data.error.message || String(data.error))
61
+ if (!Array.isArray(data?.choices) || !data.choices.length) throw new Error('上游返回里没有 choices')
62
+ return data
63
+ }
64
+
65
+ export interface SpeakOptions {
66
+ /** 预置音色名(默认 Chloe)。与 design / clonePath 互斥 */
67
+ voice?: string
68
+ /** 一句话描述音色 → 走音色设计 */
69
+ design?: string
70
+ /** 音色样本音频路径 → 走音色克隆 */
71
+ clonePath?: string
72
+ /** 风格/语气指令。三种模式都能用 */
73
+ instruction?: string
74
+ }
75
+
76
+ /** 合成的三种模式。asr 不在里面——它是识别,不由 speechMode 决定 */
77
+ export type SpeechMode = 'tts' | 'design' | 'clone'
78
+
79
+ /** 用哪种模式,取决于给了什么参数——克隆 > 设计 > 预置音色 */
80
+ export function speechMode(opts: SpeakOptions): SpeechMode {
81
+ if (opts.clonePath) return 'clone'
82
+ if (opts.design) return 'design'
83
+ return 'tts'
84
+ }
85
+
86
+ /**
87
+ * 合成语音,返回 WAV 数据。
88
+ * @param text 要读出来的文本
89
+ */
90
+ export async function synthesize(text: string, opts: SpeakOptions = {}): Promise<Buffer> {
91
+ const content = String(text || '').trim()
92
+ if (!content) throw new Error('要合成的文本是空的')
93
+
94
+ const mode = speechMode(opts)
95
+ const messages: Array<{ role: string; content: string }> = []
96
+ // user 放指令:设计模式靠它定义音色,其余模式靠它调语气
97
+ const instruction = mode === 'design' ? opts.design : opts.instruction
98
+ if (instruction) messages.push({ role: 'user', content: instruction })
99
+ // 待合成文本必须是 assistant,见文件头第 2 条
100
+ messages.push({ role: 'assistant', content })
101
+
102
+ const audio: Record<string, string> = { format: 'wav' }
103
+ if (mode === 'clone') {
104
+ const buf = await readFile(opts.clonePath as string)
105
+ audio.voice = `data:audio/wav;base64,${buf.toString('base64')}`
106
+ } else if (mode === 'tts') {
107
+ audio.voice = opts.voice || DEFAULT_VOICE
108
+ }
109
+
110
+ const data = await call({ model: MODELS[mode], messages, audio })
111
+ const b64 = data.choices[0]?.message?.audio?.data
112
+ if (!b64) throw new Error('上游没有返回音频(audio.data 为空)')
113
+ return Buffer.from(b64, 'base64')
114
+ }
115
+
116
+ /**
117
+ * 识别音频里的文字。
118
+ *
119
+ * ⚠️ 质量有波动:同一段合成音频两次实测,一次「声影成诗,一念成相」(同音字级别),
120
+ * 一次「上庸城失,一面呈象」(整句都错)。别把结果直接当可信文本用在计费或入库口径上。
121
+ */
122
+ export async function transcribe(audioPath: string): Promise<string> {
123
+ const buf = await readFile(audioPath)
124
+ const format = /\.(wav|mp3|m4a|flac|ogg|pcm)$/i.exec(audioPath)?.[1]?.toLowerCase() || 'wav'
125
+ const data = await call({
126
+ model: MODELS.asr,
127
+ messages: [{ role: 'user', content: [{ type: 'input_audio', input_audio: { data: buf.toString('base64'), format } }] }],
128
+ })
129
+ const text = data.choices[0]?.message?.content
130
+ if (typeof text !== 'string' || !text.trim()) throw new Error('上游没有返回识别文本')
131
+ return text.trim()
132
+ }
133
+
134
+ /** WAV 时长(秒),用于给用户一个「出了多长」的反馈。头部损坏时返回 null 而不是抛错 */
135
+ export function wavSeconds(buf: Buffer): number | null {
136
+ if (buf.length < 44 || buf.subarray(0, 4).toString() !== 'RIFF') return null
137
+ const byteRate = buf.readUInt32LE(28)
138
+ return byteRate > 0 ? (buf.length - 44) / byteRate : null
139
+ }