@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.
@@ -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(AI 背景移除,调用 backend)────────────
605
+ // ─── remove-background ─────────────────────────────────────────
455
606
  img.command('remove-background')
456
- .description('AI 背景移除,输出透明 PNG(模型运行在 backend,无需沙箱加载大模型)')
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 imageBase64 = readFileSync(opts.input).toString('base64');
463
- const client = new AgentApiClient();
464
- const result = await client.post('/remove-background', { imageBase64 });
465
- const buf = Buffer.from(result.imageBase64, 'base64');
466
- writeOutput(opts.output, buf);
467
- const origSize = statSync(opts.input).size;
468
- console.log(`Background removed: ${opts.output} (${sizeLabel(buf)}, orig ${sizeLabelBytes(origSize)})`);
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);
@@ -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>', '参考图路径(图生图),支持 PNG/JPG/WEBP')
116
+ .option('--reference-image <path>', '参考图路径(可重复多次,最多 8 张),支持 PNG/JPG/WEBP', collectReferenceImagePaths, [])
106
117
  .action(async (opts) => {
107
118
  try {
108
- let referenceImageBase64;
109
- let referenceImageMimeType;
110
- if (opts.referenceImage) {
111
- const imgBuf = readFileSync(opts.referenceImage);
112
- referenceImageBase64 = imgBuf.toString('base64');
113
- const ext = opts.referenceImage.split('.').pop()?.toLowerCase();
114
- referenceImageMimeType =
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
- referenceImageBase64,
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.25",
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.25",
26
- "@playcraft/common": "^0.0.14",
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",