museav-cli 2.4.1 → 2.7.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.
@@ -1,13 +1,36 @@
1
+ /**
2
+ * 每个模型的输入尺寸与归一化参数**都不一样**,必须随模型带着走。
3
+ * 取值对齐 rembg 的 session 定义(sessions/dis_general_use.py、sessions/u2net.py):
4
+ * isnet-general-use → resize 1024,mean (0.5,0.5,0.5) std (1,1,1)
5
+ * u2net → resize 320,mean ImageNet std ImageNet
6
+ * 用错任何一项都不会报错,只会安静地输出一张糊掉的 mask —— 这是最难查的那种 bug。
7
+ */
1
8
  export declare const BG_MODELS: {
2
9
  readonly isnet: {
3
10
  readonly file: 'isnet-general-use.onnx';
4
11
  readonly url: 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/isnet-general-use.onnx';
5
12
  readonly label: 'ISNet(通用,质量优先)';
13
+ readonly edge: 1024;
14
+ readonly mean: readonly [0.5, 0.5, 0.5];
15
+ readonly std: readonly [1, 1, 1];
6
16
  };
7
17
  readonly u2net: {
8
18
  readonly file: 'u2net.onnx';
9
19
  readonly url: 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx';
10
20
  readonly label: 'U2Net(经典通用)';
21
+ readonly edge: 320;
22
+ readonly mean: readonly [0.485, 0.456, 0.406];
23
+ readonly std: readonly [0.229, 0.224, 0.225];
24
+ };
25
+ readonly birefnet: {
26
+ readonly file: 'birefnet-general-lite.onnx';
27
+ readonly url: 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/BiRefNet-general-bb_swin_v1_tiny-epoch_232.onnx';
28
+ readonly label: 'BiRefNet-Lite(细节最好,毛发/低对比度主体优先用它)';
29
+ readonly edge: 1024;
30
+ readonly mean: readonly [0.485, 0.456, 0.406];
31
+ readonly std: readonly [0.229, 0.224, 0.225];
32
+ readonly sigmoid: true;
33
+ readonly size: 214;
11
34
  };
12
35
  };
13
36
  export type BgModelKey = keyof typeof BG_MODELS;
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
- process.stderr.write(`↓ 首次使用,下载 ${def.label}(~170MB,一次性,缓存到 ${MODEL_DIR})...\n`);
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
- // ── 模型输入:拉伸到 1024×1024,(x/255 - 0.5)/0.5 归一化,HWC → CHW ──
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(INPUT_EDGE, INPUT_EDGE, { fit: 'fill' })
112
+ .resize(edge, edge, { fit: 'fill', kernel: 'lanczos3' })
86
113
  .raw()
87
114
  .toBuffer();
88
- const f32 = new Float32Array(3 * INPUT_EDGE * INPUT_EDGE);
89
- const N = INPUT_EDGE * INPUT_EDGE;
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] / 255 - 0.5) / 0.5;
92
- f32[N + i] = (small[i * 3 + 1] / 255 - 0.5) / 0.5;
93
- f32[2 * N + i] = (small[i * 3 + 2] / 255 - 0.5) / 0.5;
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, INPUT_EDGE, INPUT_EDGE]);
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
- // ── 后处理:min-max 归一化到 0-255,再缩回原图尺寸 ──
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 = maskFlat[i];
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
- mask8[i] = Math.round(((maskFlat[i] - lo) / range) * 255);
116
- const maskFull = await sharp(mask8, { raw: { width: INPUT_EDGE, height: INPUT_EDGE, channels: 1 } })
117
- .resize(info.width, info.height, { fit: 'fill' })
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "museav-cli",
3
- "version": "2.4.1",
3
+ "version": "2.7.0",
4
4
  "description": "MUSE AV 出图中台官方 CLI —— 命令行调中台 API 出图、出视频、读图逆向、图生模板",
5
5
  "type": "module",
6
6
  "bin": {
package/src/client.ts CHANGED
@@ -123,6 +123,10 @@ export interface TemplateOption {
123
123
  sample_cover_image?: string | null
124
124
  /** 归属:自己租户建的 vs 平台共享的(tenant_id 为空) */
125
125
  tenant_id: string | null
126
+ /** 中台下发的归属标记:mine=本租户建的 / platform=平台共享 / personal=我这个人建的 */
127
+ source?: 'mine' | 'platform' | 'personal'
128
+ /** 创建人(平台管理员个人建的模板会带邮箱;租户建的为 null) */
129
+ created_by?: string | null
126
130
  generation_configs: Array<{
127
131
  model: string
128
132
  prompt_template: string
@@ -316,8 +320,13 @@ export interface ModelOption {
316
320
  }
317
321
 
318
322
  export interface Balance {
319
- /** 数值单位是 ¥(人民币)。字段名带 usd 是历史遗留命名,不代表美元——中台侧不存在汇率换算 */
320
- balance_usd: number
323
+ /** 余额(¥)。中台 2026-08-21 起发这个字段名 */
324
+ balance_cny?: number
325
+ /**
326
+ * 同一个数的旧字段名,中台仍在双发。字段名带 usd 纯属历史遗留,值一直是人民币——
327
+ * 中台侧不存在汇率换算。老版本 CLI 只认这个名字,所以中台不会立刻停发。
328
+ */
329
+ balance_usd?: number
321
330
  /** 租户加价率(0.2 = 加价 20%) */
322
331
  markup_pct: number
323
332
  checked_at: string
@@ -386,9 +395,13 @@ export class StudioClient {
386
395
  }
387
396
 
388
397
  /** 可用图片/文字模板清单:自己租户建的 + 平台共享的,服务端已按调用者权限过滤。
389
- * type=image|article 二选一(不传则图片+文字都返回,跟中台默认一致)。 */
390
- async templates(type?: 'image' | 'article'): Promise<TemplateOption[]> {
391
- const qs = type ? `?type=${type}` : ''
398
+ * type=image|article 二选一(不传则图片+文字都返回,跟中台默认一致)。
399
+ * source=mine|platform|personal|all(默认 all;mine=本租户,platform=平台共享,personal=我这个人建的)。 */
400
+ async templates(type?: 'image' | 'article', source?: 'mine' | 'platform' | 'personal' | 'all'): Promise<TemplateOption[]> {
401
+ const params = new URLSearchParams()
402
+ if (type) params.set('type', type)
403
+ if (source && source !== 'all') params.set('source', source)
404
+ const qs = params.toString() ? `?${params}` : ''
392
405
  const r = await this.request(`templates${qs}`)
393
406
  return Array.isArray(r) ? r : []
394
407
  }
@@ -616,9 +629,22 @@ export class StudioClient {
616
629
  * 上传素材。图片会先压到视觉模型够用的尺寸再传(见 compress.ts)——
617
630
  * 参考图是给模型看的,不是留档,原图直传只会拖慢上传和解析。
618
631
  */
619
- async uploadRef(filePath: string): Promise<{ url: string; media_type?: string; mime?: string }> {
620
- const r = await this.request('upload-ref', { method: 'POST', body: await fileForm(filePath) })
621
- return { url: r.url, media_type: r.media_type, mime: r.mime }
632
+ /**
633
+ * 上传素材。默认只存文件、回直链(参考图/垫图就该这样)。
634
+ *
635
+ * asWork=true 时另外记一条作品:不落库的话文件只存在 R2 里,
636
+ * 作品页、后台画廊、项目归档全都看不见它 —— 「传到我的账户」就没发生。
637
+ * 只对账户身份生效(作品要归到具体某个人头上),租户 key 传了也会被中台忽略。
638
+ */
639
+ async uploadRef(
640
+ filePath: string,
641
+ opts: { asWork?: boolean; workspaceId?: string } = {},
642
+ ): Promise<{ url: string; media_type?: string; mime?: string; job_id?: string | null }> {
643
+ const form = await fileForm(filePath)
644
+ if (opts.asWork) form.append('as_work', '1')
645
+ if (opts.workspaceId) form.append('workspace_id', opts.workspaceId)
646
+ const r = await this.request('upload-ref', { method: 'POST', body: form })
647
+ return { url: r.url, media_type: r.media_type, mime: r.mime, job_id: r.job_id ?? null }
622
648
  }
623
649
 
624
650
  /**
@@ -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
- process.stderr.write(`余额: ¥${r.balance_usd?.toFixed(2) ?? '?'}`)
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
- const modelKey = (opts.model || 'isnet') as BgModelKey
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()
@@ -22,10 +22,15 @@ export async function projects(client: StudioClient): Promise<void> {
22
22
  process.stderr.write('还没有工作区(museav projects create --name 新建)\n')
23
23
  return
24
24
  }
25
+ // 每个项目的素材数(模板不挂项目,这里统计的是项目素材库,不是模板)
26
+ const assetCounts = await Promise.all(
27
+ list.map(async (w) => ({ id: w.id, n: (await client.workspaceAssets(w.id).catch(() => [])).length })),
28
+ )
29
+ const assetOf = Object.fromEntries(assetCounts.map((x) => [x.id, x.n]))
25
30
  process.stderr.write(`工作区(${list.length} 个):\n`)
26
31
  for (const w of list) {
27
32
  process.stderr.write(
28
- ` ${w.id} ${w.name.padEnd(16)} 出图 ${w.gen_done ?? 0}/${w.gen_total ?? 0}${w.brand ? ` brand:${w.brand}` : ''}\n`,
33
+ ` ${w.id} ${w.name.padEnd(16)} 出图 ${w.gen_done ?? 0}/${w.gen_total ?? 0} 素材 ${assetOf[w.id] ?? 0}${w.brand ? ` brand:${w.brand}` : ''}\n`,
29
34
  )
30
35
  }
31
36
  process.stderr.write(`\n素材库: museav projects assets --project <id|名>\n出图归档: museav gen --project <id|名> ...\n`)
@@ -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
+ }
@@ -1,9 +1,25 @@
1
- /** museav templates —— 查可用图片/文字模板(自己租户建的 + 平台共享的)
2
- * --type image|article 可过滤(中台 templates 表同时装两种,不传则都列并标注类型) */
1
+ /** museav templates —— 查可用图片/文字模板。
2
+ * --type image|article 按类型过滤
3
+ * --mine 只看本租户建的;--platform 只看平台共享的;都不传则全部列出
4
+ * --category 按分类过滤 */
3
5
  import type { StudioClient } from '../client.js'
4
6
 
5
- export async function templates(client: StudioClient, opts: { category?: string; type?: string } = {}): Promise<void> {
6
- let list = await client.templates((opts.type === 'image' || opts.type === 'article') ? opts.type : undefined)
7
+ export async function templates(client: StudioClient, opts: { category?: string; type?: string; mine?: boolean; tenant?: boolean; platform?: boolean } = {}): Promise<void> {
8
+ const type = opts.type === 'image' || opts.type === 'article' ? opts.type : undefined
9
+ // 三个归属维度都走服务端 source 参数(正式 API,不再客户端猜):
10
+ // --mine → source=personal(created_by = 当前账户邮箱,我这个人建的)
11
+ // --tenant → source=mine(本租户专属)
12
+ // --platform → source=platform(平台共享)
13
+ let list: Awaited<ReturnType<StudioClient['templates']>>
14
+ if (opts.mine) {
15
+ list = await client.templates(type, 'personal')
16
+ } else if (opts.tenant) {
17
+ list = await client.templates(type, 'mine')
18
+ } else if (opts.platform) {
19
+ list = await client.templates(type, 'platform')
20
+ } else {
21
+ list = await client.templates(type)
22
+ }
7
23
  if (opts.category) {
8
24
  const kw = opts.category.toLowerCase()
9
25
  list = list.filter((t) => (t.category || '').toLowerCase().includes(kw))
@@ -13,7 +29,11 @@ export async function templates(client: StudioClient, opts: { category?: string;
13
29
  return
14
30
  }
15
31
 
16
- const tag = (t: (typeof list)[number]) => (t.tenant_id ? '' : '[平台]')
32
+ const tag = (t: (typeof list)[number]) => {
33
+ if (t.source === 'personal') return '[个人]'
34
+ if (t.tenant_id) return '[租户]'
35
+ return '[平台]'
36
+ }
17
37
  const typeTag = (t: (typeof list)[number]) => (t.template_type === 'article' ? '[文字]' : t.template_type === 'image' ? '[图片]' : '')
18
38
 
19
39
  process.stderr.write(`可用模板(${list.length} 个):\n`)
@@ -27,7 +47,7 @@ export async function templates(client: StudioClient, opts: { category?: string;
27
47
  )
28
48
  }
29
49
  process.stderr.write(`\n出图: museav gen --template <模板id> [--fields '{"key":"值"}']\n`)
30
- process.stderr.write(`按类型过滤: museav templates --type image|article\n`)
50
+ process.stderr.write(`筛选: --mine(我建的) --tenant(本租户) --platform(平台共享) --type image|article --category <分类>\n`)
31
51
  // stdout 只出 id,便于脚本与 agent 解析
32
52
  console.log(list.map((t) => t.id).join('\n'))
33
53
  }
@@ -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(client: StudioClient, filePath: string): Promise<void> {
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
  }