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/AGENTS.md +9 -0
- package/CHANGELOG.md +30 -0
- package/README.md +62 -8
- package/dist/client.d.ts +41 -0
- package/dist/client.js +39 -0
- package/dist/commands/gen.d.ts +1 -0
- package/dist/commands/gen.js +12 -0
- package/dist/commands/img-tools.d.ts +29 -0
- package/dist/commands/img-tools.js +130 -0
- package/dist/commands/jobs.d.ts +1 -0
- package/dist/commands/jobs.js +7 -1
- package/dist/commands/projects.d.ts +21 -0
- package/dist/commands/projects.js +69 -0
- package/dist/commands/reverse.d.ts +4 -3
- package/dist/commands/reverse.js +3 -2
- package/dist/index.js +79 -3
- package/dist/local-bg.d.ts +17 -0
- package/dist/local-bg.js +143 -0
- package/dist/local-upscale.d.ts +21 -0
- package/dist/local-upscale.js +115 -0
- package/dist/local-vision.js +9 -1
- package/dist/local-watermark.d.ts +21 -0
- package/dist/local-watermark.js +312 -0
- package/package.json +3 -2
- package/src/client.ts +87 -13
- package/src/commands/gen.ts +14 -0
- package/src/commands/img-tools.ts +156 -0
- package/src/commands/jobs.ts +8 -2
- package/src/commands/projects.ts +80 -0
- package/src/commands/reverse.ts +7 -6
- package/src/index.ts +89 -3
- package/src/local-bg.ts +142 -0
- package/src/local-upscale.ts +123 -0
- package/src/local-vision.ts +8 -1
- package/src/local-watermark.ts +320 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地去水印 —— remove-watermark 的核心实现。
|
|
3
|
+
* 链路:纯像素启发式定位水印(零模型依赖,不占内存)→ LaMa 掩码修复 →
|
|
4
|
+
* 输出干净图。也可 --mask 手工给掩码(白=要去除的区域),跳过自动定位。
|
|
5
|
+
* 全本地、免登录、零成本;许可证均兼容(LaMa 模型 Apache-2.0,onnxruntime MIT)。
|
|
6
|
+
*/
|
|
7
|
+
import { mkdir, writeFile, stat } from 'node:fs/promises';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
const LAMA_URLS = [
|
|
11
|
+
'https://huggingface.co/Carve/LaMa-ONNX/resolve/main/lama_fp32.onnx',
|
|
12
|
+
// 国内网络拉不动 HF 时的镜像(同一文件;镜像没缓存会回落直链,所以放第二位)
|
|
13
|
+
'https://hf-mirror.com/Carve/LaMa-ONNX/resolve/main/lama_fp32.onnx',
|
|
14
|
+
];
|
|
15
|
+
const LAMA_PATH = join(homedir(), '.museav-models', 'lama', 'lama_fp32.onnx');
|
|
16
|
+
async function ensureLamaModel() {
|
|
17
|
+
try {
|
|
18
|
+
if ((await stat(LAMA_PATH)).size > 50_000_000)
|
|
19
|
+
return LAMA_PATH;
|
|
20
|
+
}
|
|
21
|
+
catch { /* 未下载 */ }
|
|
22
|
+
await mkdir(join(homedir(), '.museav-models', 'lama'), { recursive: true });
|
|
23
|
+
process.stderr.write('↓ 首次使用,下载 LaMa 修复模型(~200MB,一次性,缓存到 ~/.museav-models/lama)...\n');
|
|
24
|
+
let lastErr = null;
|
|
25
|
+
for (const url of LAMA_URLS) {
|
|
26
|
+
try {
|
|
27
|
+
const resp = await fetch(url, { redirect: 'follow' });
|
|
28
|
+
if (!resp.ok || !resp.body)
|
|
29
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
30
|
+
const total = Number(resp.headers.get('content-length') || 0);
|
|
31
|
+
const chunks = [];
|
|
32
|
+
let got = 0;
|
|
33
|
+
const reader = resp.body.getReader();
|
|
34
|
+
for (;;) {
|
|
35
|
+
const { done, value } = await reader.read();
|
|
36
|
+
if (done)
|
|
37
|
+
break;
|
|
38
|
+
chunks.push(Buffer.from(value));
|
|
39
|
+
got += value.length;
|
|
40
|
+
if (total)
|
|
41
|
+
process.stderr.write(` ${(got / 1048576).toFixed(0)}/${(total / 1048576).toFixed(0)}MB\r`);
|
|
42
|
+
}
|
|
43
|
+
process.stderr.write('\n');
|
|
44
|
+
const buf = Buffer.concat(chunks);
|
|
45
|
+
if (buf.length < 50_000_000)
|
|
46
|
+
throw new Error('下载不完整');
|
|
47
|
+
await writeFile(LAMA_PATH, buf);
|
|
48
|
+
return LAMA_PATH;
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
lastErr = e;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`LaMa 模型下载失败(${lastErr instanceof Error ? lastErr.message : lastErr}),也可手动放到 ${LAMA_PATH}`);
|
|
55
|
+
}
|
|
56
|
+
/** LaMa 要求边长被 8 整除:右/下边缘复制填充,修完再裁回 */
|
|
57
|
+
const pad8 = (n) => Math.ceil(n / 8) * 8;
|
|
58
|
+
/**
|
|
59
|
+
* 纯像素启发式水印定位(零模型依赖)。
|
|
60
|
+
* 原理:半透明水印(角标/文字)是「低对比、高频、铺在大片区域的细碎纹理」,
|
|
61
|
+
* 把四角区域做中值模糊后与原图差分,叠字区会出现稳定的高差值像素团。
|
|
62
|
+
* 判定保守:角区差分像素占比在 [0.3%, 8%] 才算水印(太少=没叠字,
|
|
63
|
+
* 太多=画面本身纹理复杂,不硬修);坐标 0-1000 归一化输出。
|
|
64
|
+
* 误检最坏是 LaMa 重绘一块(轻微损伤),另有 --mask 手工精确兜底。
|
|
65
|
+
*/
|
|
66
|
+
export async function detectWatermarkBoxes(imagePath) {
|
|
67
|
+
const sharpMod = await import('sharp');
|
|
68
|
+
const sharp = sharpMod.default ?? sharpMod;
|
|
69
|
+
const meta = await sharp(imagePath).rotate().metadata();
|
|
70
|
+
const W = meta.width || 0;
|
|
71
|
+
const H = meta.height || 0;
|
|
72
|
+
if (!W || !H)
|
|
73
|
+
throw new Error('读不到图片尺寸');
|
|
74
|
+
const corners = [
|
|
75
|
+
{ x: 0, y: 0 },
|
|
76
|
+
{ x: 1, y: 0 },
|
|
77
|
+
{ x: 0, y: 1 },
|
|
78
|
+
{ x: 1, y: 1 },
|
|
79
|
+
];
|
|
80
|
+
const cw = Math.round(W * 0.35);
|
|
81
|
+
const ch = Math.round(H * 0.35);
|
|
82
|
+
const boxes = [];
|
|
83
|
+
for (const { x, y } of corners) {
|
|
84
|
+
const left = x ? W - cw : 0;
|
|
85
|
+
const top = y ? H - ch : 0;
|
|
86
|
+
const { data: orig } = await sharp(imagePath).rotate()
|
|
87
|
+
.extract({ left, top, width: cw, height: ch }).raw().toBuffer({ resolveWithObject: true });
|
|
88
|
+
const { data: blurred } = await sharp(imagePath).rotate()
|
|
89
|
+
.extract({ left, top, width: cw, height: ch })
|
|
90
|
+
.median(7)
|
|
91
|
+
.raw().toBuffer({ resolveWithObject: true });
|
|
92
|
+
// 差分 + 阈值;记录超阈值像素的 bbox
|
|
93
|
+
let cnt = 0;
|
|
94
|
+
let minX = Infinity, minY = Infinity, maxX = -1, maxY = -1;
|
|
95
|
+
for (let i = 0; i < cw * ch; i++) {
|
|
96
|
+
const dr = Math.abs(orig[i * 3] - blurred[i * 3]);
|
|
97
|
+
const dg = Math.abs(orig[i * 3 + 1] - blurred[i * 3 + 1]);
|
|
98
|
+
const db = Math.abs(orig[i * 3 + 2] - blurred[i * 3 + 2]);
|
|
99
|
+
if (dr + dg + db > 90) { // 每通道均值 >30
|
|
100
|
+
cnt++;
|
|
101
|
+
const px = i % cw;
|
|
102
|
+
const py = Math.floor(i / cw);
|
|
103
|
+
if (px < minX)
|
|
104
|
+
minX = px;
|
|
105
|
+
if (px > maxX)
|
|
106
|
+
maxX = px;
|
|
107
|
+
if (py < minY)
|
|
108
|
+
minY = py;
|
|
109
|
+
if (py > maxY)
|
|
110
|
+
maxY = py;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const ratio = cnt / (cw * ch);
|
|
114
|
+
if (ratio >= 0.003 && ratio <= 0.08 && maxX >= 0) {
|
|
115
|
+
// 像素 → 0-1000 归一化(带 2% 外扩)
|
|
116
|
+
const pad = 0.02;
|
|
117
|
+
const box = {
|
|
118
|
+
x1: Math.max(0, ((left + minX) / W) * 1000 - pad * 1000),
|
|
119
|
+
y1: Math.max(0, ((top + minY) / H) * 1000 - pad * 1000),
|
|
120
|
+
x2: Math.min(1000, ((left + maxX) / W) * 1000 + pad * 1000),
|
|
121
|
+
y2: Math.min(1000, ((top + maxY) / H) * 1000 + pad * 1000),
|
|
122
|
+
};
|
|
123
|
+
// 贴角锚定:水印通常贴着所在角的边沿(5% 容差)。背景装饰/纹理也常被差分命中,
|
|
124
|
+
// 但它们离角远——角标类水印的判据就是「贴角」,否则宁可不修。
|
|
125
|
+
const anchored = (x === 0 && y === 0 && box.x1 <= 50 && box.y1 <= 50) ||
|
|
126
|
+
(x === 1 && y === 0 && box.x2 >= 950 && box.y1 <= 50) ||
|
|
127
|
+
(x === 0 && y === 1 && box.x1 <= 50 && box.y2 >= 950) ||
|
|
128
|
+
(x === 1 && y === 1 && box.x2 >= 950 && box.y2 >= 950);
|
|
129
|
+
if (anchored)
|
|
130
|
+
boxes.push(box);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return boxes;
|
|
134
|
+
}
|
|
135
|
+
/** 掩码修复主流程:mask 白色=要去除(Buffer 或文件路径);输出 PNG Buffer。
|
|
136
|
+
* 模型是固定 512×512 输入——整图缩进去会毁分辨率,所以按掩码连通域逐块处理:
|
|
137
|
+
* 裁出带边距的局部 → letterbox 进 512 修复 → 只把掩码内的像素贴回原图。 */
|
|
138
|
+
export async function inpaintLocal(imagePath, mask) {
|
|
139
|
+
const ort = await import('onnxruntime-node');
|
|
140
|
+
const sharpMod = await import('sharp');
|
|
141
|
+
const sharp = sharpMod.default ?? sharpMod;
|
|
142
|
+
const modelFile = await ensureLamaModel();
|
|
143
|
+
const session = await ort.InferenceSession.create(modelFile);
|
|
144
|
+
const S = 512;
|
|
145
|
+
const { data: rgb, info } = await sharp(imagePath).rotate().removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
146
|
+
const W = info.width;
|
|
147
|
+
const H = info.height;
|
|
148
|
+
const maskRaw = await sharp(mask).rotate().resize(W, H, { fit: 'fill' }).greyscale().raw().toBuffer();
|
|
149
|
+
// 连通域(在 1/4 采样上 BFS,够用):返回像素坐标 bbox 列表
|
|
150
|
+
const boxes = connectedBoxes(maskRaw, W, H);
|
|
151
|
+
// 工作底图:逐块修复后叠回去
|
|
152
|
+
let out = rgb;
|
|
153
|
+
for (const box of boxes) {
|
|
154
|
+
// 边距:给修复模型一点上下文
|
|
155
|
+
const margin = Math.round(Math.max(box.w, box.h) * 0.35) + 16;
|
|
156
|
+
const x0 = Math.max(0, box.x - margin);
|
|
157
|
+
const y0 = Math.max(0, box.y - margin);
|
|
158
|
+
const x1 = Math.min(W, box.x + box.w + margin);
|
|
159
|
+
const y1 = Math.min(H, box.y + box.h + margin);
|
|
160
|
+
const cw = x1 - x0;
|
|
161
|
+
const ch = y1 - y0;
|
|
162
|
+
// letterbox 进 512×512(等比缩放居中,四周留黑)
|
|
163
|
+
const scale = Math.min(S / cw, S / ch);
|
|
164
|
+
const iw = Math.max(8, Math.round(cw * scale));
|
|
165
|
+
const ih = Math.max(8, Math.round(ch * scale));
|
|
166
|
+
const ox = Math.floor((S - iw) / 2);
|
|
167
|
+
const oy = Math.floor((S - ih) / 2);
|
|
168
|
+
const cropImg = await sharp(out, { raw: { width: W, height: H, channels: 3 } })
|
|
169
|
+
.extract({ left: x0, top: y0, width: cw, height: ch })
|
|
170
|
+
.resize(iw, ih, { fit: 'fill' })
|
|
171
|
+
.raw().toBuffer();
|
|
172
|
+
// ⚠ sharp 的坑:raw 1 通道经 extract→resize 会悄悄变 3 通道(长度×3),
|
|
173
|
+
// 后面按 1 通道读全是错位数据。掩码管线一律 toColourspace('b-w') 强制单通道。
|
|
174
|
+
const cropMask = await sharp(maskRaw, { raw: { width: W, height: H, channels: 1 } })
|
|
175
|
+
.extract({ left: x0, top: y0, width: cw, height: ch })
|
|
176
|
+
.resize(iw, ih, { fit: 'fill' })
|
|
177
|
+
.toColourspace('b-w')
|
|
178
|
+
.raw().toBuffer();
|
|
179
|
+
const imgC = Buffer.alloc(S * S * 3, 0);
|
|
180
|
+
const maskC = Buffer.alloc(S * S, 0);
|
|
181
|
+
for (let y = 0; y < ih; y++) {
|
|
182
|
+
for (let x = 0; x < iw; x++) {
|
|
183
|
+
const si = (y * iw + x) * 3;
|
|
184
|
+
const di = ((oy + y) * S + (ox + x)) * 3;
|
|
185
|
+
imgC[di] = cropImg[si];
|
|
186
|
+
imgC[di + 1] = cropImg[si + 1];
|
|
187
|
+
imgC[di + 2] = cropImg[si + 2];
|
|
188
|
+
maskC[(oy + y) * S + (ox + x)] = cropMask[y * iw + x] > 127 ? 1 : 0;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const N = S * S;
|
|
192
|
+
const imgF = new Float32Array(3 * N);
|
|
193
|
+
for (let i = 0; i < N; i++) {
|
|
194
|
+
imgF[i] = imgC[i * 3] / 255;
|
|
195
|
+
imgF[N + i] = imgC[i * 3 + 1] / 255;
|
|
196
|
+
imgF[2 * N + i] = imgC[i * 3 + 2] / 255;
|
|
197
|
+
}
|
|
198
|
+
const results = await session.run({
|
|
199
|
+
image: new ort.Tensor('float32', imgF, [1, 3, S, S]),
|
|
200
|
+
mask: new ort.Tensor('float32', new Float32Array(maskC), [1, 1, S, S]),
|
|
201
|
+
});
|
|
202
|
+
const o = results[session.outputNames[0]].data;
|
|
203
|
+
// 取回中心区域,缩回原尺寸
|
|
204
|
+
const back = Buffer.alloc(iw * ih * 3);
|
|
205
|
+
for (let y = 0; y < ih; y++) {
|
|
206
|
+
for (let x = 0; x < iw; x++) {
|
|
207
|
+
const so = ((oy + y) * S + (ox + x));
|
|
208
|
+
const di = (y * iw + x) * 3;
|
|
209
|
+
back[di] = clamp255(o[so]);
|
|
210
|
+
back[di + 1] = clamp255(o[N + so]);
|
|
211
|
+
back[di + 2] = clamp255(o[2 * N + so]);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const restoredFull = await sharp(back, { raw: { width: iw, height: ih, channels: 3 } })
|
|
215
|
+
.resize(cw, ch, { fit: 'fill' }).raw().toBuffer();
|
|
216
|
+
const maskFull = await sharp(cropMask, { raw: { width: iw, height: ih, channels: 1 } })
|
|
217
|
+
.resize(cw, ch, { fit: 'fill' })
|
|
218
|
+
.toColourspace('b-w')
|
|
219
|
+
.raw().toBuffer();
|
|
220
|
+
// 只把掩码内的像素贴回,其余保持原图(缩放往返会损伤未掩码区,不能整块覆盖)
|
|
221
|
+
const next = Buffer.from(out);
|
|
222
|
+
for (let i = 0; i < cw * ch; i++) {
|
|
223
|
+
if (maskFull[i] > 127) {
|
|
224
|
+
next[((y0 + Math.floor(i / cw)) * W + (x0 + (i % cw))) * 3] = restoredFull[i * 3];
|
|
225
|
+
next[(((y0 + Math.floor(i / cw)) * W + (x0 + (i % cw))) * 3) + 1] = restoredFull[i * 3 + 1];
|
|
226
|
+
next[(((y0 + Math.floor(i / cw)) * W + (x0 + (i % cw))) * 3) + 2] = restoredFull[i * 3 + 2];
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
out = next;
|
|
230
|
+
process.stderr.write(` 修复块 ${cw}x${ch} @(${x0},${y0}) 完成\n`);
|
|
231
|
+
}
|
|
232
|
+
return sharp(out, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
|
|
233
|
+
}
|
|
234
|
+
const clamp255 = (v) => Math.max(0, Math.min(255, Math.round(v)));
|
|
235
|
+
/** 掩码连通域 → 像素 bbox 列表。在 1/4 采样上网格 BFS,快且够准 */
|
|
236
|
+
function connectedBoxes(mask, W, H) {
|
|
237
|
+
const step = 4;
|
|
238
|
+
const gw = Math.ceil(W / step);
|
|
239
|
+
const gh = Math.ceil(H / step);
|
|
240
|
+
const grid = new Uint8Array(gw * gh);
|
|
241
|
+
for (let gy = 0; gy < gh; gy++) {
|
|
242
|
+
for (let gx = 0; gx < gw; gx++) {
|
|
243
|
+
let hit = false;
|
|
244
|
+
for (let dy = 0; dy < step && !hit; dy++) {
|
|
245
|
+
for (let dx = 0; dx < step && !hit; dx++) {
|
|
246
|
+
const x = gx * step + dx;
|
|
247
|
+
const y = gy * step + dy;
|
|
248
|
+
if (x < W && y < H && mask[y * W + x] > 127)
|
|
249
|
+
hit = true;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
grid[gy * gw + gx] = hit ? 1 : 0;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const seen = new Uint8Array(gw * gh);
|
|
256
|
+
const boxes = [];
|
|
257
|
+
const q = [];
|
|
258
|
+
for (let g = 0; g < grid.length; g++) {
|
|
259
|
+
if (!grid[g] || seen[g])
|
|
260
|
+
continue;
|
|
261
|
+
q.length = 0;
|
|
262
|
+
q.push(g);
|
|
263
|
+
seen[g] = 1;
|
|
264
|
+
let minx = gw, miny = gh, maxx = -1, maxy = -1;
|
|
265
|
+
while (q.length) {
|
|
266
|
+
const cur = q.pop();
|
|
267
|
+
const cx = cur % gw;
|
|
268
|
+
const cy = Math.floor(cur / gw);
|
|
269
|
+
minx = Math.min(minx, cx);
|
|
270
|
+
maxx = Math.max(maxx, cx);
|
|
271
|
+
miny = Math.min(miny, cy);
|
|
272
|
+
maxy = Math.max(maxy, cy);
|
|
273
|
+
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
274
|
+
const nx = cx + dx, ny = cy + dy;
|
|
275
|
+
if (nx < 0 || ny < 0 || nx >= gw || ny >= gh)
|
|
276
|
+
continue;
|
|
277
|
+
const ni = ny * gw + nx;
|
|
278
|
+
if (grid[ni] && !seen[ni]) {
|
|
279
|
+
seen[ni] = 1;
|
|
280
|
+
q.push(ni);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
boxes.push({ x: minx * step, y: miny * step, w: (maxx - minx + 1) * step, h: (maxy - miny + 1) * step });
|
|
285
|
+
}
|
|
286
|
+
return boxes;
|
|
287
|
+
}
|
|
288
|
+
/** 从检测框生成掩码 Buffer(框外扩 2%,白=去除区) */
|
|
289
|
+
export async function maskFromBoxes(imagePath, boxes) {
|
|
290
|
+
const sharpMod = await import('sharp');
|
|
291
|
+
const sharp = sharpMod.default ?? sharpMod;
|
|
292
|
+
const meta = await sharp(imagePath).metadata();
|
|
293
|
+
const w = meta.width || 0;
|
|
294
|
+
const h = meta.height || 0;
|
|
295
|
+
if (!w || !h)
|
|
296
|
+
throw new Error('读不到图片尺寸');
|
|
297
|
+
// 坐标体系一次判清:全部 ≤1.5 视为 0-1 归一化,否则按 qwen 惯例的 0-1000
|
|
298
|
+
const all = boxes.flatMap((b) => [b.x1, b.y1, b.x2, b.y2]);
|
|
299
|
+
const denom = Math.max(...all) <= 1.5 ? 1 : 1000;
|
|
300
|
+
const svg = boxes
|
|
301
|
+
.map((b) => {
|
|
302
|
+
const pad = 0.02;
|
|
303
|
+
const x1 = Math.max(0, (b.x1 / denom) * w - w * pad);
|
|
304
|
+
const y1 = Math.max(0, (b.y1 / denom) * h - h * pad);
|
|
305
|
+
const x2 = Math.min(w, (b.x2 / denom) * w + w * pad);
|
|
306
|
+
const y2 = Math.min(h, (b.y2 / denom) * h + h * pad);
|
|
307
|
+
return `<rect x="${x1}" y="${y1}" width="${Math.max(1, x2 - x1)}" height="${Math.max(1, y2 - y1)}" fill="#fff"/>`;
|
|
308
|
+
})
|
|
309
|
+
.join('');
|
|
310
|
+
const maskSvg = Buffer.from(`<svg width="${w}" height="${h}"><rect width="${w}" height="${h}" fill="#000"/>${svg}</svg>`);
|
|
311
|
+
return sharp(maskSvg).png().toBuffer();
|
|
312
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "museav-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "MUSE AV 出图中台官方 CLI —— 命令行调中台 API 出图、出视频、读图逆向、图生模板",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"access": "public"
|
|
54
54
|
},
|
|
55
55
|
"optionalDependencies": {
|
|
56
|
-
"sharp": "^0.35.3"
|
|
56
|
+
"sharp": "^0.35.3",
|
|
57
|
+
"onnxruntime-node": "^1.27.0"
|
|
57
58
|
}
|
|
58
59
|
}
|
package/src/client.ts
CHANGED
|
@@ -80,6 +80,32 @@ export interface GenerateOptions {
|
|
|
80
80
|
* · 只派给声明了该能力的上游;一家都没有时返回 400 说明原因,**不会静默出白底图**
|
|
81
81
|
*/
|
|
82
82
|
background?: 'transparent' | 'opaque'
|
|
83
|
+
/** 项目归档:生成结果挂到该工作区(中台仅账户身份收,租户身份忽略) */
|
|
84
|
+
workspace_id?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 工作区(项目):平台账户下的项目容器,素材库挂在它上面(GET/POST /api/workspaces) */
|
|
88
|
+
export interface Workspace {
|
|
89
|
+
id: string
|
|
90
|
+
name: string
|
|
91
|
+
brand?: string | null
|
|
92
|
+
description?: string | null
|
|
93
|
+
/** 该项目累计提交 / 完成的生成数(列表接口附带的统计) */
|
|
94
|
+
gen_total?: number
|
|
95
|
+
gen_done?: number
|
|
96
|
+
created_at?: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 工作区素材(GET/POST /api/workspace-assets):项目素材库的一条记录 */
|
|
100
|
+
export interface WorkspaceAsset {
|
|
101
|
+
id: string
|
|
102
|
+
workspace_id: string
|
|
103
|
+
media_type: 'image' | 'video' | 'audio'
|
|
104
|
+
cdn_url: string
|
|
105
|
+
name: string | null
|
|
106
|
+
tags: string[]
|
|
107
|
+
size_bytes?: number | null
|
|
108
|
+
created_at?: string
|
|
83
109
|
}
|
|
84
110
|
|
|
85
111
|
/** 图片/文字模板清单项(GET /api/templates,template_type=image|article) */
|
|
@@ -412,6 +438,8 @@ export class StudioClient {
|
|
|
412
438
|
if (opts.reference_images?.length) body.reference_images = opts.reference_images
|
|
413
439
|
if (opts.quality) body.quality = opts.quality
|
|
414
440
|
if (opts.background) body.background = opts.background
|
|
441
|
+
// 项目归档:中台只对账户身份收 workspace_id(租户身份忽略),CLI 不做二次校验
|
|
442
|
+
if (opts.workspace_id) body.workspace_id = opts.workspace_id
|
|
415
443
|
const r = await this.request('generate', {
|
|
416
444
|
method: 'POST',
|
|
417
445
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -426,6 +454,49 @@ export class StudioClient {
|
|
|
426
454
|
return r
|
|
427
455
|
}
|
|
428
456
|
|
|
457
|
+
// ── 工作区(项目)与项目素材库 ──
|
|
458
|
+
// 平台 → 账户 → 工作区三层归属;素材挂工作区,换业务换工作区,互不污染。
|
|
459
|
+
|
|
460
|
+
/** 列当前账户的工作区(含生成统计) */
|
|
461
|
+
async workspaces(): Promise<Workspace[]> {
|
|
462
|
+
return this.request('workspaces')
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** 新建工作区(最多 5 个,超了服务端会 400) */
|
|
466
|
+
async createWorkspace(name: string): Promise<Workspace> {
|
|
467
|
+
return this.request('workspaces', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) })
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** 列某工作区的素材库 */
|
|
471
|
+
async workspaceAssets(workspaceId: string): Promise<WorkspaceAsset[]> {
|
|
472
|
+
return this.request(`workspace-assets?workspace_id=${encodeURIComponent(workspaceId)}`)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** 上传素材进工作区素材库。素材是母版,**不做视觉压缩**(fileForm 那套压缩是给模型看的) */
|
|
476
|
+
async addWorkspaceAsset(input: {
|
|
477
|
+
file: string
|
|
478
|
+
workspaceId: string
|
|
479
|
+
name?: string
|
|
480
|
+
tags?: string[]
|
|
481
|
+
}): Promise<WorkspaceAsset> {
|
|
482
|
+
const blob = new Blob([new Uint8Array(readFileSync(input.file))])
|
|
483
|
+
const fd = new FormData()
|
|
484
|
+
fd.append('file', blob, basename(input.file))
|
|
485
|
+
fd.append('workspace_id', input.workspaceId)
|
|
486
|
+
if (input.name) fd.append('name', input.name)
|
|
487
|
+
for (const t of input.tags || []) fd.append('tags', t)
|
|
488
|
+
return this.request('workspace-assets', { method: 'POST', body: fd })
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** 删除素材(硬删:R2 对象 + 记录) */
|
|
492
|
+
async deleteWorkspaceAsset(id: string): Promise<void> {
|
|
493
|
+
await this.request('workspace-assets', {
|
|
494
|
+
method: 'DELETE',
|
|
495
|
+
headers: { 'Content-Type': 'application/json' },
|
|
496
|
+
body: JSON.stringify({ id }),
|
|
497
|
+
})
|
|
498
|
+
}
|
|
499
|
+
|
|
429
500
|
/**
|
|
430
501
|
* 列出当前身份名下的出图工作流(不传 id,走同一个 jobs 端点的集合语义)。
|
|
431
502
|
* 范围由鉴权凭证决定:个人 token 只看得到自己出的图;租户 apiKey 看得到自己业务下的全部记录。
|
|
@@ -474,19 +545,22 @@ export class StudioClient {
|
|
|
474
545
|
duration?: number
|
|
475
546
|
/** 图生视频:首帧/参考图 URL(中台内部自动上传垫图后拿到 URL 再传这里) */
|
|
476
547
|
image_url?: string
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
548
|
+
template_id?: string
|
|
549
|
+
input?: string | Record<string, string>
|
|
550
|
+
callback_url?: string
|
|
551
|
+
/** 项目归档(账户身份才生效) */
|
|
552
|
+
workspace_id?: string
|
|
553
|
+
}): Promise<{ jobId: string; upstreamTaskId?: string }> {
|
|
554
|
+
const body: Record<string, unknown> = {}
|
|
555
|
+
if (opts.prompt) body.prompt = opts.prompt
|
|
556
|
+
if (opts.model) body.model = opts.model
|
|
557
|
+
if (opts.ratio) body.ratio = opts.ratio
|
|
558
|
+
if (opts.duration != null) body.duration = opts.duration
|
|
559
|
+
if (opts.image_url) body.image_url = opts.image_url
|
|
560
|
+
if (opts.template_id) body.template_id = opts.template_id
|
|
561
|
+
if (opts.input) body.input = opts.input
|
|
562
|
+
if (opts.callback_url) body.callback_url = opts.callback_url
|
|
563
|
+
if (opts.workspace_id) body.workspace_id = opts.workspace_id
|
|
490
564
|
const r = await this.request('videos', {
|
|
491
565
|
method: 'POST',
|
|
492
566
|
headers: { 'Content-Type': 'application/json' },
|
package/src/commands/gen.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** museav gen —— 出图 / 出视频(核心命令) */
|
|
2
2
|
import type { StudioClient } from '../client.js'
|
|
3
|
+
import { resolveWorkspace } from './projects.js'
|
|
3
4
|
|
|
4
5
|
/** 与中台/各租户后台口径一致:一次最多 5 张参考图 */
|
|
5
6
|
const MAX_REFS = 5
|
|
@@ -15,6 +16,7 @@ export async function gen(client: StudioClient, opts: {
|
|
|
15
16
|
quality?: string
|
|
16
17
|
ref?: string[] // 可重复:--ref a.jpg --ref b.jpg,顺序即「图片1、图片2…」
|
|
17
18
|
transparent?: boolean // 透明背景 PNG;能不能做由中台按上游能力判定,做不了会明确报错
|
|
19
|
+
project?: string // 工作区 id|名:生成结果归档进该项目(账户身份才生效)
|
|
18
20
|
// 视频
|
|
19
21
|
video?: boolean
|
|
20
22
|
duration?: number
|
|
@@ -63,6 +65,13 @@ export async function gen(client: StudioClient, opts: {
|
|
|
63
65
|
if (refPaths.length) {
|
|
64
66
|
const urls: string[] = []
|
|
65
67
|
for (const [i, refPath] of refPaths.entries()) {
|
|
68
|
+
// http(s) 直链(典型来源:museav projects assets 的素材库 URL)本身就是
|
|
69
|
+
// 中台 CDN 地址,直接当参考图用,不走上传
|
|
70
|
+
if (/^https?:\/\//.test(refPath)) {
|
|
71
|
+
urls.push(refPath)
|
|
72
|
+
process.stderr.write(` 图片${i + 1} 直链: ${refPath}\n`)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
66
75
|
process.stderr.write(`上传垫图 [图片${i + 1}] ${refPath} ...\n`)
|
|
67
76
|
const up = await client.uploadRef(refPath)
|
|
68
77
|
urls.push(up.url)
|
|
@@ -72,6 +81,9 @@ export async function gen(client: StudioClient, opts: {
|
|
|
72
81
|
referenceImages = urls.length > 1 ? urls : undefined
|
|
73
82
|
}
|
|
74
83
|
|
|
84
|
+
// 项目归档:--project 解析成 workspace_id(名字/ id 都行),租户身份时中台会忽略
|
|
85
|
+
const workspaceId = opts.project ? (await resolveWorkspace(client, opts.project)).id : undefined
|
|
86
|
+
|
|
75
87
|
// ── 视频模式:走 /api/videos 独立链路 ──
|
|
76
88
|
if (opts.video) {
|
|
77
89
|
if (opts.quality) throw new Error('--quality 仅图片出图支持')
|
|
@@ -87,6 +99,7 @@ export async function gen(client: StudioClient, opts: {
|
|
|
87
99
|
image_url: referenceImage,
|
|
88
100
|
template_id: opts.template,
|
|
89
101
|
input: templateFields,
|
|
102
|
+
workspace_id: workspaceId,
|
|
90
103
|
})
|
|
91
104
|
process.stderr.write(`视频任务已提交: ${jobId}\n生成中(视频通常 1-5 分钟)...\n`)
|
|
92
105
|
const result = await client.waitVideo(jobId, (status) => {
|
|
@@ -123,6 +136,7 @@ export async function gen(client: StudioClient, opts: {
|
|
|
123
136
|
// 开关 → 枚举:CLI 这层用布尔开关最顺手,中台契约是 background: transparent|opaque
|
|
124
137
|
// (跟上游 gpt-image 的参数同名同值)。不传就不发,行为跟以前完全一样。
|
|
125
138
|
background: opts.transparent ? 'transparent' : undefined,
|
|
139
|
+
workspace_id: workspaceId,
|
|
126
140
|
},
|
|
127
141
|
(status) => {
|
|
128
142
|
if (status === 'processing') process.stderr.write('生成中...\r')
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/** museav compress / remove-bg —— 本地图像工具箱。
|
|
2
|
+
* 纯本地、免登录、不碰中台;stdout 只出产物路径,统计与进度打 stderr。
|
|
3
|
+
* 代码零平台假设(路径全走 node:path/os,无 shell 展开、无 Unix-only 命令),macOS / Windows 通用。 */
|
|
4
|
+
import { stat, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { basename, dirname, extname, join } from 'node:path'
|
|
6
|
+
import { removeBackgroundLocal, BG_MODELS, type BgModelKey } from '../local-bg.js'
|
|
7
|
+
import { upscaleLocal, UPSCALE_MODELS, type UpscaleModel } from '../local-upscale.js'
|
|
8
|
+
import { detectWatermarkBoxes, inpaintLocal, maskFromBoxes } from '../local-watermark.js'
|
|
9
|
+
|
|
10
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
11
|
+
try {
|
|
12
|
+
return (await stat(path)).isFile()
|
|
13
|
+
} catch {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fmtBytes(n: number): string {
|
|
19
|
+
return n >= 1048576 ? `${(n / 1048576).toFixed(2)}MB` : `${(n / 1024).toFixed(1)}KB`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 默认输出路径:同目录 <名字>-<后缀>.<新扩展名>。绝不覆写输入文件 */
|
|
23
|
+
export function defaultOut(input: string, suffix: string, newExt?: string): string {
|
|
24
|
+
const ext = newExt || extname(input).slice(1) || 'png'
|
|
25
|
+
return join(dirname(input), `${basename(input, extname(input))}-${suffix}.${ext}`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CompressOpts {
|
|
29
|
+
out?: string
|
|
30
|
+
maxEdge?: string
|
|
31
|
+
quality?: string
|
|
32
|
+
format?: string
|
|
33
|
+
overwrite?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function compressCmd(input: string, opts: CompressOpts): Promise<void> {
|
|
37
|
+
if (!(await fileExists(input))) throw new Error(`文件不存在: ${input}`)
|
|
38
|
+
|
|
39
|
+
let sharp: any
|
|
40
|
+
try {
|
|
41
|
+
const m = await import('sharp')
|
|
42
|
+
sharp = (m as any).default ?? m
|
|
43
|
+
} catch {
|
|
44
|
+
throw new Error('sharp 不可用(压缩依赖它)。重装 CLI 即可补上:npm install -g museav-cli')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const format = (opts.format || '').toLowerCase()
|
|
48
|
+
if (format && !['jpg', 'png', 'webp'].includes(format)) throw new Error('--format 只支持 jpg / png / webp')
|
|
49
|
+
const quality = opts.quality ? Number(opts.quality) : 82
|
|
50
|
+
if (!Number.isFinite(quality) || quality < 1 || quality > 100) throw new Error('--quality 必须是 1-100')
|
|
51
|
+
const maxEdge = opts.maxEdge ? Number(opts.maxEdge) : 0
|
|
52
|
+
if (opts.maxEdge && (!Number.isFinite(maxEdge) || maxEdge < 16)) throw new Error('--max-edge 至少 16px')
|
|
53
|
+
|
|
54
|
+
const meta = await sharp(input).metadata()
|
|
55
|
+
// 不指定 --format 时保持原格式;不在三之列的(tiff/bmp/heic…)统一转 jpg
|
|
56
|
+
const srcFormat = String(meta.format || '')
|
|
57
|
+
const target = format || (srcFormat === 'png' ? 'png' : srcFormat === 'webp' ? 'webp' : 'jpg')
|
|
58
|
+
|
|
59
|
+
let pipeline = sharp(input).rotate() // 尊重 EXIF 方向
|
|
60
|
+
if (maxEdge) pipeline = pipeline.resize({ width: maxEdge, height: maxEdge, fit: 'inside' })
|
|
61
|
+
if (target === 'jpg') pipeline = pipeline.jpeg({ quality, mozjpeg: true })
|
|
62
|
+
else if (target === 'webp') pipeline = pipeline.webp({ quality })
|
|
63
|
+
else pipeline = pipeline.png({ compressionLevel: 9 })
|
|
64
|
+
|
|
65
|
+
const outPath = opts.out || defaultOut(input, 'min', target === 'jpg' && srcFormat === 'jpeg' ? 'jpg' : target)
|
|
66
|
+
if ((await fileExists(outPath)) && !opts.overwrite) {
|
|
67
|
+
throw new Error(`输出已存在(用 --overwrite 覆盖或 --out 换路径): ${outPath}`)
|
|
68
|
+
}
|
|
69
|
+
const buf = await pipeline.toBuffer()
|
|
70
|
+
await writeFile(outPath, buf)
|
|
71
|
+
|
|
72
|
+
const before = (await stat(input)).size
|
|
73
|
+
process.stderr.write(`✅ ${fmtBytes(before)} → ${fmtBytes(buf.length)}(省 ${Math.max(0, Math.round((1 - buf.length / before) * 100))}%,${target.toUpperCase()})\n`)
|
|
74
|
+
console.log(outPath)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface RemoveBgOpts {
|
|
78
|
+
out?: string
|
|
79
|
+
model?: string
|
|
80
|
+
overwrite?: boolean
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function removeBgCmd(input: string, opts: RemoveBgOpts): Promise<void> {
|
|
84
|
+
if (!(await fileExists(input))) throw new Error(`文件不存在: ${input}`)
|
|
85
|
+
const modelKey = (opts.model || 'isnet') as BgModelKey
|
|
86
|
+
if (!(modelKey in BG_MODELS)) throw new Error(`--model 只支持 ${Object.keys(BG_MODELS).join(' / ')}`)
|
|
87
|
+
|
|
88
|
+
const start = Date.now()
|
|
89
|
+
const png = await removeBackgroundLocal(input, modelKey)
|
|
90
|
+
const outPath = opts.out || defaultOut(input, 'nobg', 'png')
|
|
91
|
+
if ((await fileExists(outPath)) && !opts.overwrite) {
|
|
92
|
+
throw new Error(`输出已存在(用 --overwrite 覆盖或 --out 换路径): ${outPath}`)
|
|
93
|
+
}
|
|
94
|
+
await writeFile(outPath, png)
|
|
95
|
+
process.stderr.write(`✅ 抠图完成(${BG_MODELS[modelKey].label},用时 ${((Date.now() - start) / 1000).toFixed(1)}s,${fmtBytes(png.length)})\n`)
|
|
96
|
+
console.log(outPath)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface RemoveWatermarkOpts {
|
|
100
|
+
out?: string
|
|
101
|
+
mask?: string
|
|
102
|
+
overwrite?: boolean
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function removeWatermarkCmd(input: string, opts: RemoveWatermarkOpts): Promise<void> {
|
|
106
|
+
if (!(await fileExists(input))) throw new Error(`文件不存在: ${input}`)
|
|
107
|
+
const outPath = opts.out || defaultOut(input, 'clean', 'png')
|
|
108
|
+
if ((await fileExists(outPath)) && !opts.overwrite) {
|
|
109
|
+
throw new Error(`输出已存在(用 --overwrite 覆盖或 --out 换路径): ${outPath}`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const start = Date.now()
|
|
113
|
+
let mask: Buffer | string
|
|
114
|
+
if (opts.mask) {
|
|
115
|
+
if (!(await fileExists(opts.mask))) throw new Error(`掩码文件不存在: ${opts.mask}`)
|
|
116
|
+
mask = opts.mask
|
|
117
|
+
process.stderr.write('使用手工掩码,跳过自动定位\n')
|
|
118
|
+
} else {
|
|
119
|
+
// 纯像素启发式定位水印(零模型依赖),失败时明确指引改用 --mask
|
|
120
|
+
const boxes = await detectWatermarkBoxes(input)
|
|
121
|
+
if (!boxes.length) throw new Error('自动定位没找到水印(角标式半透明水印通常可识别;复杂画面请用 --mask 手工指定)')
|
|
122
|
+
process.stderr.write(`定位到 ${boxes.length} 处水印:${boxes.map((b) => `(${b.x1},${b.y1})-(${b.x2},${b.y2})`).join(' ')}\n`)
|
|
123
|
+
mask = await maskFromBoxes(input, boxes)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const png = await inpaintLocal(input, mask)
|
|
127
|
+
await writeFile(outPath, png)
|
|
128
|
+
process.stderr.write(`✅ 去水印完成(用时 ${((Date.now() - start) / 1000).toFixed(1)}s,${fmtBytes(png.length)})\n`)
|
|
129
|
+
console.log(outPath)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface UpscaleOpts {
|
|
133
|
+
out?: string
|
|
134
|
+
scale?: string
|
|
135
|
+
model?: string
|
|
136
|
+
overwrite?: boolean
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function upscaleCmd(input: string, opts: UpscaleOpts): Promise<void> {
|
|
140
|
+
if (!(await fileExists(input))) throw new Error(`文件不存在: ${input}`)
|
|
141
|
+
const scale = opts.scale ? Number(opts.scale) : 4
|
|
142
|
+
if (![2, 3, 4].includes(scale)) throw new Error('--scale 只支持 2 / 3 / 4')
|
|
143
|
+
const model = (opts.model || 'realesrgan-x4plus') as UpscaleModel
|
|
144
|
+
if (!(model in UPSCALE_MODELS)) throw new Error(`--model 只支持 ${Object.keys(UPSCALE_MODELS).join(' / ')}`)
|
|
145
|
+
|
|
146
|
+
const outPath = opts.out || defaultOut(input, `${scale}x`, 'png')
|
|
147
|
+
if ((await fileExists(outPath)) && !opts.overwrite) {
|
|
148
|
+
throw new Error(`输出已存在(用 --overwrite 覆盖或 --out 换路径): ${outPath}`)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const start = Date.now()
|
|
152
|
+
await upscaleLocal({ input, output: outPath, scale, model })
|
|
153
|
+
const size = (await stat(outPath)).size
|
|
154
|
+
process.stderr.write(`✅ 超分完成(${UPSCALE_MODELS[model].label},${scale}x,用时 ${((Date.now() - start) / 1000).toFixed(1)}s,${fmtBytes(size)})\n`)
|
|
155
|
+
console.log(outPath)
|
|
156
|
+
}
|