museav-cli 2.2.0 → 2.4.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.
@@ -5,6 +5,7 @@
5
5
  * 不需要额外传租户/用户 id——你拿的是谁的凭证,就是谁的数据。
6
6
  */
7
7
  import type { StudioClient, Job } from '../client.js'
8
+ import { resolveWorkspace } from './projects.js'
8
9
 
9
10
  const STATUS_MARK: Record<Job['status'], string> = {
10
11
  pending: '⏳',
@@ -13,9 +14,14 @@ const STATUS_MARK: Record<Job['status'], string> = {
13
14
  failed: '❌',
14
15
  }
15
16
 
16
- export async function jobs(client: StudioClient, opts: { limit?: string; status?: Job['status'] }): Promise<void> {
17
+ export async function jobs(client: StudioClient, opts: { limit?: string; status?: Job['status']; project?: string }): Promise<void> {
17
18
  const limit = opts.limit ? Number(opts.limit) : 20
18
- const list = await client.listJobs({ limit, status: opts.status })
19
+ let list = await client.listJobs({ limit, status: opts.status })
20
+ // --project 客户端过滤:服务端 jobs 不认 workspace 参数,在最近 50 条内筛
21
+ if (opts.project) {
22
+ const ws = await resolveWorkspace(client, opts.project)
23
+ list = list.filter((j) => (j as unknown as { workspace_id?: string | null }).workspace_id === ws.id)
24
+ }
19
25
 
20
26
  if (!list.length) {
21
27
  process.stderr.write('没有找到工作流记录\n')
@@ -0,0 +1,80 @@
1
+ /** museav projects —— 工作区(项目)与项目素材库。
2
+ * 层级:平台 → 账户 → 工作区;素材挂工作区,业务隔离互不污染。
3
+ * 「人像库的工作区出模特图、产品库的工作区出电商图」的载体就是这里。 */
4
+ import type { StudioClient, Workspace } from '../client.js'
5
+
6
+ /** --project <id|名> 的解析:id 精确命中,否则按名称匹配;找不到/歧义时把可选项列出来 */
7
+ export async function resolveWorkspace(client: StudioClient, idOrName: string): Promise<Workspace> {
8
+ const list = await client.workspaces()
9
+ const key = String(idOrName || '').trim()
10
+ if (!key) throw new Error('缺少 --project(工作区 id 或名称)')
11
+ const byId = list.find((w) => w.id === key)
12
+ if (byId) return byId
13
+ const byName = list.filter((w) => w.name === key)
14
+ if (byName.length === 1) return byName[0]
15
+ if (byName.length > 1) throw new Error(`重名工作区「${key}」,请用 id 指定:\n${list.map((w) => ` ${w.id} ${w.name}`).join('\n')}`)
16
+ throw new Error(`没有工作区「${key}」。现有:\n${list.map((w) => ` ${w.id} ${w.name}`).join('\n')}\n(museav projects create --name 可新建)`)
17
+ }
18
+
19
+ export async function projects(client: StudioClient): Promise<void> {
20
+ const list = await client.workspaces()
21
+ if (!list.length) {
22
+ process.stderr.write('还没有工作区(museav projects create --name 新建)\n')
23
+ return
24
+ }
25
+ process.stderr.write(`工作区(${list.length} 个):\n`)
26
+ for (const w of list) {
27
+ process.stderr.write(
28
+ ` ${w.id} ${w.name.padEnd(16)} 出图 ${w.gen_done ?? 0}/${w.gen_total ?? 0}${w.brand ? ` brand:${w.brand}` : ''}\n`,
29
+ )
30
+ }
31
+ process.stderr.write(`\n素材库: museav projects assets --project <id|名>\n出图归档: museav gen --project <id|名> ...\n`)
32
+ // stdout 只出 id,便于脚本解析
33
+ console.log(list.map((w) => w.id).join('\n'))
34
+ }
35
+
36
+ export async function createProject(client: StudioClient, opts: { name: string }): Promise<void> {
37
+ const name = (opts.name || '').trim()
38
+ if (!name) throw new Error('--name 必填')
39
+ const row = await client.createWorkspace(name)
40
+ process.stderr.write(`✅ 工作区已建:${row.id} ${row.name}\n`)
41
+ process.stderr.write(`传素材: museav projects assets add <file> --project ${row.id}\n`)
42
+ console.log(row.id)
43
+ }
44
+
45
+ export async function listAssets(client: StudioClient, opts: { project?: string }): Promise<void> {
46
+ const ws = await resolveWorkspace(client, opts.project || '')
47
+ const assets = await client.workspaceAssets(ws.id)
48
+ process.stderr.write(`「${ws.name}」素材库(${assets.length} 条):\n`)
49
+ for (const a of assets) {
50
+ const tag = a.tags?.length ? `[${a.tags.join(',')}]` : ''
51
+ process.stderr.write(` ${a.id} ${(a.name || '(未命名)').padEnd(16)} ${a.media_type.padEnd(5)} ${tag}\n`)
52
+ process.stderr.write(` ${a.cdn_url}\n`)
53
+ }
54
+ process.stderr.write(`\n垫图出图: museav gen --project ${ws.id} --ref <素材URL> --prompt '...'\n`)
55
+ // stdout:id<TAB>url 每行一条,agent 拿去直接当 --ref 用
56
+ console.log(assets.map((a) => `${a.id}\t${a.cdn_url}`).join('\n'))
57
+ }
58
+
59
+ export async function addAsset(
60
+ client: StudioClient,
61
+ file: string,
62
+ opts: { project?: string; name?: string; tag?: string[] },
63
+ ): Promise<void> {
64
+ const ws = await resolveWorkspace(client, opts.project || '')
65
+ const row = await client.addWorkspaceAsset({
66
+ file,
67
+ workspaceId: ws.id,
68
+ name: opts.name,
69
+ tags: opts.tag || [],
70
+ })
71
+ process.stderr.write(`✅ 已入「${ws.name}」素材库:${row.name || '(未命名)'} ${row.media_type}\n`)
72
+ process.stderr.write(`${row.cdn_url}\n`)
73
+ console.log(row.cdn_url)
74
+ }
75
+
76
+ export async function removeAsset(client: StudioClient, opts: { id: string }): Promise<void> {
77
+ await client.deleteWorkspaceAsset(opts.id)
78
+ process.stderr.write(`✅ 素材已删:${opts.id}\n`)
79
+ console.log(opts.id)
80
+ }
@@ -1,18 +1,19 @@
1
1
  /** museav reverse —— 图片逆向(SCULPT 六要素反推 prompt)。
2
- * 主路是本地 Ollamaqwen3-vl),快、零成本、无需登录;中台 API 是回落路,走回落时会明确提示较慢。
3
- * client 懒构造(getClient):本地路成功就完全不碰中台凭证。
4
- * 本地系统的 AI 能力统一收口在这个 CLI,reverse 是第一个本地化的能力。 */
2
+ * 默认走中台 API(快、稳定、不需本地模型);--local 可切本地 Ollama(需自备 qwen3-vl
3
+ * 仅在用户显式要求时使用——本地大模型默认不拉起,不给用户的内存添负担)。
4
+ * client 懒构造:本地路成功就完全不碰中台凭证。 */
5
5
  import type { StudioClient, ReverseResult } from '../client.js'
6
6
  import { checkLocalVlm, reverseLocally, LOCAL_VLM_MODEL } from '../local-vision.js'
7
7
 
8
8
  export async function reverse(
9
9
  getClient: () => StudioClient,
10
10
  input: string,
11
- opts: { api?: boolean } = {},
11
+ opts: { api?: boolean; local?: boolean } = {},
12
12
  ): Promise<void> {
13
13
  const isUrl = /^https?:\/\//.test(input)
14
14
 
15
- if (!opts.api && !isUrl) {
15
+ // 本地路只在用户显式 --local 且输入是本地文件时尝试;服务不可用给出指引后回落 API
16
+ if (opts.local && !isUrl) {
16
17
  const status = await checkLocalVlm()
17
18
  if (status.running && status.modelPresent) {
18
19
  try {
@@ -27,7 +28,7 @@ export async function reverse(
27
28
  } else {
28
29
  process.stderr.write(`⚠ 本地读图不可用(${status.reason}),回落中台 API —— 速度较慢,请耐心等待\n`)
29
30
  }
30
- } else if (!opts.api && isUrl) {
31
+ } else if (opts.local && isUrl) {
31
32
  process.stderr.write(`ℹ URL 输入走中台 API(本地路只收文件路径)\n`)
32
33
  }
33
34
 
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, upscaleCmd, removeWatermarkCmd } 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,12 +169,83 @@ 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
+ program
194
+ .command('upscale <file>')
195
+ .description('本地超分放大(Real-ESRGAN + Vulkan GPU,免登录):默认 4x 输出 PNG。首次使用自动下载引擎与模型(~65MB,缓存 ~/.museav-bin 与 ~/.museav-models)')
196
+ .option('--out <path>', '输出路径(默认 <名>-<N>x.png)')
197
+ .option('--scale <n>', '放大倍数 2 / 3 / 4,默认 4')
198
+ .option('--model <name>', 'realesrgan-x4plus(通用照片,默认)/ realesrgan-x4plus-anime(插画动漫)')
199
+ .option('--overwrite', '允许覆盖已存在的输出文件')
200
+ .action(asyncRun((input: string, opts: any) => upscaleCmd(input, opts)))
201
+
202
+ program
203
+ .command('remove-watermark <file>')
204
+ .description('本地去水印(免登录):纯像素启发式自动定位水印 → LaMa 掩码修复,零模型依赖。首次使用自动下载修复模型(~200MB);复杂画面用 --mask 手工指定(白=去除区)')
205
+ .option('--out <path>', '输出路径(默认 <名>-clean.png)')
206
+ .option('--mask <file>', '手工掩码图(白色=要去除的区域),跳过自动定位')
207
+ .option('--overwrite', '允许覆盖已存在的输出文件')
208
+ .action(asyncRun((input: string, opts: any) => removeWatermarkCmd(input, opts)))
209
+
210
+ // 工作区(项目)与项目素材库:平台 → 账户 → 工作区三层归属,素材挂工作区
211
+ const projectsCmd = program
212
+ .command('projects')
213
+ .description('工作区(项目)管理:一个账户多个工作区,每个工作区有自己的素材库(人像库/产品库各管各的业务)')
214
+ .action(withClient((client: StudioClient) => projects(client)))
215
+
216
+ projectsCmd
217
+ .command('create')
218
+ .description('新建工作区(每账户最多 5 个)')
219
+ .requiredOption('--name <name>', '工作区名称(最多 20 字)')
220
+ .action(withClient((client: StudioClient, opts: any) => createProject(client, opts)))
221
+
222
+ const assetsCmd = projectsCmd
223
+ .command('assets')
224
+ .description('项目素材库:列出 / 上传 / 删除该工作区的素材(垫图母版,不压缩)')
225
+
226
+ assetsCmd
227
+ .description('列工作区素材库')
228
+ .option('--project <id|名>', '工作区 id 或名称(必填,不传会明确报错)')
229
+ .action(withClient((client: StudioClient, opts: any) => listAssets(client, opts)))
230
+
231
+ assetsCmd
232
+ .command('add <file>')
233
+ .description('上传素材进工作区素材库(图片/音频/视频,按字节判型;母版不压缩)')
234
+ .requiredOption('--project <id|名>', '工作区 id 或名称')
235
+ .option('--name <name>', '素材名,如「白T正面」')
236
+ .option('--tag <tag>', '标签,可重复(产品 / 人像 / 场景…)', (v: string, acc: string[]) => [...acc, v], [] as string[])
237
+ .action(withClient((client: StudioClient, file: string, opts: any) => addAsset(client, file, opts)))
238
+
239
+ assetsCmd
240
+ .command('rm <id>')
241
+ .description('删除素材(硬删:R2 对象 + 记录)')
242
+ .action(withClient((client: StudioClient, id: string) => removeAsset(client, { id })))
243
+
160
244
  program
161
245
  .command('reverse <input>')
162
- .description('读图:反推 SCULPT prompt,stdout 输出英文 prompt。主路本地 Ollamaqwen3-vl,快,无需登录);本地不可用回落中台 API(会提示较慢)。只读图;要做成模板用 image-to-template')
163
- .option('--api', '跳过本地 Ollama,强制走中台 API(慢,需登录)')
246
+ .description('读图:反推 SCULPT prompt,stdout 输出英文 prompt。默认走中台 API(需登录);--local 显式切本地 Ollama(需自备 qwen3-vl)。只读图;要做成模板用 image-to-template')
247
+ .option('--api', '强制走中台 API(默认路径)')
248
+ .option('--local', '改用本地 Ollama 读图(需先 ollama pull qwen3-vl:8b;本地不可用时回落 API)')
164
249
  .action(withLazyClient((getClient: () => StudioClient, input: string, opts: any) => reverse(getClient, input, opts)))
165
250
 
166
251
  program
@@ -255,9 +340,10 @@ program
255
340
 
256
341
  program
257
342
  .command('jobs')
258
- .description('查自己名下的出图工作流(个人 login 看自己的;租户 apiKey 看业务下全部)——服务端固定返回最近 50 条,limit/status 是本地过滤')
343
+ .description('查自己名下的出图工作流(个人 login 看自己的;租户 apiKey 看业务下全部)——服务端固定返回最近 50 条,limit/status/project 是本地过滤')
259
344
  .option('--limit <n>', '最多显示几条(在最近 50 条以内截取),默认 20', '20')
260
345
  .option('--status <status>', '按状态过滤: pending / processing / done / failed(本地过滤,不是服务端查询)')
346
+ .option('--project <id|名>', '只看归档进该工作区的任务(本地过滤)')
261
347
  .action(withClient((client: StudioClient, opts: any) => jobs(client, opts)))
262
348
 
263
349
  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
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * 本地超分(放大)—— upscale 的核心实现。
3
+ * 引擎:upscayl-ncnn(Real-ESRGAN 的 ncnn/Vulkan 后端,AGPL-3.0 —— 它是独立进程
4
+ * 二进制而非链接进 npm 包,CLI 与之分发解耦,不构成合并作品;这与把 AGPL 代码
5
+ * 编进依赖是两回事)。
6
+ * 跨平台:macOS(universal)/ Windows / Linux 二进制都在 upscayl 官方 release;
7
+ * 解压统一走 `tar -xf`(Win10+/macOS/Linux 都自带 libarchive 版 tar,不依赖 unzip);
8
+ * 二进制落地后 chmod +x(Windows 不需要)。全程 node:child_process execFile,零 shell。
9
+ */
10
+ import { mkdir, writeFile, stat, chmod } from 'node:fs/promises'
11
+ import { join } from 'node:path'
12
+ import { homedir } from 'node:os'
13
+ import { execFile } from 'node:child_process'
14
+ import { promisify } from 'node:util'
15
+ const run = promisify(execFile)
16
+
17
+ // 锁定已实测的版本(20251207-174704,macOS universal 实测可用),升级要重新过测试
18
+ const UPSCAYL_TAG = '20251207-174704'
19
+ const UPSCAYL_BASE = `https://github.com/upscayl/upscayl-ncnn/releases/download/${UPSCAYL_TAG}/upscayl-bin-${UPSCAYL_TAG}`
20
+ // 模型从 Real-ESRGAN 官方 release 的 zip 里取(只取需要的两个,别拖全量)
21
+ const MODEL_ZIP = 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesrgan-ncnn-vulkan-20220424-macos.zip'
22
+
23
+ export const UPSCALE_MODELS = {
24
+ 'realesrgan-x4plus': { label: '通用照片(默认)' },
25
+ 'realesrgan-x4plus-anime': { label: '插画/动漫' },
26
+ } as const
27
+ export type UpscaleModel = keyof typeof UPSCALE_MODELS
28
+
29
+ const BIN_DIR = join(homedir(), '.museav-bin', 'upscayl')
30
+ const MODEL_DIR = join(homedir(), '.museav-models')
31
+
32
+ function platformAsset(): { zip: string; exe: string } {
33
+ if (process.platform === 'win32') return { zip: `${UPSCAYL_BASE}-windows.zip`, exe: 'upscayl-bin.exe' }
34
+ if (process.platform === 'darwin') return { zip: `${UPSCAYL_BASE}-macos.zip`, exe: 'upscayl-bin' }
35
+ return { zip: `${UPSCAYL_BASE}-linux.zip`, exe: 'upscayl-bin' }
36
+ }
37
+
38
+ async function exists(path: string): Promise<boolean> {
39
+ try {
40
+ await stat(path)
41
+ return true
42
+ } catch {
43
+ return false
44
+ }
45
+ }
46
+
47
+ async function download(url: string, dest: string, label: string): Promise<void> {
48
+ const resp = await fetch(url)
49
+ if (!resp.ok || !resp.body) throw new Error(`${label} 下载失败 HTTP ${resp.status}`)
50
+ const total = Number(resp.headers.get('content-length') || 0)
51
+ const chunks: Buffer[] = []
52
+ let got = 0
53
+ const reader = resp.body.getReader()
54
+ for (;;) {
55
+ const { done, value } = await reader.read()
56
+ if (done) break
57
+ chunks.push(Buffer.from(value))
58
+ got += value.length
59
+ if (total) process.stderr.write(` ${label} ${(got / 1048576).toFixed(1)}/${(total / 1048576).toFixed(0)}MB\r`)
60
+ }
61
+ process.stderr.write('\n')
62
+ await writeFile(dest, Buffer.concat(chunks))
63
+ }
64
+
65
+ /** 首次使用时准备好二进制与模型,返回 { exe, modelDir }。之后直接走缓存 */
66
+ export async function ensureUpscaleRuntime(): Promise<{ exe: string; modelDir: string }> {
67
+ const { zip, exe } = platformAsset()
68
+ const exePath = join(BIN_DIR, exe)
69
+ const modelDir = join(MODEL_DIR, 'realesrgan')
70
+ const paramPath = join(modelDir, 'realesrgan-x4plus.param')
71
+
72
+ if (!(await exists(exePath))) {
73
+ await mkdir(BIN_DIR, { recursive: true })
74
+ const zipPath = join(BIN_DIR, `dl-${process.platform}.zip`)
75
+ process.stderr.write(`↓ 首次使用,下载超分引擎(~15MB,一次性,缓存到 ${BIN_DIR})...\n`)
76
+ await download(zip, zipPath, '引擎')
77
+ // tar -xf 解压:Win10+/macOS/Linux 自带,比依赖 unzip 稳
78
+ await run('tar', ['-xf', zipPath, '-C', BIN_DIR], { windowsHide: true })
79
+ // zip 里是 upscayl-bin-<tag>-<os>/upscayl-bin,拍平到 BIN_DIR
80
+ const { readdir } = await import('node:fs/promises')
81
+ for (const entry of await readdir(BIN_DIR, { withFileTypes: true })) {
82
+ if (entry.isDirectory()) {
83
+ const { rename, readdir: rd } = await import('node:fs/promises')
84
+ for (const f of await rd(join(BIN_DIR, entry.name))) {
85
+ await rename(join(BIN_DIR, entry.name, f), join(BIN_DIR, f))
86
+ }
87
+ }
88
+ }
89
+ if (process.platform !== 'win32') await chmod(exePath, 0o755)
90
+ if (!(await exists(exePath))) throw new Error(`解压后未找到 ${exe},请检查 ${BIN_DIR}`)
91
+ const { unlink } = await import('node:fs/promises')
92
+ await unlink(zipPath).catch(() => {})
93
+ }
94
+
95
+ if (!(await exists(paramPath))) {
96
+ await mkdir(modelDir, { recursive: true })
97
+ const zipPath = join(MODEL_DIR, 'dl-models.zip')
98
+ process.stderr.write('↓ 首次使用,下载超分模型(~50MB,一次性)...\n')
99
+ await download(MODEL_ZIP, zipPath, '模型')
100
+ await run('tar', ['-xf', zipPath, '-C', MODEL_DIR, 'models/realesrgan-x4plus.param', 'models/realesrgan-x4plus.bin', 'models/realesrgan-x4plus-anime.param', 'models/realesrgan-x4plus-anime.bin'], { windowsHide: true })
101
+ const { rename, rm } = await import('node:fs/promises')
102
+ for (const f of ['realesrgan-x4plus.param', 'realesrgan-x4plus.bin', 'realesrgan-x4plus-anime.param', 'realesrgan-x4plus-anime.bin']) {
103
+ await rename(join(MODEL_DIR, 'models', f), join(modelDir, f)).catch(() => {})
104
+ }
105
+ await rm(join(MODEL_DIR, 'models'), { recursive: true, force: true }).catch(() => {})
106
+ await rm(zipPath, { force: true }).catch(() => {})
107
+ if (!(await exists(paramPath))) throw new Error(`模型解压失败,请检查 ${modelDir}`)
108
+ }
109
+
110
+ return { exe: exePath, modelDir }
111
+ }
112
+
113
+ /** 超分主流程:返回输出文件的字节数组由引擎直写磁盘,这里只负责调度 */
114
+ export async function upscaleLocal(opts: {
115
+ input: string
116
+ output: string
117
+ scale: number
118
+ model: UpscaleModel
119
+ }): Promise<void> {
120
+ const { exe, modelDir } = await ensureUpscaleRuntime()
121
+ // 不走 shell 拼接;路径原样传参,空格/中文路径都安全
122
+ await run(exe, ['-i', opts.input, '-o', opts.output, '-s', String(opts.scale), '-n', opts.model, '-m', modelDir], { windowsHide: true })
123
+ }
@@ -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