museav-cli 2.2.0 → 2.3.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
@@ -15,6 +15,8 @@ import { bindFeishu } from './commands/bind-feishu.js'
15
15
  import { printWelcome } from './commands/welcome.js'
16
16
  import { gen } from './commands/gen.js'
17
17
  import { reverse } from './commands/reverse.js'
18
+ import { compressCmd, removeBgCmd } from './commands/img-tools.js'
19
+ import { projects, createProject, listAssets, addAsset, removeAsset, resolveWorkspace } from './commands/projects.js'
18
20
  import { imageToTemplate } from './commands/image-to-template.js'
19
21
  import { upload } from './commands/upload.js'
20
22
  import { models } from './commands/models.js'
@@ -109,6 +111,18 @@ function withLazyClient(fn: (getClient: () => StudioClient, ...args: any[]) => P
109
111
  }
110
112
  }
111
113
 
114
+ // 本地命令(compress / remove-bg 等):不碰中台、不需要任何凭证,只包一层统一的错误出口
115
+ function asyncRun(fn: (...args: any[]) => Promise<any>) {
116
+ return async (...args: any[]) => {
117
+ try {
118
+ await fn(...args)
119
+ } catch (e) {
120
+ process.stderr.write(`❌ ${(e as Error).message}\n`)
121
+ process.exit(1)
122
+ }
123
+ }
124
+ }
125
+
112
126
  // products / assets 查的是租户自己后台的数据,不是 Studio 中台的,走独立的 TenantClient
113
127
  // (见 tenant-client.ts 顶部注释),只支持租户 apiKey 身份,不支持个人 login token。
114
128
  function withTenantClient<T extends (...args: any[]) => Promise<any>>(fn: T) {
@@ -155,8 +169,61 @@ program
155
169
  .option('--video', '生成视频(走 /api/videos 链路;模型档次如 artsdance-2-0-pro-260801,不传 --model 走 auto 路由)')
156
170
  .option('--duration <sec>', '视频时长(秒,仅 --video;由模型与上游支持范围决定)', (v) => Number(v))
157
171
  .option('--image <file>', '图生视频首帧图(仅 --video,自动上传)')
172
+ .option('--project <id|名>', '归档进该工作区(museav projects 查;账户身份才生效)')
158
173
  .action(withClient((client: StudioClient, opts: any) => gen(client, opts)))
159
174
 
175
+ program
176
+ .command('compress <file>')
177
+ .description('本地压缩图片(sharp,免登录):默认同目录 <名>-min.<格式>,不覆写原文件')
178
+ .option('--out <path>', '输出路径(默认 <名>-min.<格式>)')
179
+ .option('--max-edge <px>', '最长边缩到该像素(等比,inside)')
180
+ .option('--quality <1-100>', 'jpg/webp 质量,默认 82')
181
+ .option('--format <fmt>', '输出格式 jpg / png / webp(默认跟随原格式)')
182
+ .option('--overwrite', '允许覆盖已存在的输出文件')
183
+ .action(asyncRun((input: string, opts: any) => compressCmd(input, opts)))
184
+
185
+ program
186
+ .command('remove-bg <file>')
187
+ .description('本地抠图去背景(ISNet/U2Net + onnxruntime,免登录):输出带 alpha 的 PNG。首次使用自动下载模型(~170MB,缓存 ~/.museav-models)')
188
+ .option('--out <path>', '输出路径(默认 <名>-nobg.png)')
189
+ .option('--model <name>', 'isnet(默认,质量优先)/ u2net')
190
+ .option('--overwrite', '允许覆盖已存在的输出文件')
191
+ .action(asyncRun((input: string, opts: any) => removeBgCmd(input, opts)))
192
+
193
+ // 工作区(项目)与项目素材库:平台 → 账户 → 工作区三层归属,素材挂工作区
194
+ const projectsCmd = program
195
+ .command('projects')
196
+ .description('工作区(项目)管理:一个账户多个工作区,每个工作区有自己的素材库(人像库/产品库各管各的业务)')
197
+ .action(withClient((client: StudioClient) => projects(client)))
198
+
199
+ projectsCmd
200
+ .command('create')
201
+ .description('新建工作区(每账户最多 5 个)')
202
+ .requiredOption('--name <name>', '工作区名称(最多 20 字)')
203
+ .action(withClient((client: StudioClient, opts: any) => createProject(client, opts)))
204
+
205
+ const assetsCmd = projectsCmd
206
+ .command('assets')
207
+ .description('项目素材库:列出 / 上传 / 删除该工作区的素材(垫图母版,不压缩)')
208
+
209
+ assetsCmd
210
+ .description('列工作区素材库')
211
+ .option('--project <id|名>', '工作区 id 或名称(必填,不传会明确报错)')
212
+ .action(withClient((client: StudioClient, opts: any) => listAssets(client, opts)))
213
+
214
+ assetsCmd
215
+ .command('add <file>')
216
+ .description('上传素材进工作区素材库(图片/音频/视频,按字节判型;母版不压缩)')
217
+ .requiredOption('--project <id|名>', '工作区 id 或名称')
218
+ .option('--name <name>', '素材名,如「白T正面」')
219
+ .option('--tag <tag>', '标签,可重复(产品 / 人像 / 场景…)', (v: string, acc: string[]) => [...acc, v], [] as string[])
220
+ .action(withClient((client: StudioClient, file: string, opts: any) => addAsset(client, file, opts)))
221
+
222
+ assetsCmd
223
+ .command('rm <id>')
224
+ .description('删除素材(硬删:R2 对象 + 记录)')
225
+ .action(withClient((client: StudioClient, id: string) => removeAsset(client, { id })))
226
+
160
227
  program
161
228
  .command('reverse <input>')
162
229
  .description('读图:反推 SCULPT prompt,stdout 输出英文 prompt。主路本地 Ollama(qwen3-vl,快,无需登录);本地不可用回落中台 API(会提示较慢)。只读图;要做成模板用 image-to-template')
@@ -255,9 +322,10 @@ program
255
322
 
256
323
  program
257
324
  .command('jobs')
258
- .description('查自己名下的出图工作流(个人 login 看自己的;租户 apiKey 看业务下全部)——服务端固定返回最近 50 条,limit/status 是本地过滤')
325
+ .description('查自己名下的出图工作流(个人 login 看自己的;租户 apiKey 看业务下全部)——服务端固定返回最近 50 条,limit/status/project 是本地过滤')
259
326
  .option('--limit <n>', '最多显示几条(在最近 50 条以内截取),默认 20', '20')
260
327
  .option('--status <status>', '按状态过滤: pending / processing / done / failed(本地过滤,不是服务端查询)')
328
+ .option('--project <id|名>', '只看归档进该工作区的任务(本地过滤)')
261
329
  .action(withClient((client: StudioClient, opts: any) => jobs(client, opts)))
262
330
 
263
331
  program
@@ -0,0 +1,142 @@
1
+ /**
2
+ * 本地抠图(去背景)—— remove-bg 的核心实现。
3
+ * 模型走 ONNX(ISNet / U2Net,均 Apache-2.0),推理走 onnxruntime-node(MIT),
4
+ * 前后处理走 sharp —— 整条链路许可证干净(imgly 那个 npm 包是 AGPL,不进依赖),
5
+ * 且三个依赖在 macOS / Windows / Linux 都有预编译,无平台特化代码。
6
+ * 模型文件首次使用时下载到 ~/.museav-models/ 缓存(一次性 ~170MB)。
7
+ */
8
+ import { mkdir, writeFile, stat } from 'node:fs/promises'
9
+ import { join } from 'node:path'
10
+ import { homedir } from 'node:os'
11
+
12
+ export const BG_MODELS = {
13
+ isnet: {
14
+ file: 'isnet-general-use.onnx',
15
+ // rembg 官方 release 托管的同一份模型(Apache-2.0,源自 xuebinqin/DIS)
16
+ url: 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/isnet-general-use.onnx',
17
+ label: 'ISNet(通用,质量优先)',
18
+ },
19
+ u2net: {
20
+ file: 'u2net.onnx',
21
+ url: 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx',
22
+ label: 'U2Net(经典通用)',
23
+ },
24
+ } as const
25
+
26
+ export type BgModelKey = keyof typeof BG_MODELS
27
+
28
+ const MODEL_DIR = join(homedir(), '.museav-models')
29
+ const INPUT_EDGE = 1024
30
+
31
+ function modelPath(key: BgModelKey): string {
32
+ return join(MODEL_DIR, BG_MODELS[key].file)
33
+ }
34
+
35
+ /** 模型在位返回路径;不在则下载(流式,进度打 stderr)。下载失败抛 Error */
36
+ export async function ensureBgModel(key: BgModelKey): Promise<string> {
37
+ const dest = modelPath(key)
38
+ try {
39
+ const s = await stat(dest)
40
+ if (s.size > 10_000_000) return dest // 正常模型都是百 MB 级;太小的文件视为残缺重下
41
+ } catch {
42
+ // 不存在,走下载
43
+ }
44
+ await mkdir(MODEL_DIR, { recursive: true })
45
+ const def = BG_MODELS[key]
46
+ process.stderr.write(`↓ 首次使用,下载 ${def.label}(~170MB,一次性,缓存到 ${MODEL_DIR})...\n`)
47
+ const resp = await fetch(def.url)
48
+ if (!resp.ok || !resp.body) throw new Error(`模型下载失败 HTTP ${resp.status}:${def.url}`)
49
+ const total = Number(resp.headers.get('content-length') || 0)
50
+ const chunks: Buffer[] = []
51
+ let got = 0
52
+ const reader = resp.body.getReader()
53
+ for (;;) {
54
+ const { done, value } = await reader.read()
55
+ if (done) break
56
+ chunks.push(Buffer.from(value))
57
+ got += value.length
58
+ if (total) process.stderr.write(` ${((got / total) * 100).toFixed(0)}%\r`)
59
+ }
60
+ process.stderr.write('\n')
61
+ const buf = Buffer.concat(chunks)
62
+ if (buf.length < 10_000_000) throw new Error('模型下载不完整,请重试')
63
+ await writeFile(dest, buf)
64
+ return dest
65
+ }
66
+
67
+ /** 抠图主流程:输入图片路径 → 输出带 alpha 的 PNG Buffer */
68
+ export async function removeBackgroundLocal(inputPath: string, modelKey: BgModelKey): Promise<Buffer> {
69
+ // 动态加载:onnxruntime-node 是 optionalDependency,缺失时给安装指引而不是崩
70
+ let ort: typeof import('onnxruntime-node')
71
+ try {
72
+ ort = await import('onnxruntime-node')
73
+ } catch {
74
+ throw new Error('onnxruntime-node 不可用。重装 CLI 即可补上:npm install -g museav-cli')
75
+ }
76
+ const sharp = await loadSharpOrThrow()
77
+ const modelFile = await ensureBgModel(modelKey)
78
+ const session = await ort.InferenceSession.create(modelFile)
79
+
80
+ // ── 预处理:EXIF 转正、去 alpha、RGB raw ──
81
+ const { data: rgb, info } = await sharp(inputPath).rotate().removeAlpha().raw().toBuffer({ resolveWithObject: true })
82
+ if (info.channels !== 3) throw new Error(`预处理得到 ${info.channels} 通道(预期 3)`)
83
+
84
+ // ── 模型输入:拉伸到 1024×1024,(x/255 - 0.5)/0.5 归一化,HWC → CHW ──
85
+ const small = await sharp(rgb, { raw: { width: info.width, height: info.height, channels: 3 } })
86
+ .resize(INPUT_EDGE, INPUT_EDGE, { fit: 'fill' })
87
+ .raw()
88
+ .toBuffer()
89
+ const f32 = new Float32Array(3 * INPUT_EDGE * INPUT_EDGE)
90
+ const N = INPUT_EDGE * INPUT_EDGE
91
+ 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
95
+ }
96
+ const feeds: Record<string, import('onnxruntime-node').Tensor> = {}
97
+ feeds[session.inputNames[0]] = new ort.Tensor('float32', f32, [1, 3, INPUT_EDGE, INPUT_EDGE])
98
+ const results = await session.run(feeds)
99
+ const out = results[session.outputNames[0]]
100
+ const maskFlat = out.data as Float32Array
101
+ if (maskFlat.length < N) throw new Error(`模型输出尺寸异常(${maskFlat.length})`)
102
+
103
+ // ── 后处理:min-max 归一化到 0-255,再缩回原图尺寸 ──
104
+ let lo = Infinity
105
+ let hi = -Infinity
106
+ for (let i = 0; i < N; i++) {
107
+ const v = maskFlat[i]
108
+ if (v < lo) lo = v
109
+ if (v > hi) hi = v
110
+ }
111
+ const range = hi - lo || 1
112
+ 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' })
116
+ .raw()
117
+ .toBuffer()
118
+
119
+ // ── alpha 合成:直接构造 RGBA(alpha = mask),不依赖 composite 的混合语义 ──
120
+ const w = info.width
121
+ const h = info.height
122
+ const rgba = Buffer.alloc(w * h * 4)
123
+ for (let i = 0; i < w * h; i++) {
124
+ rgba[i * 4] = rgb[i * 3]
125
+ rgba[i * 4 + 1] = rgb[i * 3 + 1]
126
+ rgba[i * 4 + 2] = rgb[i * 3 + 2]
127
+ rgba[i * 4 + 3] = maskFull[i]
128
+ }
129
+ return sharp(rgba, { raw: { width: w, height: h, channels: 4 } }).png().toBuffer()
130
+ }
131
+
132
+ async function loadSharpOrThrow(): Promise<any> {
133
+ try {
134
+ const m = await import('sharp')
135
+ const sharp = (m as any).default ?? m
136
+ // 造 1px 图跑通全链路:native binding 坏了在第一次真用时才炸,这里提前暴露
137
+ await sharp({ create: { width: 1, height: 1, channels: 3, background: '#000' } }).raw().toBuffer()
138
+ return sharp
139
+ } catch {
140
+ throw new Error('sharp 不可用。重装 CLI 即可补上:npm install -g museav-cli')
141
+ }
142
+ }
@@ -28,6 +28,13 @@ export interface LocalVlmStatus {
28
28
  reason?: string
29
29
  }
30
30
 
31
+ // 各系统启动 Ollama 的正确姿势不同,提示语跟着平台走(Windows 没有 brew)
32
+ function ollamaStartHint(): string {
33
+ if (process.platform === 'win32') return '启动 Ollama 应用(开始菜单 / Ollama.exe),或命令行运行 ollama serve'
34
+ if (process.platform === 'darwin') return 'brew services start ollama,或 ollama serve'
35
+ return 'systemctl --user start ollama,或 ollama serve'
36
+ }
37
+
31
38
  /** 探活 + 模型在位检查。3 秒探不通就是没起服务,不等推理超时才发现 */
32
39
  export async function checkLocalVlm(): Promise<LocalVlmStatus> {
33
40
  const host = ollamaHost()
@@ -43,7 +50,7 @@ export async function checkLocalVlm(): Promise<LocalVlmStatus> {
43
50
  }
44
51
  return { running: true, modelPresent: true, host }
45
52
  } catch {
46
- return { running: false, modelPresent: false, host, reason: `Ollama 未运行(${host}),启动: ollama serve 或 brew services start ollama` }
53
+ return { running: false, modelPresent: false, host, reason: `Ollama 未运行(${host}),${ollamaStartHint()}` }
47
54
  }
48
55
  }
49
56