@playcraft/cli 0.0.19 → 0.0.22
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/cli-root-help.js +22 -0
- package/dist/commands/audio.js +219 -0
- package/dist/commands/build-all.js +129 -13
- package/dist/commands/build.js +191 -14
- package/dist/commands/image.js +470 -0
- package/dist/commands/tools.js +438 -0
- package/dist/index.js +22 -1
- package/dist/playable/base-builder.js +265 -0
- package/dist/playable/builder.js +1462 -0
- package/dist/playable/converter.js +150 -0
- package/dist/playable/index.js +3 -0
- package/dist/playable/platforms/base.js +12 -0
- package/dist/playable/platforms/facebook.js +37 -0
- package/dist/playable/platforms/index.js +24 -0
- package/dist/playable/platforms/snapchat.js +59 -0
- package/dist/playable/playable-builder.js +521 -0
- package/dist/playable/types.js +1 -0
- package/dist/playable/vite/config-builder.js +136 -0
- package/dist/playable/vite/platform-configs.js +102 -0
- package/dist/playable/vite/plugin-model-compression.js +63 -0
- package/dist/playable/vite/plugin-platform.js +65 -0
- package/dist/playable/vite/plugin-playcanvas.js +454 -0
- package/dist/playable/vite-builder.js +125 -0
- package/dist/utils/agent-api-client.js +82 -0
- package/dist/utils/audio-processor.js +269 -0
- package/package.json +8 -3
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'fs';
|
|
2
|
+
import { basename, dirname } from 'path';
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import { AgentApiClient } from '../utils/agent-api-client.js';
|
|
5
|
+
/** 单文件输入上限(字节),防止误处理巨型文件导致 OOM */
|
|
6
|
+
const DEFAULT_MAX_INPUT_BYTES = 512 * 1024 * 1024;
|
|
7
|
+
function getMaxInputBytes() {
|
|
8
|
+
const raw = process.env.PLAYCRAFT_IMAGE_MAX_INPUT_BYTES;
|
|
9
|
+
if (raw) {
|
|
10
|
+
const n = Number.parseInt(raw, 10);
|
|
11
|
+
if (Number.isFinite(n) && n > 0)
|
|
12
|
+
return n;
|
|
13
|
+
}
|
|
14
|
+
return DEFAULT_MAX_INPUT_BYTES;
|
|
15
|
+
}
|
|
16
|
+
function assertInputWithinLimit(filePath, label = 'input') {
|
|
17
|
+
const max = getMaxInputBytes();
|
|
18
|
+
const st = statSync(filePath);
|
|
19
|
+
if (!st.isFile()) {
|
|
20
|
+
console.error(`Error: ${label} is not a regular file: ${filePath}`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
if (st.size > max) {
|
|
24
|
+
console.error(`Error: ${label} file too large (${st.size} B > ${max} B). Raise PLAYCRAFT_IMAGE_MAX_INPUT_BYTES if needed.`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function ensureDir(filePath) {
|
|
29
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
function writeOutput(path, buf) {
|
|
32
|
+
ensureDir(path);
|
|
33
|
+
writeFileSync(path, buf);
|
|
34
|
+
}
|
|
35
|
+
function sizeLabelBytes(byteLength) {
|
|
36
|
+
const kb = byteLength / 1024;
|
|
37
|
+
return kb >= 1024 ? `${(kb / 1024).toFixed(2)}MB` : `${Math.round(kb)}KB`;
|
|
38
|
+
}
|
|
39
|
+
function sizeLabel(buf) {
|
|
40
|
+
return sizeLabelBytes(buf.length);
|
|
41
|
+
}
|
|
42
|
+
function handleError(err) {
|
|
43
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
44
|
+
console.error(`Error: ${msg}`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
/** CLI --fit → sharp resize `fit`(非法则退出并提示) */
|
|
48
|
+
function parseResizeFitOption(raw) {
|
|
49
|
+
const v = (raw ?? 'cover').toLowerCase();
|
|
50
|
+
if (v === 'contain' ||
|
|
51
|
+
v === 'cover' ||
|
|
52
|
+
v === 'fill' ||
|
|
53
|
+
v === 'inside' ||
|
|
54
|
+
v === 'outside') {
|
|
55
|
+
return v;
|
|
56
|
+
}
|
|
57
|
+
console.error('Error: --fit must be contain|cover|fill|inside|outside');
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
export function registerImageCommands(program) {
|
|
61
|
+
const img = program
|
|
62
|
+
.command('image')
|
|
63
|
+
.description('素材工具:本地图片处理(sharp);remove-background 调用后端 API');
|
|
64
|
+
// ─── resize ──────────────────────────────────────────────────
|
|
65
|
+
img.command('resize')
|
|
66
|
+
.description('缩放图片(按比例或指定宽高)')
|
|
67
|
+
.requiredOption('--input <path>', '输入图片路径')
|
|
68
|
+
.requiredOption('--output <path>', '输出图片路径')
|
|
69
|
+
.option('--width <n>', '目标宽度(像素)', parseInt)
|
|
70
|
+
.option('--height <n>', '目标高度(像素)', parseInt)
|
|
71
|
+
.option('--scale <factor>', '缩放比例(如 0.5 = 50%)', parseFloat)
|
|
72
|
+
.option('--fit <mode>', 'contain|cover|fill|inside|outside', 'cover')
|
|
73
|
+
.action(async (opts) => {
|
|
74
|
+
try {
|
|
75
|
+
assertInputWithinLimit(opts.input);
|
|
76
|
+
const meta = await sharp(opts.input).metadata();
|
|
77
|
+
const w = meta.width ?? 64;
|
|
78
|
+
const h = meta.height ?? 64;
|
|
79
|
+
let targetW;
|
|
80
|
+
let targetH;
|
|
81
|
+
if (opts.scale !== undefined) {
|
|
82
|
+
targetW = Math.round(w * opts.scale);
|
|
83
|
+
targetH = Math.round(h * opts.scale);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
targetW = opts.width;
|
|
87
|
+
targetH = opts.height;
|
|
88
|
+
}
|
|
89
|
+
if (!targetW && !targetH) {
|
|
90
|
+
console.error('Error: provide --scale, --width, or --height');
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
const fit = parseResizeFitOption(opts.fit);
|
|
94
|
+
ensureDir(opts.output);
|
|
95
|
+
await sharp(opts.input)
|
|
96
|
+
.resize(targetW, targetH, { fit })
|
|
97
|
+
.toFile(opts.output);
|
|
98
|
+
const outSz = statSync(opts.output).size;
|
|
99
|
+
console.log(`Resized ${w}x${h} → ${targetW ?? '?'}x${targetH ?? '?'} — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
handleError(e);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
// ─── crop ────────────────────────────────────────────────────
|
|
106
|
+
img.command('crop')
|
|
107
|
+
.description('裁剪图片(指定区域)')
|
|
108
|
+
.requiredOption('--input <path>', '输入图片路径')
|
|
109
|
+
.requiredOption('--output <path>', '输出图片路径')
|
|
110
|
+
.requiredOption('--x <n>', '左边距(像素)', parseInt)
|
|
111
|
+
.requiredOption('--y <n>', '上边距(像素)', parseInt)
|
|
112
|
+
.requiredOption('--width <n>', '裁剪宽度(像素)', parseInt)
|
|
113
|
+
.requiredOption('--height <n>', '裁剪高度(像素)', parseInt)
|
|
114
|
+
.action(async (opts) => {
|
|
115
|
+
try {
|
|
116
|
+
assertInputWithinLimit(opts.input);
|
|
117
|
+
ensureDir(opts.output);
|
|
118
|
+
await sharp(opts.input)
|
|
119
|
+
.extract({ left: opts.x, top: opts.y, width: opts.width, height: opts.height })
|
|
120
|
+
.toFile(opts.output);
|
|
121
|
+
const outSz = statSync(opts.output).size;
|
|
122
|
+
console.log(`Cropped to ${opts.width}x${opts.height} at (${opts.x},${opts.y}) — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
handleError(e);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
// ─── rotate ──────────────────────────────────────────────────
|
|
129
|
+
img.command('rotate')
|
|
130
|
+
.description('旋转图片')
|
|
131
|
+
.requiredOption('--input <path>', '输入图片路径')
|
|
132
|
+
.requiredOption('--output <path>', '输出图片路径')
|
|
133
|
+
.requiredOption('--angle <n>', '旋转角度(顺时针,如 90/180/270 或任意整数)', parseInt)
|
|
134
|
+
.option('--background <color>', '旋转后露出的背景色(CSS hex,如 #000000)', '#00000000')
|
|
135
|
+
.action(async (opts) => {
|
|
136
|
+
try {
|
|
137
|
+
assertInputWithinLimit(opts.input);
|
|
138
|
+
ensureDir(opts.output);
|
|
139
|
+
await sharp(opts.input)
|
|
140
|
+
.rotate(opts.angle, { background: opts.background })
|
|
141
|
+
.toFile(opts.output);
|
|
142
|
+
const outSz = statSync(opts.output).size;
|
|
143
|
+
console.log(`Rotated ${opts.angle}° — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
handleError(e);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
// ─── flip ────────────────────────────────────────────────────
|
|
150
|
+
img.command('flip')
|
|
151
|
+
.description('镜像翻转图片')
|
|
152
|
+
.requiredOption('--input <path>', '输入图片路径')
|
|
153
|
+
.requiredOption('--output <path>', '输出图片路径')
|
|
154
|
+
.option('--direction <dir>', 'horizontal(左右)或 vertical(上下)', 'horizontal')
|
|
155
|
+
.action(async (opts) => {
|
|
156
|
+
try {
|
|
157
|
+
assertInputWithinLimit(opts.input);
|
|
158
|
+
let pipeline = sharp(opts.input);
|
|
159
|
+
if (opts.direction === 'horizontal') {
|
|
160
|
+
pipeline = pipeline.flop();
|
|
161
|
+
}
|
|
162
|
+
else if (opts.direction === 'vertical') {
|
|
163
|
+
pipeline = pipeline.flip();
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
console.error('Error: --direction must be horizontal or vertical');
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
ensureDir(opts.output);
|
|
170
|
+
await pipeline.toFile(opts.output);
|
|
171
|
+
const outSz = statSync(opts.output).size;
|
|
172
|
+
console.log(`Flipped ${opts.direction} — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
handleError(e);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
// ─── pad ─────────────────────────────────────────────────────
|
|
179
|
+
img.command('pad')
|
|
180
|
+
.description('扩展画布,在图片四周添加边距(保持原图居中)')
|
|
181
|
+
.requiredOption('--input <path>', '输入图片路径')
|
|
182
|
+
.requiredOption('--output <path>', '输出图片路径')
|
|
183
|
+
.option('--top <n>', '上边距(像素)', parseInt, 0)
|
|
184
|
+
.option('--bottom <n>', '下边距(像素)', parseInt, 0)
|
|
185
|
+
.option('--left <n>', '左边距(像素)', parseInt, 0)
|
|
186
|
+
.option('--right <n>', '右边距(像素)', parseInt, 0)
|
|
187
|
+
.option('--all <n>', '四周统一边距(覆盖单独方向设置)', parseInt)
|
|
188
|
+
.option('--background <color>', '背景色(CSS hex)', '#00000000')
|
|
189
|
+
.action(async (opts) => {
|
|
190
|
+
try {
|
|
191
|
+
assertInputWithinLimit(opts.input);
|
|
192
|
+
const pad = opts.all !== undefined
|
|
193
|
+
? { top: opts.all, bottom: opts.all, left: opts.all, right: opts.all }
|
|
194
|
+
: { top: opts.top, bottom: opts.bottom, left: opts.left, right: opts.right };
|
|
195
|
+
ensureDir(opts.output);
|
|
196
|
+
await sharp(opts.input)
|
|
197
|
+
.extend({ ...pad, background: opts.background })
|
|
198
|
+
.toFile(opts.output);
|
|
199
|
+
const meta = await sharp(opts.output).metadata();
|
|
200
|
+
const outSz = statSync(opts.output).size;
|
|
201
|
+
console.log(`Padded to ${meta.width}x${meta.height} — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
handleError(e);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
// ─── convert ─────────────────────────────────────────────────
|
|
208
|
+
img.command('convert')
|
|
209
|
+
.description('转换图片格式(png/jpg/webp/avif)并可调整压缩质量')
|
|
210
|
+
.requiredOption('--input <path>', '输入图片路径')
|
|
211
|
+
.requiredOption('--output <path>', '输出图片路径(扩展名决定格式,或用 --format 强制指定)')
|
|
212
|
+
.option('--format <fmt>', '目标格式 (png|jpg|webp|avif),默认从输出路径扩展名推断')
|
|
213
|
+
.option('--quality <n>', '压缩质量 1-100(jpg/webp/avif 有效)', parseInt, 85)
|
|
214
|
+
.option('--lossless', '无损压缩(webp/avif 有效)')
|
|
215
|
+
.action(async (opts) => {
|
|
216
|
+
try {
|
|
217
|
+
assertInputWithinLimit(opts.input);
|
|
218
|
+
const origSize = statSync(opts.input).size;
|
|
219
|
+
const fmt = (opts.format ?? opts.output.split('.').pop() ?? 'png').toLowerCase();
|
|
220
|
+
let pipeline = sharp(opts.input);
|
|
221
|
+
switch (fmt) {
|
|
222
|
+
case 'jpg':
|
|
223
|
+
case 'jpeg':
|
|
224
|
+
pipeline = pipeline.jpeg({ quality: opts.quality });
|
|
225
|
+
break;
|
|
226
|
+
case 'webp':
|
|
227
|
+
pipeline = pipeline.webp({ quality: opts.quality, lossless: !!opts.lossless });
|
|
228
|
+
break;
|
|
229
|
+
case 'avif':
|
|
230
|
+
pipeline = pipeline.avif({ quality: opts.quality, lossless: !!opts.lossless });
|
|
231
|
+
break;
|
|
232
|
+
case 'png':
|
|
233
|
+
default:
|
|
234
|
+
pipeline = pipeline.png();
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
ensureDir(opts.output);
|
|
238
|
+
await pipeline.toFile(opts.output);
|
|
239
|
+
const outSz = statSync(opts.output).size;
|
|
240
|
+
const ratio = origSize > 0 ? ((1 - outSz / origSize) * 100).toFixed(1) : '0.0';
|
|
241
|
+
console.log(`Converted to ${fmt} — ${opts.output} (${sizeLabelBytes(outSz)}, ${ratio}% reduction)`);
|
|
242
|
+
}
|
|
243
|
+
catch (e) {
|
|
244
|
+
handleError(e);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
// ─── grayscale ───────────────────────────────────────────────
|
|
248
|
+
img.command('grayscale')
|
|
249
|
+
.description('图片灰度化')
|
|
250
|
+
.requiredOption('--input <path>', '输入图片路径')
|
|
251
|
+
.requiredOption('--output <path>', '输出图片路径')
|
|
252
|
+
.action(async (opts) => {
|
|
253
|
+
try {
|
|
254
|
+
assertInputWithinLimit(opts.input);
|
|
255
|
+
ensureDir(opts.output);
|
|
256
|
+
await sharp(opts.input).grayscale().toFile(opts.output);
|
|
257
|
+
const outSz = statSync(opts.output).size;
|
|
258
|
+
console.log(`Grayscale — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
259
|
+
}
|
|
260
|
+
catch (e) {
|
|
261
|
+
handleError(e);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
// ─── blur ────────────────────────────────────────────────────
|
|
265
|
+
img.command('blur')
|
|
266
|
+
.description('高斯模糊')
|
|
267
|
+
.requiredOption('--input <path>', '输入图片路径')
|
|
268
|
+
.requiredOption('--output <path>', '输出图片路径')
|
|
269
|
+
.option('--sigma <n>', '模糊强度(0.3–1000,越大越模糊)', parseFloat, 3)
|
|
270
|
+
.action(async (opts) => {
|
|
271
|
+
try {
|
|
272
|
+
assertInputWithinLimit(opts.input);
|
|
273
|
+
ensureDir(opts.output);
|
|
274
|
+
await sharp(opts.input).blur(opts.sigma).toFile(opts.output);
|
|
275
|
+
const outSz = statSync(opts.output).size;
|
|
276
|
+
console.log(`Blurred (sigma=${opts.sigma}) — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
277
|
+
}
|
|
278
|
+
catch (e) {
|
|
279
|
+
handleError(e);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
// ─── tint ────────────────────────────────────────────────────
|
|
283
|
+
img.command('tint')
|
|
284
|
+
.description('叠加色调(做角色不同颜色变体)')
|
|
285
|
+
.requiredOption('--input <path>', '输入图片路径')
|
|
286
|
+
.requiredOption('--output <path>', '输出图片路径')
|
|
287
|
+
.requiredOption('--color <hex>', '叠加颜色(CSS hex,如 #ff0000)')
|
|
288
|
+
.action(async (opts) => {
|
|
289
|
+
try {
|
|
290
|
+
assertInputWithinLimit(opts.input);
|
|
291
|
+
ensureDir(opts.output);
|
|
292
|
+
await sharp(opts.input).tint(opts.color).toFile(opts.output);
|
|
293
|
+
const outSz = statSync(opts.output).size;
|
|
294
|
+
console.log(`Tinted with ${opts.color} — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
295
|
+
}
|
|
296
|
+
catch (e) {
|
|
297
|
+
handleError(e);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
// ─── trim ────────────────────────────────────────────────────
|
|
301
|
+
img.command('trim')
|
|
302
|
+
.description('自动裁掉透明或纯色边界')
|
|
303
|
+
.requiredOption('--input <path>', '输入图片路径')
|
|
304
|
+
.requiredOption('--output <path>', '输出图片路径')
|
|
305
|
+
.option('--threshold <n>', '颜色容差(0-255,默认 10)', parseInt, 10)
|
|
306
|
+
.action(async (opts) => {
|
|
307
|
+
try {
|
|
308
|
+
assertInputWithinLimit(opts.input);
|
|
309
|
+
const before = await sharp(opts.input).metadata();
|
|
310
|
+
ensureDir(opts.output);
|
|
311
|
+
await sharp(opts.input).trim({ threshold: opts.threshold }).toFile(opts.output);
|
|
312
|
+
const after = await sharp(opts.output).metadata();
|
|
313
|
+
const outSz = statSync(opts.output).size;
|
|
314
|
+
console.log(`Trimmed ${before.width}x${before.height} → ${after.width}x${after.height} — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
315
|
+
}
|
|
316
|
+
catch (e) {
|
|
317
|
+
handleError(e);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
// ─── negate ──────────────────────────────────────────────────
|
|
321
|
+
img.command('negate')
|
|
322
|
+
.description('图片反色(颜色取反)')
|
|
323
|
+
.requiredOption('--input <path>', '输入图片路径')
|
|
324
|
+
.requiredOption('--output <path>', '输出图片路径')
|
|
325
|
+
.option('--no-alpha', '不反转透明通道')
|
|
326
|
+
.action(async (opts) => {
|
|
327
|
+
try {
|
|
328
|
+
assertInputWithinLimit(opts.input);
|
|
329
|
+
ensureDir(opts.output);
|
|
330
|
+
await sharp(opts.input).negate({ alpha: opts.alpha !== false }).toFile(opts.output);
|
|
331
|
+
const outSz = statSync(opts.output).size;
|
|
332
|
+
console.log(`Negated — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
333
|
+
}
|
|
334
|
+
catch (e) {
|
|
335
|
+
handleError(e);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
// ─── overlay ─────────────────────────────────────────────────
|
|
339
|
+
img.command('overlay')
|
|
340
|
+
.description('将一张图叠在另一张图的指定位置(合成)')
|
|
341
|
+
.requiredOption('--base <path>', '底图路径')
|
|
342
|
+
.requiredOption('--overlay <path>', '叠加图路径')
|
|
343
|
+
.requiredOption('--output <path>', '输出图片路径')
|
|
344
|
+
.option('--x <n>', '叠加图 X 偏移(像素,默认居中)', parseInt)
|
|
345
|
+
.option('--y <n>', '叠加图 Y 偏移(像素,默认居中)', parseInt)
|
|
346
|
+
.option('--gravity <g>', '对齐方向(center|north|south|east|west...,--x/--y 优先)', 'center')
|
|
347
|
+
.action(async (opts) => {
|
|
348
|
+
try {
|
|
349
|
+
assertInputWithinLimit(opts.base, 'base');
|
|
350
|
+
assertInputWithinLimit(opts.overlay, 'overlay');
|
|
351
|
+
const overlayOpts = { input: opts.overlay };
|
|
352
|
+
if (opts.x !== undefined && opts.y !== undefined) {
|
|
353
|
+
overlayOpts.left = opts.x;
|
|
354
|
+
overlayOpts.top = opts.y;
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
overlayOpts.gravity = opts.gravity;
|
|
358
|
+
}
|
|
359
|
+
ensureDir(opts.output);
|
|
360
|
+
await sharp(opts.base).composite([overlayOpts]).toFile(opts.output);
|
|
361
|
+
const meta = await sharp(opts.output).metadata();
|
|
362
|
+
const outSz = statSync(opts.output).size;
|
|
363
|
+
console.log(`Composited — ${meta.width}x${meta.height} — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
364
|
+
}
|
|
365
|
+
catch (e) {
|
|
366
|
+
handleError(e);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
// ─── pixelate ────────────────────────────────────────────────
|
|
370
|
+
img.command('pixelate')
|
|
371
|
+
.description('像素化图片(像素风格效果)')
|
|
372
|
+
.requiredOption('--input <path>', '输入图片路径')
|
|
373
|
+
.requiredOption('--output <path>', '输出图片路径')
|
|
374
|
+
.option('--pixel-size <n>', '像素块大小(默认 8)', parseInt, 8)
|
|
375
|
+
.action(async (opts) => {
|
|
376
|
+
try {
|
|
377
|
+
assertInputWithinLimit(opts.input);
|
|
378
|
+
const meta = await sharp(opts.input).metadata();
|
|
379
|
+
const w = meta.width ?? 64;
|
|
380
|
+
const h = meta.height ?? 64;
|
|
381
|
+
const smallW = Math.max(1, Math.floor(w / opts.pixelSize));
|
|
382
|
+
const smallH = Math.max(1, Math.floor(h / opts.pixelSize));
|
|
383
|
+
ensureDir(opts.output);
|
|
384
|
+
await sharp(opts.input)
|
|
385
|
+
.resize(smallW, smallH, { kernel: 'nearest' })
|
|
386
|
+
.resize(w, h, { kernel: 'nearest' })
|
|
387
|
+
.toFile(opts.output);
|
|
388
|
+
const outSz = statSync(opts.output).size;
|
|
389
|
+
console.log(`Pixelated (block=${opts.pixelSize}px) — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
390
|
+
}
|
|
391
|
+
catch (e) {
|
|
392
|
+
handleError(e);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
// ─── sprite-sheet ────────────────────────────────────────────
|
|
396
|
+
img.command('sprite-sheet')
|
|
397
|
+
.description('将多张图合并为精灵图,并输出帧坐标 JSON(完整本地实现)')
|
|
398
|
+
.requiredOption('--inputs <paths>', '输入图片路径列表,逗号分隔')
|
|
399
|
+
.requiredOption('--output <basePath>', '输出基路径(自动生成 .png 和 .json)')
|
|
400
|
+
.option('--columns <n>', '列数(默认自动按平方根计算)', parseInt)
|
|
401
|
+
.option('--padding <n>', '帧间距(像素,默认 0)', parseInt, 0)
|
|
402
|
+
.option('--cell-width <n>', '统一格子宽度(不填则用第一张图宽度)', parseInt)
|
|
403
|
+
.option('--cell-height <n>', '统一格子高度(不填则用第一张图高度)', parseInt)
|
|
404
|
+
.action(async (opts) => {
|
|
405
|
+
try {
|
|
406
|
+
const paths = opts.inputs.split(',').map((p) => p.trim()).filter(Boolean);
|
|
407
|
+
if (!paths.length) {
|
|
408
|
+
console.error('Error: --inputs is empty');
|
|
409
|
+
process.exit(1);
|
|
410
|
+
}
|
|
411
|
+
for (let i = 0; i < paths.length; i++) {
|
|
412
|
+
assertInputWithinLimit(paths[i], `inputs[${i}]`);
|
|
413
|
+
}
|
|
414
|
+
const firstMeta = await sharp(paths[0]).metadata();
|
|
415
|
+
const cellW = opts.cellWidth ?? firstMeta.width ?? 64;
|
|
416
|
+
const cellH = opts.cellHeight ?? firstMeta.height ?? 64;
|
|
417
|
+
const padding = opts.padding;
|
|
418
|
+
const cols = opts.columns ?? Math.ceil(Math.sqrt(paths.length));
|
|
419
|
+
const rows = Math.ceil(paths.length / cols);
|
|
420
|
+
const totalW = cols * cellW + (cols - 1) * padding;
|
|
421
|
+
const totalH = rows * cellH + (rows - 1) * padding;
|
|
422
|
+
const composites = [];
|
|
423
|
+
const frames = {};
|
|
424
|
+
for (let i = 0; i < paths.length; i++) {
|
|
425
|
+
const col = i % cols;
|
|
426
|
+
const row = Math.floor(i / cols);
|
|
427
|
+
const x = col * (cellW + padding);
|
|
428
|
+
const y = row * (cellH + padding);
|
|
429
|
+
const frameName = basename(paths[i]) || `frame_${i}`;
|
|
430
|
+
const resized = await sharp(paths[i]).resize(cellW, cellH).toBuffer();
|
|
431
|
+
composites.push({ input: resized, left: x, top: y });
|
|
432
|
+
frames[frameName] = { x, y, w: cellW, h: cellH };
|
|
433
|
+
}
|
|
434
|
+
const canvas = await sharp({
|
|
435
|
+
create: { width: totalW, height: totalH, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
|
|
436
|
+
}).png().toBuffer();
|
|
437
|
+
const sheetBuf = await sharp(canvas).composite(composites).png().toBuffer();
|
|
438
|
+
const basePath = opts.output.replace(/\.(png|json)$/i, '');
|
|
439
|
+
const pngPath = `${basePath}.png`;
|
|
440
|
+
const jsonPath = `${basePath}.json`;
|
|
441
|
+
writeOutput(pngPath, sheetBuf);
|
|
442
|
+
writeOutput(jsonPath, Buffer.from(JSON.stringify({ frames, meta: { image: pngPath, size: { w: totalW, h: totalH }, scale: 1 } }, null, 2), 'utf-8'));
|
|
443
|
+
console.log(`Sprite sheet: ${pngPath} (${cols}x${rows} grid, ${paths.length} frames, ${sizeLabel(sheetBuf)})`);
|
|
444
|
+
console.log(`Frame data: ${jsonPath}`);
|
|
445
|
+
}
|
|
446
|
+
catch (e) {
|
|
447
|
+
handleError(e);
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
// ─── remove-background(AI 背景移除,调用 backend)────────────
|
|
451
|
+
img.command('remove-background')
|
|
452
|
+
.description('AI 背景移除,输出透明 PNG(模型运行在 backend,无需沙箱加载大模型)')
|
|
453
|
+
.requiredOption('--input <path>', '输入图片路径')
|
|
454
|
+
.requiredOption('--output <path>', '输出路径(必须是 .png,保留透明通道)')
|
|
455
|
+
.action(async (opts) => {
|
|
456
|
+
try {
|
|
457
|
+
assertInputWithinLimit(opts.input);
|
|
458
|
+
const imageBase64 = readFileSync(opts.input).toString('base64');
|
|
459
|
+
const client = new AgentApiClient();
|
|
460
|
+
const result = await client.post('/remove-background', { imageBase64 });
|
|
461
|
+
const buf = Buffer.from(result.imageBase64, 'base64');
|
|
462
|
+
writeOutput(opts.output, buf);
|
|
463
|
+
const origSize = statSync(opts.input).size;
|
|
464
|
+
console.log(`Background removed: ${opts.output} (${sizeLabel(buf)}, orig ${sizeLabelBytes(origSize)})`);
|
|
465
|
+
}
|
|
466
|
+
catch (e) {
|
|
467
|
+
handleError(e);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
}
|