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.
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
 
@@ -87,7 +103,9 @@ museav products
87
103
  museav assets
88
104
 
89
105
  # Check who you're logged in as and whether the account is affiliated with a tenant
90
- # (personal login only apiKey callers get an error, they're already acting as the tenant)
106
+ # Works for both personal login and apiKey (platform account or tenant). For apiKey:
107
+ # platform-account → 账户: nickname + 邮箱 + 累计出图 + credits
108
+ # tenant → 租户: tenant_id + name + logo
91
109
  museav whoami
92
110
  ```
93
111
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,69 @@
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
+
56
+ ## 2.5.0 (2026-08-17)
57
+
58
+ ### 修复
59
+ - **`whoami` 不再拒绝 apiKey 模式**:之前会直接抛错「只支持个人 login 身份」。实际上 `/api/me` 对两种 apiKey 都返回真实数据——
60
+ - 平台账户 apiKey(`STUDIO_API_KEY` 指向 `accounts.sk-*`)→ 账户身份:邮箱 + nickname + 累计出图次数 + credits
61
+ - 租户 apiKey(指向 `api_tenants.tenant_key`)→ 业务身份:tenant_id + name + nickname + logo
62
+ 修复后三种身份都能 `museav whoami` 查清楚当前是谁、关联到哪个租户。
63
+
64
+ ### 文档
65
+ - AGENTS.md `whoami` 说明:去掉「apiKey 不可用」的旧断言。
66
+
3
67
  ## 2.4.0 · 2026-08-17
4
68
 
5
69
  **本地图像工具箱补全(免登录、零成本、macOS/Windows 通用)。** 均为轻量级工具,无大模型常驻内存。
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
 
@@ -214,6 +219,10 @@ curl -o poster.png "$URL"
214
219
  # 先查有哪些模板(自己租户建的 + 平台共享的)
215
220
  museav templates
216
221
  museav templates --category 电商白底图 # 按分类过滤
222
+ museav templates --mine # 只看我这个人建的
223
+ museav templates --tenant # 只看本租户专属的
224
+ museav templates --platform # 只看平台共享的
225
+ museav templates --type image # 按类型过滤(image / article)
217
226
 
218
227
  # 没有占位符的模板,直接用
219
228
  museav gen --template <模板id>
@@ -324,6 +333,48 @@ museav gen --template "$ID" --fields '{"title":"新的主标题"}'
324
333
  - **降级不是失败**:文字层逆向 / 变量化 / 建模板任一步出问题,读图结果(SCULPT、prompt)照常给你,
325
334
  只是没有模具。命令会明确告诉你卡在哪一步。
326
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
+
327
378
  ### 上传素材 `upload`
328
379
 
329
380
  图片、音频、视频都能传,中台按**文件字节内容**判类型(不看扩展名,也不信客户端声明的 MIME),
@@ -341,6 +392,20 @@ museav upload face.png
341
392
  > `gen --ref` / `gen --video --image` 内部已经自动帮你上传了,不需要先手动跑一次 `upload`。
342
393
  > 单独用 `upload` 的场景是:同一张垫图要复用多次,或者你想把 URL 存下来给别的系统用。
343
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
+
344
409
  ### 本地图像工具 `compress` / `remove-bg`
345
410
 
346
411
  纯本地、免登录、不消耗中台额度,macOS / Windows / Linux 通用(依赖全走 npm 预编译,无平台特化代码):
package/dist/client.d.ts CHANGED
@@ -71,6 +71,10 @@ export interface TemplateOption {
71
71
  sample_cover_image?: string | null;
72
72
  /** 归属:自己租户建的 vs 平台共享的(tenant_id 为空) */
73
73
  tenant_id: string | null;
74
+ /** 中台下发的归属标记:mine=本租户建的 / platform=平台共享 / personal=我这个人建的 */
75
+ source?: 'mine' | 'platform' | 'personal';
76
+ /** 创建人(平台管理员个人建的模板会带邮箱;租户建的为 null) */
77
+ created_by?: string | null;
74
78
  generation_configs: Array<{
75
79
  model: string;
76
80
  prompt_template: string;
@@ -273,8 +277,13 @@ export interface ModelOption {
273
277
  description?: string;
274
278
  }
275
279
  export interface Balance {
276
- /** 数值单位是 ¥(人民币)。字段名带 usd 是历史遗留命名,不代表美元——中台侧不存在汇率换算 */
277
- balance_usd: number;
280
+ /** 余额(¥)。中台 2026-08-21 起发这个字段名 */
281
+ balance_cny?: number;
282
+ /**
283
+ * 同一个数的旧字段名,中台仍在双发。字段名带 usd 纯属历史遗留,值一直是人民币——
284
+ * 中台侧不存在汇率换算。老版本 CLI 只认这个名字,所以中台不会立刻停发。
285
+ */
286
+ balance_usd?: number;
278
287
  /** 租户加价率(0.2 = 加价 20%) */
279
288
  markup_pct: number;
280
289
  checked_at: string;
@@ -319,8 +328,9 @@ export declare class StudioClient {
319
328
  /** 可用技能清单:私有 + 所属租户专属模板 + 公共库,服务端已按调用者权限过滤 */
320
329
  skills(): Promise<SkillOption[]>;
321
330
  /** 可用图片/文字模板清单:自己租户建的 + 平台共享的,服务端已按调用者权限过滤。
322
- * type=image|article 二选一(不传则图片+文字都返回,跟中台默认一致)。 */
323
- templates(type?: 'image' | 'article'): Promise<TemplateOption[]>;
331
+ * type=image|article 二选一(不传则图片+文字都返回,跟中台默认一致)。
332
+ * source=mine|platform|personal|all(默认 all;mine=本租户,platform=平台共享,personal=我这个人建的)。 */
333
+ templates(type?: 'image' | 'article', source?: 'mine' | 'platform' | 'personal' | 'all'): Promise<TemplateOption[]>;
324
334
  /** 视频模板清单(POST /api/videos 用 template_id)。结构同图片模板的 generation_configs 形态 */
325
335
  videoTemplates(): Promise<TemplateOption[]>;
326
336
  /** 新建图片模板。归属(是否关联租户)由服务端根据鉴权身份决定,见 CreateTemplateInput 注释 */
@@ -413,10 +423,21 @@ export declare class StudioClient {
413
423
  * 上传素材。图片会先压到视觉模型够用的尺寸再传(见 compress.ts)——
414
424
  * 参考图是给模型看的,不是留档,原图直传只会拖慢上传和解析。
415
425
  */
416
- 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<{
417
437
  url: string;
418
438
  media_type?: string;
419
439
  mime?: string;
440
+ job_id?: string | null;
420
441
  }>;
421
442
  /**
422
443
  * 图片转模板(POST /api/image-to-template):一张图 → 一个可复用的图片模板。
package/dist/client.js CHANGED
@@ -90,9 +90,15 @@ export class StudioClient {
90
90
  return Array.isArray(r) ? r : [];
91
91
  }
92
92
  /** 可用图片/文字模板清单:自己租户建的 + 平台共享的,服务端已按调用者权限过滤。
93
- * type=image|article 二选一(不传则图片+文字都返回,跟中台默认一致)。 */
94
- async templates(type) {
95
- const qs = type ? `?type=${type}` : '';
93
+ * type=image|article 二选一(不传则图片+文字都返回,跟中台默认一致)。
94
+ * source=mine|platform|personal|all(默认 all;mine=本租户,platform=平台共享,personal=我这个人建的)。 */
95
+ async templates(type, source) {
96
+ const params = new URLSearchParams();
97
+ if (type)
98
+ params.set('type', type);
99
+ if (source && source !== 'all')
100
+ params.set('source', source);
101
+ const qs = params.toString() ? `?${params}` : '';
96
102
  const r = await this.request(`templates${qs}`);
97
103
  return Array.isArray(r) ? r : [];
98
104
  }
@@ -304,9 +310,21 @@ export class StudioClient {
304
310
  * 上传素材。图片会先压到视觉模型够用的尺寸再传(见 compress.ts)——
305
311
  * 参考图是给模型看的,不是留档,原图直传只会拖慢上传和解析。
306
312
  */
307
- async uploadRef(filePath) {
308
- const r = await this.request('upload-ref', { method: 'POST', body: await fileForm(filePath) });
309
- 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 };
310
328
  }
311
329
  /**
312
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();
@@ -20,9 +20,12 @@ export async function projects(client) {
20
20
  process.stderr.write('还没有工作区(museav projects create --name 新建)\n');
21
21
  return;
22
22
  }
23
+ // 每个项目的素材数(模板不挂项目,这里统计的是项目素材库,不是模板)
24
+ const assetCounts = await Promise.all(list.map(async (w) => ({ id: w.id, n: (await client.workspaceAssets(w.id).catch(() => [])).length })));
25
+ const assetOf = Object.fromEntries(assetCounts.map((x) => [x.id, x.n]));
23
26
  process.stderr.write(`工作区(${list.length} 个):\n`);
24
27
  for (const w of list) {
25
- process.stderr.write(` ${w.id} ${w.name.padEnd(16)} 出图 ${w.gen_done ?? 0}/${w.gen_total ?? 0}${w.brand ? ` brand:${w.brand}` : ''}\n`);
28
+ process.stderr.write(` ${w.id} ${w.name.padEnd(16)} 出图 ${w.gen_done ?? 0}/${w.gen_total ?? 0} 素材 ${assetOf[w.id] ?? 0}${w.brand ? ` brand:${w.brand}` : ''}\n`);
26
29
  }
27
30
  process.stderr.write(`\n素材库: museav projects assets --project <id|名>\n出图归档: museav gen --project <id|名> ...\n`);
28
31
  // stdout 只出 id,便于脚本解析
@@ -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
+ }
@@ -1,9 +1,14 @@
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
  export declare function templates(client: StudioClient, opts?: {
5
7
  category?: string;
6
8
  type?: string;
9
+ mine?: boolean;
10
+ tenant?: boolean;
11
+ platform?: boolean;
7
12
  }): Promise<void>;
8
13
  interface CreateTemplateOpts {
9
14
  name: string;
@@ -1,5 +1,22 @@
1
1
  export async function templates(client, opts = {}) {
2
- let list = await client.templates((opts.type === 'image' || opts.type === 'article') ? opts.type : undefined);
2
+ const type = opts.type === 'image' || opts.type === 'article' ? opts.type : undefined;
3
+ // 三个归属维度都走服务端 source 参数(正式 API,不再客户端猜):
4
+ // --mine → source=personal(created_by = 当前账户邮箱,我这个人建的)
5
+ // --tenant → source=mine(本租户专属)
6
+ // --platform → source=platform(平台共享)
7
+ let list;
8
+ if (opts.mine) {
9
+ list = await client.templates(type, 'personal');
10
+ }
11
+ else if (opts.tenant) {
12
+ list = await client.templates(type, 'mine');
13
+ }
14
+ else if (opts.platform) {
15
+ list = await client.templates(type, 'platform');
16
+ }
17
+ else {
18
+ list = await client.templates(type);
19
+ }
3
20
  if (opts.category) {
4
21
  const kw = opts.category.toLowerCase();
5
22
  list = list.filter((t) => (t.category || '').toLowerCase().includes(kw));
@@ -8,7 +25,13 @@ export async function templates(client, opts = {}) {
8
25
  process.stderr.write(opts.category ? `没有匹配「${opts.category}」的模板\n` : '没有可用模板\n');
9
26
  return;
10
27
  }
11
- const tag = (t) => (t.tenant_id ? '' : '[平台]');
28
+ const tag = (t) => {
29
+ if (t.source === 'personal')
30
+ return '[个人]';
31
+ if (t.tenant_id)
32
+ return '[租户]';
33
+ return '[平台]';
34
+ };
12
35
  const typeTag = (t) => (t.template_type === 'article' ? '[文字]' : t.template_type === 'image' ? '[图片]' : '');
13
36
  process.stderr.write(`可用模板(${list.length} 个):\n`);
14
37
  for (const t of list) {
@@ -19,7 +42,7 @@ export async function templates(client, opts = {}) {
19
42
  process.stderr.write(` ${t.id.padEnd(38)} ${(t.zh_name || '').padEnd(16)} ${(t.category || '').padEnd(10)} ${(t.ratio || '').padEnd(6)} ${typeTag(t).padEnd(8)} ${fieldHint.padEnd(20)} ${tag(t)}\n`);
20
43
  }
21
44
  process.stderr.write(`\n出图: museav gen --template <模板id> [--fields '{"key":"值"}']\n`);
22
- process.stderr.write(`按类型过滤: museav templates --type image|article\n`);
45
+ process.stderr.write(`筛选: --mine(我建的) --tenant(本租户) --platform(平台共享) --type image|article --category <分类>\n`);
23
46
  // stdout 只出 id,便于脚本与 agent 解析
24
47
  console.log(list.map((t) => t.id).join('\n'));
25
48
  }
@@ -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
  }
@@ -1,3 +1,9 @@
1
- /** museav whoami —— 查当前登录账户 + 租户归属 */
1
+ /** museav whoami —— 查当前身份 + 关联信息。
2
+ *
3
+ * apiKey 模式下也支持(2026-08-17 修复):服务端 /api/me 对两种 apiKey 都返回真实数据——
4
+ * - 平台账户 apiKey(STUDIO_API_KEY 指向 accounts.sk-*) → 走个人身份,含 credits / gen 统计
5
+ * - 租户 apiKey(指向 api_tenants.tenant_key) → 走业务身份,只下发 tenant 信息
6
+ * 个人 login(museav login)走完整 me,输出不变。
7
+ */
2
8
  import type { StudioClient } from '../client.js';
3
9
  export declare function whoami(client: StudioClient): Promise<void>;
@@ -1,14 +1,19 @@
1
- import { loadConfig } from '../config.js';
2
1
  export async function whoami(client) {
3
- // apiKey 是系统接入方/服务身份,不是个人账户,/api/me 不适用——直接提示,别等服务端 401
4
- const cfg = loadConfig();
5
- if (cfg.apiKey && !cfg.token) {
6
- throw new Error('whoami 只支持个人 login 身份:museav login\n' +
7
- '(apiKey 代表接入的业务系统,不是个人账户,可用 museav jobs / balance 查看业务数据)');
2
+ const me = (await client.me());
3
+ if (me.identity === 'tenant') {
4
+ const t = me.tenant;
5
+ process.stderr.write(`身份: 租户 API Key\n`);
6
+ process.stderr.write(`租户: ${t.nickname || t.name}(${t.name})\n`);
7
+ process.stderr.write(`tenant_id: ${t.id}\n`);
8
+ if (t.logo)
9
+ process.stderr.write(`logo: ${t.logo}\n`);
10
+ }
11
+ else {
12
+ process.stderr.write(`账户: ${me.nickname || me.email}(${me.email})\n`);
13
+ process.stderr.write(me.brand ? `业务系统: ${me.brand.name}\n` : `身份: 平台账户 API Key\n`);
14
+ process.stderr.write(`出图: 累计 ${me.gen_total ?? 0} 次,成功 ${me.gen_done ?? 0} 次\n`);
15
+ if (me.credits != null)
16
+ process.stderr.write(`credits: ${me.credits}\n`);
8
17
  }
9
- const me = await client.me();
10
- process.stderr.write(`账户: ${me.nickname || me.email}(${me.email})\n`);
11
- process.stderr.write(me.brand ? `业务系统: ${me.brand.name}\n` : '业务系统: 未接入(个人用户)\n');
12
- process.stderr.write(`出图: 累计 ${me.gen_total} 次,成功 ${me.gen_done} 次\n`);
13
18
  console.log(JSON.stringify(me));
14
19
  }
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('查可用模型列表')
@@ -262,6 +278,9 @@ const templatesCmd = program
262
278
  .description('查可用图片/文字模板:自己租户建的 + 平台共享的(跟技能是两套不同的机制,见 gen --template)')
263
279
  .option('--category <name>', '按分类过滤,如 电商白底图 / 演唱会')
264
280
  .option('--type <type>', '按类型过滤:image(图片) / article(文字),不传则两类都列并标注')
281
+ .option('--mine', '只看我这个人建的模板(created_by 是我,排除系统种子和租户专属)')
282
+ .option('--tenant', '只看本租户建的模板')
283
+ .option('--platform', '只看平台共享的模板')
265
284
  .action(withClient((client, opts) => templates(client, opts)));
266
285
  const videoTemplatesCmd = program
267
286
  .command('video-templates')