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.
package/dist/index.js 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 } 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) {
109
111
  }
110
112
  };
111
113
  }
114
+ // 本地命令(compress / remove-bg 等):不碰中台、不需要任何凭证,只包一层统一的错误出口
115
+ function asyncRun(fn) {
116
+ return async (...args) => {
117
+ try {
118
+ await fn(...args);
119
+ }
120
+ catch (e) {
121
+ process.stderr.write(`❌ ${e.message}\n`);
122
+ process.exit(1);
123
+ }
124
+ };
125
+ }
112
126
  // products / assets 查的是租户自己后台的数据,不是 Studio 中台的,走独立的 TenantClient
113
127
  // (见 tenant-client.ts 顶部注释),只支持租户 apiKey 身份,不支持个人 login token。
114
128
  function withTenantClient(fn) {
@@ -152,11 +166,72 @@ program
152
166
  .option('--video', '生成视频(走 /api/videos 链路;模型档次如 artsdance-2-0-pro-260801,不传 --model 走 auto 路由)')
153
167
  .option('--duration <sec>', '视频时长(秒,仅 --video;由模型与上游支持范围决定)', (v) => Number(v))
154
168
  .option('--image <file>', '图生视频首帧图(仅 --video,自动上传)')
169
+ .option('--project <id|名>', '归档进该工作区(museav projects 查;账户身份才生效)')
155
170
  .action(withClient((client, opts) => gen(client, opts)));
171
+ program
172
+ .command('compress <file>')
173
+ .description('本地压缩图片(sharp,免登录):默认同目录 <名>-min.<格式>,不覆写原文件')
174
+ .option('--out <path>', '输出路径(默认 <名>-min.<格式>)')
175
+ .option('--max-edge <px>', '最长边缩到该像素(等比,inside)')
176
+ .option('--quality <1-100>', 'jpg/webp 质量,默认 82')
177
+ .option('--format <fmt>', '输出格式 jpg / png / webp(默认跟随原格式)')
178
+ .option('--overwrite', '允许覆盖已存在的输出文件')
179
+ .action(asyncRun((input, opts) => compressCmd(input, opts)));
180
+ program
181
+ .command('remove-bg <file>')
182
+ .description('本地抠图去背景(ISNet/U2Net + onnxruntime,免登录):输出带 alpha 的 PNG。首次使用自动下载模型(~170MB,缓存 ~/.museav-models)')
183
+ .option('--out <path>', '输出路径(默认 <名>-nobg.png)')
184
+ .option('--model <name>', 'isnet(默认,质量优先)/ u2net')
185
+ .option('--overwrite', '允许覆盖已存在的输出文件')
186
+ .action(asyncRun((input, opts) => removeBgCmd(input, opts)));
187
+ program
188
+ .command('upscale <file>')
189
+ .description('本地超分放大(Real-ESRGAN + Vulkan GPU,免登录):默认 4x 输出 PNG。首次使用自动下载引擎与模型(~65MB,缓存 ~/.museav-bin 与 ~/.museav-models)')
190
+ .option('--out <path>', '输出路径(默认 <名>-<N>x.png)')
191
+ .option('--scale <n>', '放大倍数 2 / 3 / 4,默认 4')
192
+ .option('--model <name>', 'realesrgan-x4plus(通用照片,默认)/ realesrgan-x4plus-anime(插画动漫)')
193
+ .option('--overwrite', '允许覆盖已存在的输出文件')
194
+ .action(asyncRun((input, opts) => upscaleCmd(input, opts)));
195
+ program
196
+ .command('remove-watermark <file>')
197
+ .description('本地去水印(免登录):纯像素启发式自动定位水印 → LaMa 掩码修复,零模型依赖。首次使用自动下载修复模型(~200MB);复杂画面用 --mask 手工指定(白=去除区)')
198
+ .option('--out <path>', '输出路径(默认 <名>-clean.png)')
199
+ .option('--mask <file>', '手工掩码图(白色=要去除的区域),跳过自动定位')
200
+ .option('--overwrite', '允许覆盖已存在的输出文件')
201
+ .action(asyncRun((input, opts) => removeWatermarkCmd(input, opts)));
202
+ // 工作区(项目)与项目素材库:平台 → 账户 → 工作区三层归属,素材挂工作区
203
+ const projectsCmd = program
204
+ .command('projects')
205
+ .description('工作区(项目)管理:一个账户多个工作区,每个工作区有自己的素材库(人像库/产品库各管各的业务)')
206
+ .action(withClient((client) => projects(client)));
207
+ projectsCmd
208
+ .command('create')
209
+ .description('新建工作区(每账户最多 5 个)')
210
+ .requiredOption('--name <name>', '工作区名称(最多 20 字)')
211
+ .action(withClient((client, opts) => createProject(client, opts)));
212
+ const assetsCmd = projectsCmd
213
+ .command('assets')
214
+ .description('项目素材库:列出 / 上传 / 删除该工作区的素材(垫图母版,不压缩)');
215
+ assetsCmd
216
+ .description('列工作区素材库')
217
+ .option('--project <id|名>', '工作区 id 或名称(必填,不传会明确报错)')
218
+ .action(withClient((client, opts) => listAssets(client, opts)));
219
+ assetsCmd
220
+ .command('add <file>')
221
+ .description('上传素材进工作区素材库(图片/音频/视频,按字节判型;母版不压缩)')
222
+ .requiredOption('--project <id|名>', '工作区 id 或名称')
223
+ .option('--name <name>', '素材名,如「白T正面」')
224
+ .option('--tag <tag>', '标签,可重复(产品 / 人像 / 场景…)', (v, acc) => [...acc, v], [])
225
+ .action(withClient((client, file, opts) => addAsset(client, file, opts)));
226
+ assetsCmd
227
+ .command('rm <id>')
228
+ .description('删除素材(硬删:R2 对象 + 记录)')
229
+ .action(withClient((client, id) => removeAsset(client, { id })));
156
230
  program
157
231
  .command('reverse <input>')
158
- .description('读图:反推 SCULPT prompt,stdout 输出英文 prompt。主路本地 Ollamaqwen3-vl,快,无需登录);本地不可用回落中台 API(会提示较慢)。只读图;要做成模板用 image-to-template')
159
- .option('--api', '跳过本地 Ollama,强制走中台 API(慢,需登录)')
232
+ .description('读图:反推 SCULPT prompt,stdout 输出英文 prompt。默认走中台 API(需登录);--local 显式切本地 Ollama(需自备 qwen3-vl)。只读图;要做成模板用 image-to-template')
233
+ .option('--api', '强制走中台 API(默认路径)')
234
+ .option('--local', '改用本地 Ollama 读图(需先 ollama pull qwen3-vl:8b;本地不可用时回落 API)')
160
235
  .action(withLazyClient((getClient, input, opts) => reverse(getClient, input, opts)));
161
236
  program
162
237
  .command('image-to-template <input>')
@@ -235,9 +310,10 @@ program
235
310
  .action(withClient((client) => balance(client)));
236
311
  program
237
312
  .command('jobs')
238
- .description('查自己名下的出图工作流(个人 login 看自己的;租户 apiKey 看业务下全部)——服务端固定返回最近 50 条,limit/status 是本地过滤')
313
+ .description('查自己名下的出图工作流(个人 login 看自己的;租户 apiKey 看业务下全部)——服务端固定返回最近 50 条,limit/status/project 是本地过滤')
239
314
  .option('--limit <n>', '最多显示几条(在最近 50 条以内截取),默认 20', '20')
240
315
  .option('--status <status>', '按状态过滤: pending / processing / done / failed(本地过滤,不是服务端查询)')
316
+ .option('--project <id|名>', '只看归档进该工作区的任务(本地过滤)')
241
317
  .action(withClient((client, opts) => jobs(client, opts)));
242
318
  program
243
319
  .command('whoami')
@@ -0,0 +1,17 @@
1
+ export declare const BG_MODELS: {
2
+ readonly isnet: {
3
+ readonly file: 'isnet-general-use.onnx';
4
+ readonly url: 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/isnet-general-use.onnx';
5
+ readonly label: 'ISNet(通用,质量优先)';
6
+ };
7
+ readonly u2net: {
8
+ readonly file: 'u2net.onnx';
9
+ readonly url: 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx';
10
+ readonly label: 'U2Net(经典通用)';
11
+ };
12
+ };
13
+ export type BgModelKey = keyof typeof BG_MODELS;
14
+ /** 模型在位返回路径;不在则下载(流式,进度打 stderr)。下载失败抛 Error */
15
+ export declare function ensureBgModel(key: BgModelKey): Promise<string>;
16
+ /** 抠图主流程:输入图片路径 → 输出带 alpha 的 PNG Buffer */
17
+ export declare function removeBackgroundLocal(inputPath: string, modelKey: BgModelKey): Promise<Buffer>;
@@ -0,0 +1,143 @@
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
+ export const BG_MODELS = {
12
+ isnet: {
13
+ file: 'isnet-general-use.onnx',
14
+ // rembg 官方 release 托管的同一份模型(Apache-2.0,源自 xuebinqin/DIS)
15
+ url: 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/isnet-general-use.onnx',
16
+ label: 'ISNet(通用,质量优先)',
17
+ },
18
+ u2net: {
19
+ file: 'u2net.onnx',
20
+ url: 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx',
21
+ label: 'U2Net(经典通用)',
22
+ },
23
+ };
24
+ const MODEL_DIR = join(homedir(), '.museav-models');
25
+ const INPUT_EDGE = 1024;
26
+ function modelPath(key) {
27
+ return join(MODEL_DIR, BG_MODELS[key].file);
28
+ }
29
+ /** 模型在位返回路径;不在则下载(流式,进度打 stderr)。下载失败抛 Error */
30
+ export async function ensureBgModel(key) {
31
+ const dest = modelPath(key);
32
+ try {
33
+ const s = await stat(dest);
34
+ if (s.size > 10_000_000)
35
+ return dest; // 正常模型都是百 MB 级;太小的文件视为残缺重下
36
+ }
37
+ catch {
38
+ // 不存在,走下载
39
+ }
40
+ await mkdir(MODEL_DIR, { recursive: true });
41
+ const def = BG_MODELS[key];
42
+ process.stderr.write(`↓ 首次使用,下载 ${def.label}(~170MB,一次性,缓存到 ${MODEL_DIR})...\n`);
43
+ const resp = await fetch(def.url);
44
+ if (!resp.ok || !resp.body)
45
+ throw new Error(`模型下载失败 HTTP ${resp.status}:${def.url}`);
46
+ const total = Number(resp.headers.get('content-length') || 0);
47
+ const chunks = [];
48
+ let got = 0;
49
+ const reader = resp.body.getReader();
50
+ for (;;) {
51
+ const { done, value } = await reader.read();
52
+ if (done)
53
+ break;
54
+ chunks.push(Buffer.from(value));
55
+ got += value.length;
56
+ if (total)
57
+ process.stderr.write(` ${((got / total) * 100).toFixed(0)}%\r`);
58
+ }
59
+ process.stderr.write('\n');
60
+ const buf = Buffer.concat(chunks);
61
+ if (buf.length < 10_000_000)
62
+ throw new Error('模型下载不完整,请重试');
63
+ await writeFile(dest, buf);
64
+ return dest;
65
+ }
66
+ /** 抠图主流程:输入图片路径 → 输出带 alpha 的 PNG Buffer */
67
+ export async function removeBackgroundLocal(inputPath, modelKey) {
68
+ // 动态加载:onnxruntime-node 是 optionalDependency,缺失时给安装指引而不是崩
69
+ let ort;
70
+ try {
71
+ ort = await import('onnxruntime-node');
72
+ }
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
+ // ── 预处理:EXIF 转正、去 alpha、RGB raw ──
80
+ const { data: rgb, info } = await sharp(inputPath).rotate().removeAlpha().raw().toBuffer({ resolveWithObject: true });
81
+ if (info.channels !== 3)
82
+ throw new Error(`预处理得到 ${info.channels} 通道(预期 3)`);
83
+ // ── 模型输入:拉伸到 1024×1024,(x/255 - 0.5)/0.5 归一化,HWC → CHW ──
84
+ const small = await sharp(rgb, { raw: { width: info.width, height: info.height, channels: 3 } })
85
+ .resize(INPUT_EDGE, INPUT_EDGE, { fit: 'fill' })
86
+ .raw()
87
+ .toBuffer();
88
+ const f32 = new Float32Array(3 * INPUT_EDGE * INPUT_EDGE);
89
+ const N = INPUT_EDGE * INPUT_EDGE;
90
+ 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;
94
+ }
95
+ const feeds = {};
96
+ feeds[session.inputNames[0]] = new ort.Tensor('float32', f32, [1, 3, INPUT_EDGE, INPUT_EDGE]);
97
+ const results = await session.run(feeds);
98
+ const out = results[session.outputNames[0]];
99
+ const maskFlat = out.data;
100
+ if (maskFlat.length < N)
101
+ throw new Error(`模型输出尺寸异常(${maskFlat.length})`);
102
+ // ── 后处理:min-max 归一化到 0-255,再缩回原图尺寸 ──
103
+ let lo = Infinity;
104
+ let hi = -Infinity;
105
+ for (let i = 0; i < N; i++) {
106
+ const v = maskFlat[i];
107
+ if (v < lo)
108
+ lo = v;
109
+ if (v > hi)
110
+ hi = v;
111
+ }
112
+ const range = hi - lo || 1;
113
+ 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' })
118
+ .raw()
119
+ .toBuffer();
120
+ // ── alpha 合成:直接构造 RGBA(alpha = mask),不依赖 composite 的混合语义 ──
121
+ const w = info.width;
122
+ const h = info.height;
123
+ const rgba = Buffer.alloc(w * h * 4);
124
+ for (let i = 0; i < w * h; i++) {
125
+ rgba[i * 4] = rgb[i * 3];
126
+ rgba[i * 4 + 1] = rgb[i * 3 + 1];
127
+ rgba[i * 4 + 2] = rgb[i * 3 + 2];
128
+ rgba[i * 4 + 3] = maskFull[i];
129
+ }
130
+ return sharp(rgba, { raw: { width: w, height: h, channels: 4 } }).png().toBuffer();
131
+ }
132
+ async function loadSharpOrThrow() {
133
+ try {
134
+ const m = await import('sharp');
135
+ const sharp = m.default ?? m;
136
+ // 造 1px 图跑通全链路:native binding 坏了在第一次真用时才炸,这里提前暴露
137
+ await sharp({ create: { width: 1, height: 1, channels: 3, background: '#000' } }).raw().toBuffer();
138
+ return sharp;
139
+ }
140
+ catch {
141
+ throw new Error('sharp 不可用。重装 CLI 即可补上:npm install -g museav-cli');
142
+ }
143
+ }
@@ -0,0 +1,21 @@
1
+ export declare const UPSCALE_MODELS: {
2
+ readonly 'realesrgan-x4plus': {
3
+ readonly label: '通用照片(默认)';
4
+ };
5
+ readonly 'realesrgan-x4plus-anime': {
6
+ readonly label: '插画/动漫';
7
+ };
8
+ };
9
+ export type UpscaleModel = keyof typeof UPSCALE_MODELS;
10
+ /** 首次使用时准备好二进制与模型,返回 { exe, modelDir }。之后直接走缓存 */
11
+ export declare function ensureUpscaleRuntime(): Promise<{
12
+ exe: string;
13
+ modelDir: string;
14
+ }>;
15
+ /** 超分主流程:返回输出文件的字节数组由引擎直写磁盘,这里只负责调度 */
16
+ export declare function upscaleLocal(opts: {
17
+ input: string;
18
+ output: string;
19
+ scale: number;
20
+ model: UpscaleModel;
21
+ }): Promise<void>;
@@ -0,0 +1,115 @@
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
+ // 锁定已实测的版本(20251207-174704,macOS universal 实测可用),升级要重新过测试
17
+ const UPSCAYL_TAG = '20251207-174704';
18
+ const UPSCAYL_BASE = `https://github.com/upscayl/upscayl-ncnn/releases/download/${UPSCAYL_TAG}/upscayl-bin-${UPSCAYL_TAG}`;
19
+ // 模型从 Real-ESRGAN 官方 release 的 zip 里取(只取需要的两个,别拖全量)
20
+ const MODEL_ZIP = 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesrgan-ncnn-vulkan-20220424-macos.zip';
21
+ export const UPSCALE_MODELS = {
22
+ 'realesrgan-x4plus': { label: '通用照片(默认)' },
23
+ 'realesrgan-x4plus-anime': { label: '插画/动漫' },
24
+ };
25
+ const BIN_DIR = join(homedir(), '.museav-bin', 'upscayl');
26
+ const MODEL_DIR = join(homedir(), '.museav-models');
27
+ function platformAsset() {
28
+ if (process.platform === 'win32')
29
+ return { zip: `${UPSCAYL_BASE}-windows.zip`, exe: 'upscayl-bin.exe' };
30
+ if (process.platform === 'darwin')
31
+ return { zip: `${UPSCAYL_BASE}-macos.zip`, exe: 'upscayl-bin' };
32
+ return { zip: `${UPSCAYL_BASE}-linux.zip`, exe: 'upscayl-bin' };
33
+ }
34
+ async function exists(path) {
35
+ try {
36
+ await stat(path);
37
+ return true;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ async function download(url, dest, label) {
44
+ const resp = await fetch(url);
45
+ if (!resp.ok || !resp.body)
46
+ throw new Error(`${label} 下载失败 HTTP ${resp.status}`);
47
+ const total = Number(resp.headers.get('content-length') || 0);
48
+ const chunks = [];
49
+ let got = 0;
50
+ const reader = resp.body.getReader();
51
+ for (;;) {
52
+ const { done, value } = await reader.read();
53
+ if (done)
54
+ break;
55
+ chunks.push(Buffer.from(value));
56
+ got += value.length;
57
+ if (total)
58
+ process.stderr.write(` ${label} ${(got / 1048576).toFixed(1)}/${(total / 1048576).toFixed(0)}MB\r`);
59
+ }
60
+ process.stderr.write('\n');
61
+ await writeFile(dest, Buffer.concat(chunks));
62
+ }
63
+ /** 首次使用时准备好二进制与模型,返回 { exe, modelDir }。之后直接走缓存 */
64
+ export async function ensureUpscaleRuntime() {
65
+ const { zip, exe } = platformAsset();
66
+ const exePath = join(BIN_DIR, exe);
67
+ const modelDir = join(MODEL_DIR, 'realesrgan');
68
+ const paramPath = join(modelDir, 'realesrgan-x4plus.param');
69
+ if (!(await exists(exePath))) {
70
+ await mkdir(BIN_DIR, { recursive: true });
71
+ const zipPath = join(BIN_DIR, `dl-${process.platform}.zip`);
72
+ process.stderr.write(`↓ 首次使用,下载超分引擎(~15MB,一次性,缓存到 ${BIN_DIR})...\n`);
73
+ await download(zip, zipPath, '引擎');
74
+ // tar -xf 解压:Win10+/macOS/Linux 自带,比依赖 unzip 稳
75
+ await run('tar', ['-xf', zipPath, '-C', BIN_DIR], { windowsHide: true });
76
+ // zip 里是 upscayl-bin-<tag>-<os>/upscayl-bin,拍平到 BIN_DIR
77
+ const { readdir } = await import('node:fs/promises');
78
+ for (const entry of await readdir(BIN_DIR, { withFileTypes: true })) {
79
+ if (entry.isDirectory()) {
80
+ const { rename, readdir: rd } = await import('node:fs/promises');
81
+ for (const f of await rd(join(BIN_DIR, entry.name))) {
82
+ await rename(join(BIN_DIR, entry.name, f), join(BIN_DIR, f));
83
+ }
84
+ }
85
+ }
86
+ if (process.platform !== 'win32')
87
+ await chmod(exePath, 0o755);
88
+ if (!(await exists(exePath)))
89
+ throw new Error(`解压后未找到 ${exe},请检查 ${BIN_DIR}`);
90
+ const { unlink } = await import('node:fs/promises');
91
+ await unlink(zipPath).catch(() => { });
92
+ }
93
+ if (!(await exists(paramPath))) {
94
+ await mkdir(modelDir, { recursive: true });
95
+ const zipPath = join(MODEL_DIR, 'dl-models.zip');
96
+ process.stderr.write('↓ 首次使用,下载超分模型(~50MB,一次性)...\n');
97
+ await download(MODEL_ZIP, zipPath, '模型');
98
+ 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 });
99
+ const { rename, rm } = await import('node:fs/promises');
100
+ for (const f of ['realesrgan-x4plus.param', 'realesrgan-x4plus.bin', 'realesrgan-x4plus-anime.param', 'realesrgan-x4plus-anime.bin']) {
101
+ await rename(join(MODEL_DIR, 'models', f), join(modelDir, f)).catch(() => { });
102
+ }
103
+ await rm(join(MODEL_DIR, 'models'), { recursive: true, force: true }).catch(() => { });
104
+ await rm(zipPath, { force: true }).catch(() => { });
105
+ if (!(await exists(paramPath)))
106
+ throw new Error(`模型解压失败,请检查 ${modelDir}`);
107
+ }
108
+ return { exe: exePath, modelDir };
109
+ }
110
+ /** 超分主流程:返回输出文件的字节数组由引擎直写磁盘,这里只负责调度 */
111
+ export async function upscaleLocal(opts) {
112
+ const { exe, modelDir } = await ensureUpscaleRuntime();
113
+ // 不走 shell 拼接;路径原样传参,空格/中文路径都安全
114
+ await run(exe, ['-i', opts.input, '-o', opts.output, '-s', String(opts.scale), '-n', opts.model, '-m', modelDir], { windowsHide: true });
115
+ }
@@ -16,6 +16,14 @@ function ollamaHost() {
16
16
  host = `http://${host}`;
17
17
  return host.replace(/\/+$/, '');
18
18
  }
19
+ // 各系统启动 Ollama 的正确姿势不同,提示语跟着平台走(Windows 没有 brew)
20
+ function ollamaStartHint() {
21
+ if (process.platform === 'win32')
22
+ return '启动 Ollama 应用(开始菜单 / Ollama.exe),或命令行运行 ollama serve';
23
+ if (process.platform === 'darwin')
24
+ return 'brew services start ollama,或 ollama serve';
25
+ return 'systemctl --user start ollama,或 ollama serve';
26
+ }
19
27
  /** 探活 + 模型在位检查。3 秒探不通就是没起服务,不等推理超时才发现 */
20
28
  export async function checkLocalVlm() {
21
29
  const host = ollamaHost();
@@ -32,7 +40,7 @@ export async function checkLocalVlm() {
32
40
  return { running: true, modelPresent: true, host };
33
41
  }
34
42
  catch {
35
- return { running: false, modelPresent: false, host, reason: `Ollama 未运行(${host}),启动: ollama serve 或 brew services start ollama` };
43
+ return { running: false, modelPresent: false, host, reason: `Ollama 未运行(${host}),${ollamaStartHint()}` };
36
44
  }
37
45
  }
38
46
  /** SCULPT 系统提示词 —— 从中台 reverse-template.js 移植。本地路只做纯读图,
@@ -0,0 +1,21 @@
1
+ export interface Bbox {
2
+ x1: number;
3
+ y1: number;
4
+ x2: number;
5
+ y2: number;
6
+ }
7
+ /**
8
+ * 纯像素启发式水印定位(零模型依赖)。
9
+ * 原理:半透明水印(角标/文字)是「低对比、高频、铺在大片区域的细碎纹理」,
10
+ * 把四角区域做中值模糊后与原图差分,叠字区会出现稳定的高差值像素团。
11
+ * 判定保守:角区差分像素占比在 [0.3%, 8%] 才算水印(太少=没叠字,
12
+ * 太多=画面本身纹理复杂,不硬修);坐标 0-1000 归一化输出。
13
+ * 误检最坏是 LaMa 重绘一块(轻微损伤),另有 --mask 手工精确兜底。
14
+ */
15
+ export declare function detectWatermarkBoxes(imagePath: string): Promise<Bbox[]>;
16
+ /** 掩码修复主流程:mask 白色=要去除(Buffer 或文件路径);输出 PNG Buffer。
17
+ * 模型是固定 512×512 输入——整图缩进去会毁分辨率,所以按掩码连通域逐块处理:
18
+ * 裁出带边距的局部 → letterbox 进 512 修复 → 只把掩码内的像素贴回原图。 */
19
+ export declare function inpaintLocal(imagePath: string, mask: Buffer | string): Promise<Buffer>;
20
+ /** 从检测框生成掩码 Buffer(框外扩 2%,白=去除区) */
21
+ export declare function maskFromBoxes(imagePath: string, boxes: Bbox[]): Promise<Buffer>;