@playcraft/cli 0.0.25 → 0.0.27
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/commands/image.js +213 -9
- package/dist/commands/tools.js +21 -13
- package/package.json +3 -3
package/dist/commands/image.js
CHANGED
|
@@ -48,6 +48,157 @@ function handleError(err) {
|
|
|
48
48
|
console.error(`Error: ${msg}`);
|
|
49
49
|
process.exit(1);
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* 从图片四条边向内 BFS flood-fill,自动检测边缘主色并移除连通的同色背景区域。
|
|
53
|
+
* 适用于 AI 生成的素材(纯色、灰色、棋盘格背景),前景纹理零损失。
|
|
54
|
+
*/
|
|
55
|
+
async function floodFillRemoveBg(inputPath, opts) {
|
|
56
|
+
const { data, info } = await sharp(inputPath)
|
|
57
|
+
.ensureAlpha()
|
|
58
|
+
.raw()
|
|
59
|
+
.toBuffer({ resolveWithObject: true });
|
|
60
|
+
const w = info.width;
|
|
61
|
+
const h = info.height;
|
|
62
|
+
const totalPixels = w * h;
|
|
63
|
+
const result = Buffer.from(data);
|
|
64
|
+
const tolerance = opts.tolerance;
|
|
65
|
+
// ── 1. Auto-detect background colors from edge pixels ──
|
|
66
|
+
const edgeSamples = [];
|
|
67
|
+
const px = (x, y) => {
|
|
68
|
+
const i = (y * w + x) * 4;
|
|
69
|
+
return { r: data[i], g: data[i + 1], b: data[i + 2] };
|
|
70
|
+
};
|
|
71
|
+
for (let x = 0; x < w; x++) {
|
|
72
|
+
edgeSamples.push(px(x, 0), px(x, h - 1));
|
|
73
|
+
}
|
|
74
|
+
for (let y = 1; y < h - 1; y++) {
|
|
75
|
+
edgeSamples.push(px(0, y), px(w - 1, y));
|
|
76
|
+
}
|
|
77
|
+
const clusters = [];
|
|
78
|
+
for (const s of edgeSamples) {
|
|
79
|
+
let matched = false;
|
|
80
|
+
for (const c of clusters) {
|
|
81
|
+
if (Math.abs(c.r - s.r) < 30 &&
|
|
82
|
+
Math.abs(c.g - s.g) < 30 &&
|
|
83
|
+
Math.abs(c.b - s.b) < 30) {
|
|
84
|
+
c.r = (c.r * c.count + s.r) / (c.count + 1);
|
|
85
|
+
c.g = (c.g * c.count + s.g) / (c.count + 1);
|
|
86
|
+
c.b = (c.b * c.count + s.b) / (c.count + 1);
|
|
87
|
+
c.count++;
|
|
88
|
+
matched = true;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!matched)
|
|
93
|
+
clusters.push({ r: s.r, g: s.g, b: s.b, count: 1 });
|
|
94
|
+
}
|
|
95
|
+
clusters.sort((a, b) => b.count - a.count);
|
|
96
|
+
const bgColors = clusters
|
|
97
|
+
.slice(0, 3)
|
|
98
|
+
.filter((c) => c.count > edgeSamples.length * 0.05);
|
|
99
|
+
if (!bgColors.length) {
|
|
100
|
+
const out = await sharp(result, {
|
|
101
|
+
raw: { width: w, height: h, channels: 4 },
|
|
102
|
+
})
|
|
103
|
+
.png()
|
|
104
|
+
.toBuffer();
|
|
105
|
+
return { buffer: out, removedPct: 0, bgColors: [] };
|
|
106
|
+
}
|
|
107
|
+
const isBg = (idx) => {
|
|
108
|
+
const r = data[idx];
|
|
109
|
+
const g = data[idx + 1];
|
|
110
|
+
const b = data[idx + 2];
|
|
111
|
+
return bgColors.some((bg) => Math.abs(r - bg.r) < tolerance &&
|
|
112
|
+
Math.abs(g - bg.g) < tolerance &&
|
|
113
|
+
Math.abs(b - bg.b) < tolerance);
|
|
114
|
+
};
|
|
115
|
+
// ── 2. BFS flood-fill from edges ──
|
|
116
|
+
const visited = new Uint8Array(totalPixels);
|
|
117
|
+
const queue = [];
|
|
118
|
+
for (let x = 0; x < w; x++) {
|
|
119
|
+
queue.push(x, (h - 1) * w + x);
|
|
120
|
+
}
|
|
121
|
+
for (let y = 1; y < h - 1; y++) {
|
|
122
|
+
queue.push(y * w, y * w + (w - 1));
|
|
123
|
+
}
|
|
124
|
+
let qi = 0;
|
|
125
|
+
while (qi < queue.length) {
|
|
126
|
+
const pos = queue[qi++];
|
|
127
|
+
if (pos < 0 || pos >= totalPixels || visited[pos])
|
|
128
|
+
continue;
|
|
129
|
+
if (!isBg(pos * 4))
|
|
130
|
+
continue;
|
|
131
|
+
visited[pos] = 1;
|
|
132
|
+
result[pos * 4 + 3] = 0;
|
|
133
|
+
const x = pos % w;
|
|
134
|
+
const y = (pos - x) / w;
|
|
135
|
+
if (x > 0)
|
|
136
|
+
queue.push(pos - 1);
|
|
137
|
+
if (x < w - 1)
|
|
138
|
+
queue.push(pos + 1);
|
|
139
|
+
if (y > 0)
|
|
140
|
+
queue.push(pos - w);
|
|
141
|
+
if (y < h - 1)
|
|
142
|
+
queue.push(pos + w);
|
|
143
|
+
}
|
|
144
|
+
// ── 3. Edge smoothing (multi-layer alpha gradient) ──
|
|
145
|
+
if (opts.edgeLayers > 0) {
|
|
146
|
+
const layerAlphas = [200, 120, 60];
|
|
147
|
+
let borderSet = new Set();
|
|
148
|
+
for (let y = 1; y < h - 1; y++) {
|
|
149
|
+
for (let x = 1; x < w - 1; x++) {
|
|
150
|
+
const pos = y * w + x;
|
|
151
|
+
if (result[pos * 4 + 3] === 0)
|
|
152
|
+
continue;
|
|
153
|
+
const nb = [pos - 1, pos + 1, pos - w, pos + w];
|
|
154
|
+
if (nb.some((n) => result[n * 4 + 3] === 0))
|
|
155
|
+
borderSet.add(pos);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (let layer = 0; layer < opts.edgeLayers && layer < layerAlphas.length; layer++) {
|
|
159
|
+
const alpha = layerAlphas[layer];
|
|
160
|
+
for (const pos of borderSet) {
|
|
161
|
+
result[pos * 4 + 3] = Math.min(result[pos * 4 + 3], alpha);
|
|
162
|
+
}
|
|
163
|
+
if (layer < opts.edgeLayers - 1) {
|
|
164
|
+
const nextBorder = new Set();
|
|
165
|
+
for (const pos of borderSet) {
|
|
166
|
+
const x = pos % w;
|
|
167
|
+
const y = (pos - x) / w;
|
|
168
|
+
for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) {
|
|
169
|
+
const nx = x + dx;
|
|
170
|
+
const ny = y + dy;
|
|
171
|
+
if (nx < 0 || nx >= w || ny < 0 || ny >= h)
|
|
172
|
+
continue;
|
|
173
|
+
const npos = ny * w + nx;
|
|
174
|
+
if (result[npos * 4 + 3] === 0 && !borderSet.has(npos)) {
|
|
175
|
+
result[npos * 4 + 3] = Math.min(60, layerAlphas[layer + 1] ?? 40);
|
|
176
|
+
nextBorder.add(npos);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
borderSet = nextBorder;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
let removed = 0;
|
|
185
|
+
for (let i = 0; i < totalPixels; i++) {
|
|
186
|
+
if (result[i * 4 + 3] === 0)
|
|
187
|
+
removed++;
|
|
188
|
+
}
|
|
189
|
+
const outBuf = await sharp(result, {
|
|
190
|
+
raw: { width: w, height: h, channels: 4 },
|
|
191
|
+
})
|
|
192
|
+
.png()
|
|
193
|
+
.toBuffer();
|
|
194
|
+
const bgLabels = bgColors.map((c) => `rgb(${Math.round(c.r)},${Math.round(c.g)},${Math.round(c.b)})`);
|
|
195
|
+
return {
|
|
196
|
+
buffer: outBuf,
|
|
197
|
+
removedPct: (removed / totalPixels) * 100,
|
|
198
|
+
bgColors: bgLabels,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
// ─── CLI helpers ────────────────────────────────────────────────────────────
|
|
51
202
|
/** CLI --fit → sharp resize `fit`(非法则退出并提示) */
|
|
52
203
|
function parseResizeFitOption(raw) {
|
|
53
204
|
const v = (raw ?? 'cover').toLowerCase();
|
|
@@ -451,21 +602,74 @@ export function registerImageCommands(program) {
|
|
|
451
602
|
handleError(e);
|
|
452
603
|
}
|
|
453
604
|
});
|
|
454
|
-
// ─── remove-background
|
|
605
|
+
// ─── remove-background ─────────────────────────────────────────
|
|
455
606
|
img.command('remove-background')
|
|
456
|
-
.description(
|
|
607
|
+
.description([
|
|
608
|
+
'背景移除,输出透明 PNG。支持两种方法(--method 选择):',
|
|
609
|
+
'',
|
|
610
|
+
' floodfill(默认)— 从图片边缘 flood-fill 移除纯色/渐变/棋盘格背景',
|
|
611
|
+
' ✅ 适用:AI 生成的素材(纯色背景、灰色背景、棋盘格背景)',
|
|
612
|
+
' ✅ 优势:前景纹理零损失、速度快(<0.5s)、完全本地执行',
|
|
613
|
+
' ⚠️ 局限:仅能移除从边缘连通的简单背景;复杂场景照片无效',
|
|
614
|
+
'',
|
|
615
|
+
' ai — 基于 @imgly/background-removal-node 的 AI 语义分割',
|
|
616
|
+
' ✅ 适用:真实照片、复杂背景、前景与背景颜色相近的场景',
|
|
617
|
+
' ⚠️ 局限:对浅色纹理前景(如木纹麻将牌)可能误判导致褪色;需调用 backend API',
|
|
618
|
+
'',
|
|
619
|
+
' 选择建议:',
|
|
620
|
+
' - AI 生图的素材(Gemini 等),背景通常为纯色/棋盘格 → 用 floodfill',
|
|
621
|
+
' - 真实照片或背景极复杂 → 用 ai',
|
|
622
|
+
' - 不确定时 → 先试 floodfill,效果不好再换 ai',
|
|
623
|
+
].join('\n'))
|
|
457
624
|
.requiredOption('--input <path>', '输入图片路径')
|
|
458
625
|
.requiredOption('--output <path>', '输出路径(必须是 .png,保留透明通道)')
|
|
626
|
+
.option('--method <method>', 'floodfill(本地颜色 flood-fill,默认)或 ai(AI 模型,调用 backend)', 'floodfill')
|
|
627
|
+
.option('--tolerance <n>', '[floodfill] 颜色容差 0-255(越大越激进,默认 30)', cliParseInt, 30)
|
|
628
|
+
.option('--edge-smooth <n>', '[floodfill] 边缘平滑层数 0-4(0=硬边,默认 2)', cliParseInt, 2)
|
|
629
|
+
.option('--model <size>', '[ai] 抠图模型:small / medium(默认 medium)', 'medium')
|
|
630
|
+
.option('--no-refine', '[ai] 跳过 alpha 后处理(保留模型原始软边)')
|
|
459
631
|
.action(async (opts) => {
|
|
460
632
|
try {
|
|
461
633
|
assertInputWithinLimit(opts.input);
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
634
|
+
const method = opts.method ?? 'floodfill';
|
|
635
|
+
if (method === 'floodfill') {
|
|
636
|
+
const t0 = Date.now();
|
|
637
|
+
const { buffer, removedPct, bgColors } = await floodFillRemoveBg(opts.input, {
|
|
638
|
+
tolerance: opts.tolerance ?? 30,
|
|
639
|
+
edgeLayers: Math.min(opts.edgeSmooth ?? 2, 4),
|
|
640
|
+
});
|
|
641
|
+
writeOutput(opts.output, buffer);
|
|
642
|
+
const elapsed = ((Date.now() - t0) / 1000).toFixed(2);
|
|
643
|
+
const origSize = statSync(opts.input).size;
|
|
644
|
+
console.log(`Background removed (floodfill): ${opts.output} (${sizeLabel(buffer)}, orig ${sizeLabelBytes(origSize)}, ${elapsed}s)`);
|
|
645
|
+
console.log(` detected bg: ${bgColors.join(', ') || 'none'} | removed ${removedPct.toFixed(1)}% pixels | tolerance=${opts.tolerance} edge-smooth=${opts.edgeSmooth ?? 2}`);
|
|
646
|
+
if (removedPct < 5) {
|
|
647
|
+
console.log(' ⚠️ 移除比例很低,背景可能较复杂。建议尝试 --method ai 或增大 --tolerance');
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
else if (method === 'ai') {
|
|
651
|
+
const imageBase64 = readFileSync(opts.input).toString('base64');
|
|
652
|
+
const model = opts.model;
|
|
653
|
+
if (model && !['small', 'medium', 'large'].includes(model)) {
|
|
654
|
+
console.error('Error: --model must be small, medium, or large');
|
|
655
|
+
process.exit(1);
|
|
656
|
+
}
|
|
657
|
+
const client = new AgentApiClient();
|
|
658
|
+
const payload = {
|
|
659
|
+
imageBase64,
|
|
660
|
+
model: model ?? 'medium',
|
|
661
|
+
refineAlpha: opts.refine !== false,
|
|
662
|
+
};
|
|
663
|
+
const result = await client.post('/remove-background', payload);
|
|
664
|
+
const buf = Buffer.from(result.imageBase64, 'base64');
|
|
665
|
+
writeOutput(opts.output, buf);
|
|
666
|
+
const origSize = statSync(opts.input).size;
|
|
667
|
+
console.log(`Background removed (ai/${model}): ${opts.output} (${sizeLabel(buf)}, orig ${sizeLabelBytes(origSize)})`);
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
console.error('Error: --method must be floodfill or ai');
|
|
671
|
+
process.exit(1);
|
|
672
|
+
}
|
|
469
673
|
}
|
|
470
674
|
catch (e) {
|
|
471
675
|
handleError(e);
|
package/dist/commands/tools.js
CHANGED
|
@@ -91,36 +91,44 @@ function resolveImageOutputPath(outputPath, wantExt) {
|
|
|
91
91
|
}
|
|
92
92
|
return join(dir, `${name}${wantExt}`);
|
|
93
93
|
}
|
|
94
|
+
function collectReferenceImagePaths(value, previous) {
|
|
95
|
+
return previous.concat([value]);
|
|
96
|
+
}
|
|
97
|
+
function mimeTypeForImagePath(filePath) {
|
|
98
|
+
const ext = filePath.split('.').pop()?.toLowerCase();
|
|
99
|
+
if (ext === 'png')
|
|
100
|
+
return 'image/png';
|
|
101
|
+
if (ext === 'webp')
|
|
102
|
+
return 'image/webp';
|
|
103
|
+
return 'image/jpeg';
|
|
104
|
+
}
|
|
94
105
|
export function registerToolsCommands(program) {
|
|
95
106
|
const tools = program
|
|
96
107
|
.command('tools')
|
|
97
108
|
.description('后端 /api/agent/tools;需 PLAYCRAFT_API_URL + PLAYCRAFT_SANDBOX_TOKEN 或 .playcraft.json');
|
|
98
109
|
// ─── Generation ─────────────────────────────────────────────
|
|
99
110
|
tools.command('generate-image')
|
|
100
|
-
.description('AI
|
|
111
|
+
.description('AI 生成图片(支持多张参考图图生图)')
|
|
101
112
|
.requiredOption('--prompt <text>', '图片描述')
|
|
102
113
|
.option('--aspect-ratio <ratio>', '宽高比 (1:1|16:9|9:16|3:4|4:3)', '1:1')
|
|
103
114
|
.requiredOption('--output <path>', '保存路径')
|
|
104
115
|
.option('--image-size <size>', '图片尺寸 (1K|2K|4K)')
|
|
105
|
-
.option('--reference-image <path>', '
|
|
116
|
+
.option('--reference-image <path>', '参考图路径(可重复多次,最多 8 张),支持 PNG/JPG/WEBP', collectReferenceImagePaths, [])
|
|
106
117
|
.action(async (opts) => {
|
|
107
118
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : 'image/jpeg';
|
|
116
|
-
}
|
|
119
|
+
const paths = opts.referenceImage ?? [];
|
|
120
|
+
const referenceImages = paths.length > 0
|
|
121
|
+
? paths.map((p) => ({
|
|
122
|
+
base64: readFileSync(p).toString('base64'),
|
|
123
|
+
mimeType: mimeTypeForImagePath(p),
|
|
124
|
+
}))
|
|
125
|
+
: undefined;
|
|
117
126
|
const client = new AgentApiClient();
|
|
118
127
|
const result = await client.post('/generate-image', {
|
|
119
128
|
prompt: opts.prompt,
|
|
120
129
|
aspectRatio: opts.aspectRatio,
|
|
121
130
|
imageSize: opts.imageSize,
|
|
122
|
-
|
|
123
|
-
referenceImageMimeType,
|
|
131
|
+
referenceImages,
|
|
124
132
|
});
|
|
125
133
|
const buf = Buffer.from(result.imageBase64, 'base64');
|
|
126
134
|
const sniffed = sniffImageExtension(buf);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.27",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
"release": "node scripts/release.js"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@playcraft/build": "^0.0.
|
|
26
|
-
"@playcraft/common": "^0.0.
|
|
25
|
+
"@playcraft/build": "^0.0.27",
|
|
26
|
+
"@playcraft/common": "^0.0.16",
|
|
27
27
|
"chokidar": "^4.0.3",
|
|
28
28
|
"commander": "^13.1.0",
|
|
29
29
|
"cors": "^2.8.6",
|