museav-cli 2.5.0 → 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.
package/AGENTS.md CHANGED
@@ -56,6 +56,22 @@ museav gen --prompt "$(museav reverse ./photo.png)"
56
56
  museav image-to-template ./poster.jpg --name '暗金演唱会主视觉' --variables title,subject,location
57
57
  museav image-to-template ./poster.jpg --no-create # dry run: draft JSON on stdout, nothing created
58
58
 
59
+ # Text-to-speech / speech-to-text (Xiaomi MiMo). These talk DIRECTLY to the upstream,
60
+ # NOT through the platform — so they need MIMO_API_KEY and ignore your login/apiKey entirely.
61
+ # Reason: the platform's audio pipeline isn't wired up yet, and this capability is
62
+ # internal-only for now (tenants don't have this key, so they can't reach it).
63
+ # export MIMO_API_KEY=... or cs kyvault run --env MIMO_API_KEY=secret://mimo/api-key -- museav ...
64
+ # stdout: the written .wav path (speak) / the recognized text (transcribe).
65
+ museav speak '声影成诗,一念成像。' # preset voice (Chloe)
66
+ museav speak '欢迎收听' --design '低沉沙哑的中年男声' # invent a voice from a description
67
+ museav speak '这句换个音色' --clone ./sample.wav # clone the voice in sample.wav
68
+ museav speak '慢一点念' --instruction '语速放慢,温柔一些' # style/tone instruction
69
+ museav transcribe ./recording.wav
70
+
71
+ # ⚠️ transcribe accuracy wobbles on homophones — the same synthesized line came back as
72
+ # 「声影成诗,一念成相」 once and 「上庸城失,一面呈象」 another time. Don't feed its output
73
+ # into anything that bills, stores, or branches on exact text without a human check.
74
+
59
75
  # Upload a file (image/audio/video; type is detected from the bytes, not the extension)
60
76
  museav upload ./face.png
61
77
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.7.0 (2026-08-27)
4
+
5
+ ### 修复:`remove-bg` 抠不出主体(三个静默 bug)
6
+
7
+ 抠图一直输出条纹残影、主体几乎全被抠掉。排查出三个独立 bug,共同点是**全都不报错**,
8
+ 只是安静地输出一张糊掉的 mask —— 这类 bug 只能靠拿基准实现(rembg)逐项对齐才查得出来。
9
+
10
+ 1. **主因:`sharp` 对单通道 raw 做 `resize` 后会返回 3 通道**(灰度被展开成 RGB)。
11
+ 代码仍按 `maskFull[i]` 索引,等于以 1/3 的步长错位采样,于是每隔几行错位一次 ——
12
+ 这就是条纹残影的来源。现改为 `toColourspace('b-w')` + 按实际 `channels` 步长索引,两道保险。
13
+ 2. **输入尺寸与归一化参数被硬编码成一套**(1024 + `(x/255-0.5)/0.5`),而每个模型都不同:
14
+ `isnet` 是 1024 / mean .5 / **std 1.0**,`u2net` 是 **320** / ImageNet 参数。
15
+ 于是 u2net 直接崩(`Got: 1024 Expected: 320`),isnet 因 std 用 0.5 而非 1.0
16
+ 把输入值域放大一倍。参数已改为随模型定义。
17
+ 3. **归一化分母用固定 255**,而 rembg 用的是该图的最大像素值(`im_ary / max(im_ary)`)。
18
+ 偏暗的图用 255 归一化会让输入分布整体偏小。
19
+
20
+ ### 新增:BiRefNet-Lite 模型,并设为 `remove-bg` 默认
21
+
22
+ `--model birefnet`(214MB,首次自动下载)。实测同一张白猫照片,主体召回:
23
+ **birefnet 57% / u2net 25% / isnet 10%** —— 毛发、白色主体、低对比度背景全面更好。
24
+
25
+ 注意 BiRefNet 输出的是 **logits,要先过 sigmoid** 才是概率(ISNet/U2Net 的输出已在 0-1 区间)。
26
+ 漏掉这步同样不报错,只会得到一张几乎全是半透明的 mask,所以 `sigmoid` 做成了模型属性。
27
+
28
+ 修完三个模型的输出与 rembg 基准逐一对齐(57.0/57.1、10.5/10.9、25.1/25.1)。
29
+
30
+
31
+ ## 2.6.0 (2026-08-21)
32
+
33
+ ### 新增:语音能力(`speak` / `transcribe`)
34
+
35
+ 接入小米 MiMo 的语音档,**直连上游、不走中台身份**,只认 `MIMO_API_KEY` 环境变量。
36
+ 中台的出音链路(`media_type=audio` 的路由与落盘)还没接完,而这批能力目前内部用——
37
+ 没有那把 key 的人(包括租户)用不了,所以不需要额外做权限控制。
38
+
39
+ - `speak <text>`:文字转语音,输出 24kHz / 16bit 单声道 WAV,stdout 只打印文件路径。
40
+ 三种音色来源,给哪个参数走哪条:默认预置音色(`--voice`,默认 Chloe)、
41
+ `--design` 一句话描述当场造一个、`--clone <file>` 拿一段音频克隆它的音色。
42
+ `--instruction` 可叠加语气/风格指令。
43
+ - `transcribe <audio>`:语音转文字,stdout 只打印识别结果。
44
+
45
+ 协议上有四个反直觉的点,都写进了 `src/mimo-speech.ts` 的文件头(实测踩出来的,写错不报错、
46
+ 只是拿不到音频):合成不走 `/v1/audio/speech`(OpenAI 那套音频端点全 404,四种能力共用
47
+ `/v1/chat/completions`);**待合成文本要放 assistant 角色**,放 user 会得到一段「回答」而不是朗读;
48
+ 音频是 base64 回在 `message.audio.data`;识别的输入音频在 user 的 content 数组里且要裸 base64。
49
+
50
+ ### 已知限制
51
+
52
+ **识别结果的同音字会飘。** 同一段合成音频,一次识别成「声影成诗,一念成相」,另一次成
53
+ 「上庸城失,一面呈象」。CLI 会在 stderr 提醒,但不要把它的输出直接用在计费、入库或需要
54
+ 精确匹配的判断上。
55
+
3
56
  ## 2.5.0 (2026-08-17)
4
57
 
5
58
  ### 修复
package/README.md CHANGED
@@ -102,9 +102,14 @@
102
102
  | | 抠图去背景(输出透明 PNG) | `remove-bg` |
103
103
  | | **放大清晰度**(2M → 10M+ 级) | `upscale` |
104
104
  | | 去水印 | `remove-watermark` |
105
+ | **语音**(直连小米 MiMo,需 `MIMO_API_KEY`) | 文字转语音 / 音色设计 / 音色克隆 | `speak` |
106
+ | | 语音转文字 | `transcribe` |
105
107
  | **素材与统计** | 上传素材 / 查任务 / 查余额 / 查模型 / 查身份 | `upload` / `jobs` / `balance` / `models` / `whoami` |
106
108
 
107
109
  > 本地工具(`compress` / `remove-bg` / `upscale` / `remove-watermark`)**不用登录、不花一分钱**,装了就能用;其余命令需要一个凭证(个人 `login` 或租户 apiKey)。
110
+ >
111
+ > 语音(`speak` / `transcribe`)是第三种情况:**不走中台身份**,直连小米 MiMo,只认 `MIMO_API_KEY` 环境变量。
112
+ > 中台的出音链路还没接完,这批能力目前内部用,没有那把 key 的人(包括租户)用不了。
108
113
 
109
114
  ---
110
115
 
@@ -328,6 +333,48 @@ museav gen --template "$ID" --fields '{"title":"新的主标题"}'
328
333
  - **降级不是失败**:文字层逆向 / 变量化 / 建模板任一步出问题,读图结果(SCULPT、prompt)照常给你,
329
334
  只是没有模具。命令会明确告诉你卡在哪一步。
330
335
 
336
+ ### 文字转语音 `speak` / 语音转文字 `transcribe`
337
+
338
+ 直连小米 MiMo,**不用 `museav login`**,只要一个环境变量:
339
+
340
+ ```bash
341
+ export MIMO_API_KEY=...
342
+ # 或者不落盘,用密钥库注入一次性子进程
343
+ cs kyvault run --env MIMO_API_KEY=secret://mimo/api-key -- museav speak '声影成诗'
344
+ ```
345
+
346
+ 三种音色来源,给了哪个参数就走哪条路:
347
+
348
+ ```bash
349
+ # 预置音色(默认 Chloe)
350
+ museav speak '声影成诗,一念成像。' --out hello.wav
351
+
352
+ # 音色设计:一句话描述,当场造一个音色
353
+ museav speak '欢迎收听山鬼电台' --design '低沉沙哑的中年男声,像深夜电台'
354
+
355
+ # 音色克隆:拿一段音频当样本,复刻它的音色
356
+ museav speak '这句换个音色来念' --clone ./sample.wav
357
+
358
+ # 语气/风格指令,三种模式都能叠加
359
+ museav speak '慢一点念这句' --instruction '语速放慢,温柔一些'
360
+ ```
361
+
362
+ 输出是 24kHz / 16bit 单声道 WAV,stdout 只打印文件路径(方便直接接管道):
363
+
364
+ ```bash
365
+ museav upload "$(museav speak '开场白')" # 合成完直接上传到中台图库
366
+ ```
367
+
368
+ 语音转文字:
369
+
370
+ ```bash
371
+ museav transcribe ./recording.wav # stdout 只有识别出的文本
372
+ ```
373
+
374
+ > ⚠️ **识别结果的同音字会飘。** 同一段合成音频,一次识别成「声影成诗,一念成相」,
375
+ > 另一次成「上庸城失,一面呈象」。别把它的输出直接用在计费、入库或需要精确匹配的判断上,
376
+ > 重要场景请人工核对一遍。
377
+
331
378
  ### 上传素材 `upload`
332
379
 
333
380
  图片、音频、视频都能传,中台按**文件字节内容**判类型(不看扩展名,也不信客户端声明的 MIME),
@@ -345,6 +392,20 @@ museav upload face.png
345
392
  > `gen --ref` / `gen --video --image` 内部已经自动帮你上传了,不需要先手动跑一次 `upload`。
346
393
  > 单独用 `upload` 的场景是:同一张垫图要复用多次,或者你想把 URL 存下来给别的系统用。
347
394
 
395
+ **把外面做好的成品收进账户**:加 `--to-works`。
396
+
397
+ ```bash
398
+ museav upload my-clip.mp4 --to-works # 收进「我的作品」
399
+ museav upload my-clip.mp4 --to-works --workspace <id> # 顺便归档到某个项目
400
+ ```
401
+
402
+ 区别在于**落不落库**:默认只把文件存进图库、回一个直链(参考图就该这样,它是原料不是成品);
403
+ 加了 `--to-works` 会额外记一条作品,这样它才会出现在网页的「我的作品」、后台画廊和项目归档里。
404
+ 不加的话文件只存在存储桶里,界面上哪儿都找不到它。
405
+
406
+ 只对**账户身份**生效(`museav login` 或账户 Key):作品要归到具体某个人头上,
407
+ 租户 Key 没有具体的人,中台会忽略这个参数,CLI 也会明确告诉你没记成作品。
408
+
348
409
  ### 本地图像工具 `compress` / `remove-bg`
349
410
 
350
411
  纯本地、免登录、不消耗中台额度,macOS / Windows / Linux 通用(依赖全走 npm 预编译,无平台特化代码):
package/dist/client.d.ts CHANGED
@@ -277,8 +277,13 @@ export interface ModelOption {
277
277
  description?: string;
278
278
  }
279
279
  export interface Balance {
280
- /** 数值单位是 ¥(人民币)。字段名带 usd 是历史遗留命名,不代表美元——中台侧不存在汇率换算 */
281
- balance_usd: number;
280
+ /** 余额(¥)。中台 2026-08-21 起发这个字段名 */
281
+ balance_cny?: number;
282
+ /**
283
+ * 同一个数的旧字段名,中台仍在双发。字段名带 usd 纯属历史遗留,值一直是人民币——
284
+ * 中台侧不存在汇率换算。老版本 CLI 只认这个名字,所以中台不会立刻停发。
285
+ */
286
+ balance_usd?: number;
282
287
  /** 租户加价率(0.2 = 加价 20%) */
283
288
  markup_pct: number;
284
289
  checked_at: string;
@@ -418,10 +423,21 @@ export declare class StudioClient {
418
423
  * 上传素材。图片会先压到视觉模型够用的尺寸再传(见 compress.ts)——
419
424
  * 参考图是给模型看的,不是留档,原图直传只会拖慢上传和解析。
420
425
  */
421
- uploadRef(filePath: string): Promise<{
426
+ /**
427
+ * 上传素材。默认只存文件、回直链(参考图/垫图就该这样)。
428
+ *
429
+ * asWork=true 时另外记一条作品:不落库的话文件只存在 R2 里,
430
+ * 作品页、后台画廊、项目归档全都看不见它 —— 「传到我的账户」就没发生。
431
+ * 只对账户身份生效(作品要归到具体某个人头上),租户 key 传了也会被中台忽略。
432
+ */
433
+ uploadRef(filePath: string, opts?: {
434
+ asWork?: boolean;
435
+ workspaceId?: string;
436
+ }): Promise<{
422
437
  url: string;
423
438
  media_type?: string;
424
439
  mime?: string;
440
+ job_id?: string | null;
425
441
  }>;
426
442
  /**
427
443
  * 图片转模板(POST /api/image-to-template):一张图 → 一个可复用的图片模板。
package/dist/client.js CHANGED
@@ -310,9 +310,21 @@ export class StudioClient {
310
310
  * 上传素材。图片会先压到视觉模型够用的尺寸再传(见 compress.ts)——
311
311
  * 参考图是给模型看的,不是留档,原图直传只会拖慢上传和解析。
312
312
  */
313
- async uploadRef(filePath) {
314
- const r = await this.request('upload-ref', { method: 'POST', body: await fileForm(filePath) });
315
- return { url: r.url, media_type: r.media_type, mime: r.mime };
313
+ /**
314
+ * 上传素材。默认只存文件、回直链(参考图/垫图就该这样)。
315
+ *
316
+ * asWork=true 时另外记一条作品:不落库的话文件只存在 R2 里,
317
+ * 作品页、后台画廊、项目归档全都看不见它 —— 「传到我的账户」就没发生。
318
+ * 只对账户身份生效(作品要归到具体某个人头上),租户 key 传了也会被中台忽略。
319
+ */
320
+ async uploadRef(filePath, opts = {}) {
321
+ const form = await fileForm(filePath);
322
+ if (opts.asWork)
323
+ form.append('as_work', '1');
324
+ if (opts.workspaceId)
325
+ form.append('workspace_id', opts.workspaceId);
326
+ const r = await this.request('upload-ref', { method: 'POST', body: form });
327
+ return { url: r.url, media_type: r.media_type, mime: r.mime, job_id: r.job_id ?? null };
316
328
  }
317
329
  /**
318
330
  * 图片转模板(POST /api/image-to-template):一张图 → 一个可复用的图片模板。
@@ -1,7 +1,10 @@
1
1
  export async function balance(client) {
2
2
  const r = await client.balance();
3
- // 单位 ¥ 人民币(后台 2026-08-09 起只返回租户自己的余额,不再下发上游供应商聚合数据)
4
- process.stderr.write(`余额: ¥${r.balance_usd?.toFixed(2) ?? '?'}`);
3
+ // 单位 ¥ 人民币(后台 2026-08-09 起只返回租户自己的余额,不再下发上游供应商聚合数据)。
4
+ // 优先读 balance_cny:中台已改用这个名字,balance_usd 是双发过渡期的旧名,
5
+ // 两个值永远相等,但等中台停发旧名时这里不用再改一次。
6
+ const cny = r.balance_cny ?? r.balance_usd;
7
+ process.stderr.write(`余额: ¥${cny?.toFixed(2) ?? '?'}`);
5
8
  if (r.markup_pct)
6
9
  process.stderr.write(` 加价率: ${(r.markup_pct * 100).toFixed(0)}%`);
7
10
  if (r.checked_at)
@@ -68,7 +68,8 @@ export async function compressCmd(input, opts) {
68
68
  export async function removeBgCmd(input, opts) {
69
69
  if (!(await fileExists(input)))
70
70
  throw new Error(`文件不存在: ${input}`);
71
- const modelKey = (opts.model || 'isnet');
71
+ // 默认 birefnet:实测对毛发、白色主体、低对比度背景的召回远好于 isnet/u2net
72
+ const modelKey = (opts.model || 'birefnet');
72
73
  if (!(modelKey in BG_MODELS))
73
74
  throw new Error(`--model 只支持 ${Object.keys(BG_MODELS).join(' / ')}`);
74
75
  const start = Date.now();
@@ -0,0 +1,9 @@
1
+ export interface SpeakCliOptions {
2
+ out?: string;
3
+ voice?: string;
4
+ design?: string;
5
+ clone?: string;
6
+ instruction?: string;
7
+ }
8
+ export declare function speak(text: string, opts?: SpeakCliOptions): Promise<void>;
9
+ export declare function transcribeCmd(audioPath: string): Promise<void>;
@@ -0,0 +1,41 @@
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
+ const MODE_LABEL = { tts: '预置音色', design: '音色设计', clone: '音色克隆' };
16
+ export async function speak(text, opts = {}) {
17
+ const mode = speechMode({ clonePath: opts.clone, design: opts.design });
18
+ // 克隆模式下 opts.clone 是整条路径,进度行里只留文件名——绝对路径会把这行顶到换行
19
+ const detail = mode === 'tts' ? (opts.voice || DEFAULT_VOICE)
20
+ : mode === 'clone' ? basename(opts.clone || '') : (opts.design || '');
21
+ process.stderr.write(`合成中(${MODE_LABEL[mode]}${detail ? ` · ${detail}` : ''})...\n`);
22
+ const buf = await synthesize(text, {
23
+ voice: opts.voice,
24
+ design: opts.design,
25
+ clonePath: opts.clone,
26
+ instruction: opts.instruction,
27
+ });
28
+ // 默认落在当前目录,文件名带时间戳避免连续合成互相覆盖
29
+ const out = resolve(opts.out || `speech-${Date.now()}.wav`);
30
+ await writeFile(out, buf);
31
+ const secs = wavSeconds(buf);
32
+ process.stderr.write(`✅ ${(buf.length / 1024).toFixed(0)}KB${secs ? ` · ${secs.toFixed(2)}s` : ''}\n`);
33
+ console.log(out);
34
+ }
35
+ export async function transcribeCmd(audioPath) {
36
+ process.stderr.write(`识别中 ${audioPath} ...\n`);
37
+ const text = await transcribe(audioPath);
38
+ // 质量有波动(见 mimo-speech.ts 的注释),提醒一句,但不影响 stdout 的机器可读性
39
+ process.stderr.write('✅ 识别完成(同音字可能有误,重要场景请核对)\n');
40
+ console.log(text);
41
+ }
@@ -6,4 +6,7 @@
6
6
  * 拿到的 URL 可以直接喂给 gen --ref / gen --video --image,也能给 reverse 当图片 URL。
7
7
  */
8
8
  import type { StudioClient } from '../client.js';
9
- export declare function upload(client: StudioClient, filePath: string): Promise<void>;
9
+ export declare function upload(client: StudioClient, filePath: string, opts?: {
10
+ toWorks?: boolean;
11
+ workspace?: string;
12
+ }): Promise<void>;
@@ -1,8 +1,21 @@
1
1
  const KIND_LABEL = { image: '图片', audio: '音频', video: '视频' };
2
- export async function upload(client, filePath) {
2
+ export async function upload(client, filePath, opts = {}) {
3
3
  process.stderr.write(`上传 ${filePath} ...\n`);
4
- const { url, media_type, mime } = await client.uploadRef(filePath);
4
+ const { url, media_type, mime, job_id } = await client.uploadRef(filePath, {
5
+ asWork: opts.toWorks,
6
+ workspaceId: opts.workspace,
7
+ });
5
8
  const kind = media_type ? `${KIND_LABEL[media_type] || media_type}${mime ? ` · ${mime}` : ''}` : '';
6
9
  process.stderr.write(`✅ 上传成功${kind ? `(${kind})` : ''}\n`);
10
+ if (opts.toWorks) {
11
+ // 说清有没有真的进作品库:租户 key 调用时中台不记作品,只提示「已上传」会让人以为进去了
12
+ process.stderr.write(job_id
13
+ ? '📁 已收进你的作品库,在「我的作品」里能看到\n'
14
+ : '⚠️ 文件已上传,但没能记进作品库(租户 Key 调用不记作品,作品要归到具体账户)\n');
15
+ }
16
+ else if (media_type === 'video') {
17
+ // 传视频十有八九是想收成品,顺手提一句 —— 但不擅自替他决定
18
+ process.stderr.write('提示:加 --to-works 可以把它收进「我的作品」\n');
19
+ }
7
20
  console.log(url);
8
21
  }
package/dist/compress.js CHANGED
@@ -47,6 +47,14 @@ export async function compressForVision(filePath) {
47
47
  meta = await sharp(filePath).metadata();
48
48
  }
49
49
  catch {
50
+ // HEIC 解码失败会走到这里(libheif 安全检查拒绝),但它不是「视频/音频」——
51
+ // 原样上传会被中台按魔数判成 image/heic,而 gpt-image-2 上游解码不了 HEIC
52
+ // 字节 → 400 invalid_image_input。与其等到出图时才炸,不如上传前就报清楚。
53
+ // 判断依据:文件名扩展名 .heic/.heif/.avif 最可靠(HEIC 与 MP4 共享 ftyp 容器头,
54
+ // 不能靠字节区分;sharp 读不了恰恰说明它是图不是视频——视频不喂 sharp 走这里)。
55
+ if (/\.(heic|heif|avif)$/i.test(name)) {
56
+ throw new Error(`HEIC 图片 ${name} 无法直接上传:上游模型认不得 HEIC 字节。请先转成 JPG/PNG 再上传(macOS 可用「预览」打开后另存为 JPEG,或用 museav img min 转换)`);
57
+ }
50
58
  return { buffer: null, filename: name, note: '' }; // 不是 sharp 认识的图(视频/音频)→ 原样传
51
59
  }
52
60
  const longEdge = Math.max(meta.width || 0, meta.height || 0);
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ 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 { speak, transcribeCmd } from './commands/speak.js';
31
32
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
32
33
  // 每 12 小时最多查一次 npm registry,过期才提示,不拖慢日常调用
33
34
  updateNotifier({ pkg, updateCheckInterval: 1000 * 60 * 60 * 12 }).notify({ defer: false });
@@ -179,9 +180,9 @@ program
179
180
  .action(asyncRun((input, opts) => compressCmd(input, opts)));
180
181
  program
181
182
  .command('remove-bg <file>')
182
- .description('本地抠图去背景(ISNet/U2Net + onnxruntime,免登录):输出带 alpha 的 PNG。首次使用自动下载模型(~170MB,缓存 ~/.museav-models)')
183
+ .description('本地抠图去背景(BiRefNet/ISNet/U2Net + onnxruntime,免登录):输出带 alpha 的 PNG。首次使用自动下载模型(缓存 ~/.museav-models)')
183
184
  .option('--out <path>', '输出路径(默认 <名>-nobg.png)')
184
- .option('--model <name>', 'isnet(默认,质量优先)/ u2net')
185
+ .option('--model <name>', 'birefnet(默认,细节最好,~214MB)/ isnet / u2net')
185
186
  .option('--overwrite', '允许覆盖已存在的输出文件')
186
187
  .action(asyncRun((input, opts) => removeBgCmd(input, opts)));
187
188
  program
@@ -247,7 +248,22 @@ program
247
248
  program
248
249
  .command('upload <file>')
249
250
  .description('上传素材(图片/音频/视频,按字节内容判类型;图片 8MB / 音频 20MB / 视频 50MB),stdout 输出公网直链')
250
- .action(withClient((client, file) => upload(client, file)));
251
+ .option('--to-works', '同时收进「我的作品」(在外面做好的成品视频/图片用这个;参考图不用)')
252
+ .option('--workspace <id>', '归档到指定项目')
253
+ .action(withClient((client, file, opts) => upload(client, file, { toWorks: opts.toWorks, workspace: opts.workspace })));
254
+ program
255
+ .command('speak <text>')
256
+ .description('文本转语音(小米 MiMo,直连上游需 MIMO_API_KEY,不走中台身份):stdout 输出 wav 路径')
257
+ .option('--out <path>', '输出路径(默认 speech-<时间戳>.wav)')
258
+ .option('--voice <name>', '预置音色,默认 Chloe')
259
+ .option('--design <desc>', '一句话描述音色,当场造一个(如「低沉沙哑的中年男声」)')
260
+ .option('--clone <file>', '拿这段音频当样本,克隆它的音色')
261
+ .option('--instruction <text>', '语气/风格指令(三种模式都可用)')
262
+ .action(asyncRun((text, opts) => speak(text, opts)));
263
+ program
264
+ .command('transcribe <audio>')
265
+ .description('语音转文本(小米 MiMo,需 MIMO_API_KEY):stdout 输出识别结果。同音字可能有误,重要场景请核对')
266
+ .action(asyncRun((audio) => transcribeCmd(audio)));
251
267
  program
252
268
  .command('models')
253
269
  .description('查可用模型列表')
@@ -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;