@playcraft/cli 0.0.40 → 0.0.42
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/README.md +66 -3
- package/dist/atom-plan/validate-atom-plan.js +298 -0
- package/dist/cli-root-help.js +1 -1
- package/dist/commands/3d.js +363 -0
- package/dist/commands/create.js +337 -0
- package/dist/commands/image.js +1337 -43
- package/dist/commands/recommend.js +1 -1
- package/dist/commands/remix.js +213 -0
- package/dist/commands/skills.js +1379 -0
- package/dist/commands/tools-3d.js +473 -0
- package/dist/commands/tools-generation.js +452 -0
- package/dist/commands/tools-project.js +400 -0
- package/dist/commands/tools-research.js +37 -0
- package/dist/commands/tools-research.test.js +216 -0
- package/dist/commands/tools-utils.js +183 -0
- package/dist/commands/tools.js +7 -616
- package/dist/config.js +2 -0
- package/dist/index.js +19 -1
- package/dist/utils/version-checker.js +8 -11
- package/package.json +9 -3
- package/project-template/.claude/agents/designer.md +120 -0
- package/project-template/.claude/agents/developer.md +124 -0
- package/project-template/.claude/agents/pm.md +164 -0
- package/project-template/.claude/agents/refs/README.md +73 -0
- package/project-template/.claude/agents/refs/designer-art-style-catalog.md +533 -0
- package/project-template/.claude/agents/refs/designer-color-audio-recipes.md +153 -0
- package/project-template/.claude/agents/refs/designer-deliverable-spec.md +191 -0
- package/project-template/.claude/agents/refs/designer-dimension-axis.md +27 -0
- package/project-template/.claude/agents/refs/designer-handoff-v2-checklist.md +68 -0
- package/project-template/.claude/agents/refs/designer-master-composite-recipes.md +208 -0
- package/project-template/.claude/agents/refs/designer-style-exploration-flow.md +37 -0
- package/project-template/.claude/agents/refs/developer-dev-handoff.md +109 -0
- package/project-template/.claude/agents/refs/developer-impl-cookbook.md +134 -0
- package/project-template/.claude/agents/refs/developer-phase1-flow.md +136 -0
- package/project-template/.claude/agents/refs/pm-workflow-detail.md +551 -0
- package/project-template/.claude/agents/refs/reviewer-convergence-eval.md +130 -0
- package/project-template/.claude/agents/refs/reviewer-six-dimension-eval.md +6 -0
- package/project-template/.claude/agents/refs/ta-3d-flip-recipe.md +85 -0
- package/project-template/.claude/agents/refs/ta-atlas-deliverable-standard.md +67 -0
- package/project-template/.claude/agents/refs/ta-batch-pipeline-recipes.md +120 -0
- package/project-template/.claude/agents/refs/ta-image-generation-detail.md +356 -0
- package/project-template/.claude/agents/refs/ta-image-ops-reference.md +495 -0
- package/project-template/.claude/agents/refs/ta-pipeline-cookbook.md +1108 -0
- package/project-template/.claude/agents/refs/ta-tools-reference.md +111 -0
- package/project-template/.claude/agents/refs/ta-vfx-preset-catalog.md +365 -0
- package/project-template/.claude/agents/reviewer.md +127 -0
- package/project-template/.claude/agents/technical-artist.md +122 -0
- package/project-template/.claude/hooks/README.md +44 -0
- package/project-template/.claude/hooks/validate-atom-plan.mjs +224 -0
- package/project-template/.claude/hooks/validate-workflow-stop.mjs +343 -0
- package/project-template/.claude/settings.json +36 -0
- package/project-template/.claude/settings.local.json +4 -0
- package/project-template/.claude/skills/playcraft-ad-psychology/SKILL.md +182 -0
- package/project-template/.claude/skills/playcraft-art-style-guide/SKILL.md +123 -0
- package/project-template/.claude/skills/playcraft-asset-state-sheet/SKILL.md +141 -0
- package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +280 -0
- package/project-template/.claude/skills/playcraft-batch-pipeline/SKILL.md +184 -0
- package/project-template/.claude/skills/playcraft-build-optimizer/SKILL.md +306 -0
- package/project-template/.claude/skills/playcraft-image-generation/SKILL.md +279 -0
- package/project-template/.claude/skills/playcraft-image-generation/reference/build-sprite-sheet.template.mjs +123 -0
- package/project-template/.claude/skills/playcraft-image-generation/reference/compare-style.template.mjs +254 -0
- package/project-template/.claude/skills/playcraft-image-generation/reference/gen-batch-sprite.template.mjs +235 -0
- package/project-template/.claude/skills/playcraft-image-generation/reference/gen-batch.template.mjs +97 -0
- package/project-template/.claude/skills/playcraft-image-generation/reference/gen-edit-variants.template.mjs +118 -0
- package/project-template/.claude/skills/playcraft-image-generation/reference/process-batch.template.mjs +137 -0
- package/project-template/.claude/skills/playcraft-image-generation/reference/prompt-cookbook.md +397 -0
- package/project-template/.claude/skills/playcraft-image-generation/reference/validate-sprite-sheet.template.mjs +296 -0
- package/project-template/.claude/skills/playcraft-image-ops/SKILL.md +122 -0
- package/project-template/.claude/skills/playcraft-masking/SKILL.md +373 -0
- package/project-template/.claude/skills/playcraft-research/SKILL.md +212 -0
- package/project-template/.claude/skills/playcraft-sprite-generation/SKILL.md +423 -0
- package/project-template/.claude/skills/playcraft-storyboard/SKILL.md +167 -0
- package/project-template/.claude/skills/playcraft-style-qa/SKILL.md +270 -0
- package/project-template/.claude/skills/playcraft-text-rendering/SKILL.md +236 -0
- package/project-template/.claude/skills/playcraft-vfx-animation/SKILL.md +130 -0
- package/project-template/.claude/skills/playcraft-workflow/SKILL.md +485 -0
- package/project-template/.claude/skills/playwright-cli/SKILL.md +390 -0
- package/project-template/.claude/skills/playwright-cli/references/element-attributes.md +23 -0
- package/project-template/.claude/skills/playwright-cli/references/playwright-tests.md +39 -0
- package/project-template/.claude/skills/playwright-cli/references/request-mocking.md +87 -0
- package/project-template/.claude/skills/playwright-cli/references/running-code.md +240 -0
- package/project-template/.claude/skills/playwright-cli/references/session-management.md +226 -0
- package/project-template/.claude/skills/playwright-cli/references/spec-driven-testing.md +312 -0
- package/project-template/.claude/skills/playwright-cli/references/storage-state.md +275 -0
- package/project-template/.claude/skills/playwright-cli/references/test-generation.md +138 -0
- package/project-template/.claude/skills/playwright-cli/references/tracing.md +142 -0
- package/project-template/.claude/skills/playwright-cli/references/video-recording.md +157 -0
- package/project-template/.cursor/hooks.json +17 -0
- package/project-template/.cursor/rules/playcraft-orchestrator.mdc +137 -0
- package/project-template/.cursor/rules/playcraft-subagent-boundary.mdc +18 -0
- package/project-template/CLAUDE.md +280 -0
- package/project-template/assets/audio/bgm/.gitkeep +0 -0
- package/project-template/assets/audio/sfx/.gitkeep +0 -0
- package/project-template/assets/bundles/.gitkeep +0 -0
- package/project-template/assets/images/bg/.gitkeep +0 -0
- package/project-template/assets/images/reference/.gitkeep +0 -0
- package/project-template/assets/images/storyboard/.gitkeep +0 -0
- package/project-template/assets/images/tiles/.gitkeep +0 -0
- package/project-template/assets/images/ui/.gitkeep +0 -0
- package/project-template/assets/images/vfx/.gitkeep +0 -0
- package/project-template/assets/models/.gitkeep +0 -0
- package/project-template/docs/team/agent-conduct.md +121 -0
- package/project-template/docs/team/agent-runtime-matrix.md +62 -0
- package/project-template/docs/team/atom-plan-format.md +105 -0
- package/project-template/docs/team/collaboration.md +297 -0
- package/project-template/docs/team/core-model.md +50 -0
- package/project-template/docs/team/platform-capabilities.md +15 -0
- package/project-template/docs/team/workflow-changelog.md +65 -0
- package/project-template/docs/team/workflow-consistency-checklist.md +140 -0
- package/project-template/game/config/.gitkeep +0 -0
- package/project-template/game/gameplay/.gitkeep +0 -0
- package/project-template/game/scenes/.gitkeep +0 -0
- package/project-template/logs/.gitkeep +0 -0
- package/project-template/ta-workspace/logs/.gitkeep +0 -0
- package/project-template/ta-workspace/scripts/.gitkeep +0 -0
- package/project-template/ta-workspace/tmp/.gitkeep +0 -0
- package/project-template/templates/atom-plan.template.json +26 -0
- package/project-template/templates/atom-plan.template.md +108 -0
- package/project-template/templates/design-brief.template.md +195 -0
- package/project-template/templates/design-lens-checklist.reference.md +117 -0
- package/project-template/templates/design-methodology.md +99 -0
- package/project-template/templates/designer-log.template.md +114 -0
- package/project-template/templates/developer-log.template.md +134 -0
- package/project-template/templates/five-axis-framework.md +186 -0
- package/project-template/templates/intent-clarifications.template.md +58 -0
- package/project-template/templates/layout-spec.template.md +146 -0
- package/project-template/templates/project-state.template.md +237 -0
- package/project-template/templates/review-report.template.md +91 -0
- package/project-template/templates/style-exploration.template.md +93 -0
- package/project-template/templates/ta-log.template.md +343 -0
package/dist/commands/image.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'fs';
|
|
2
|
-
import { basename, dirname, join } from 'path';
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'fs';
|
|
2
|
+
import { basename, dirname, extname, join, resolve } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
3
4
|
import sharp from 'sharp';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
4
6
|
import { AgentApiClient } from '../utils/agent-api-client.js';
|
|
5
7
|
/** 单文件输入上限(字节),防止误处理巨型文件导致 OOM */
|
|
6
8
|
const DEFAULT_MAX_INPUT_BYTES = 512 * 1024 * 1024;
|
|
@@ -48,6 +50,84 @@ function handleError(err) {
|
|
|
48
50
|
console.error(`Error: ${msg}`);
|
|
49
51
|
process.exit(1);
|
|
50
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* sharp 不允许 input 与 output 是同一文件(toFile 会报错)。
|
|
55
|
+
* safeToFile 自动检测同路径场景:先写临时文件,成功后原子 rename 覆盖。
|
|
56
|
+
*/
|
|
57
|
+
async function safeToFile(pipeline, outputPath, inputPath) {
|
|
58
|
+
ensureDir(outputPath);
|
|
59
|
+
if (inputPath && resolve(inputPath) === resolve(outputPath)) {
|
|
60
|
+
const tmpPath = outputPath + '.__tmp__';
|
|
61
|
+
try {
|
|
62
|
+
await pipeline.toFile(tmpPath);
|
|
63
|
+
renameSync(tmpPath, outputPath);
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
try {
|
|
67
|
+
if (existsSync(tmpPath)) {
|
|
68
|
+
const { unlinkSync } = await import('fs');
|
|
69
|
+
unlinkSync(tmpPath);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch { /* ignore */ }
|
|
73
|
+
throw e;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
await pipeline.toFile(outputPath);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 简易 glob 匹配:将 *.ext 转为正则,仅匹配文件名(不含路径分隔符)。
|
|
82
|
+
* 支持 * 和 ? 通配符,不支持深层路径通配。
|
|
83
|
+
*/
|
|
84
|
+
function globPattern(pattern) {
|
|
85
|
+
const ext = extname(pattern);
|
|
86
|
+
const base = basename(pattern, ext);
|
|
87
|
+
const esc = (s) => s.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
88
|
+
const toRegex = (part) => esc(part).replace(/\\\*/g, '.*').replace(/\\\?/g, '.');
|
|
89
|
+
const nameReg = toRegex(base) + (ext ? esc(ext) : '');
|
|
90
|
+
return new RegExp(`^${nameReg}$`, 'i');
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 从目录中按 glob 模式收集文件,按文件名自然排序。
|
|
94
|
+
* pattern 只做文件名匹配(如 "*.png",不支持子目录 **)。
|
|
95
|
+
*/
|
|
96
|
+
function collectFromDir(dir, pattern = '*.png', sort = 'name') {
|
|
97
|
+
if (!existsSync(dir))
|
|
98
|
+
return [];
|
|
99
|
+
const reg = globPattern(pattern);
|
|
100
|
+
const files = readdirSync(dir)
|
|
101
|
+
.filter((f) => reg.test(f) && statSync(join(dir, f)).isFile())
|
|
102
|
+
.map((f) => join(dir, f));
|
|
103
|
+
if (sort === 'name') {
|
|
104
|
+
files.sort((a, b) => basename(a).localeCompare(basename(b), undefined, { numeric: true, sensitivity: 'base' }));
|
|
105
|
+
}
|
|
106
|
+
else if (sort === 'modified') {
|
|
107
|
+
files.sort((a, b) => statSync(a).mtimeMs - statSync(b).mtimeMs);
|
|
108
|
+
}
|
|
109
|
+
return files;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 展开逗号分隔的输入列表,支持简单 glob 模式(含 * 或 ?)。
|
|
113
|
+
* glob 只匹配当前目录文件(不支持子目录通配)。
|
|
114
|
+
*/
|
|
115
|
+
function expandInputPaths(raw) {
|
|
116
|
+
const parts = raw.split(',').map((p) => p.trim()).filter(Boolean);
|
|
117
|
+
const result = [];
|
|
118
|
+
for (const part of parts) {
|
|
119
|
+
if (part.includes('*') || part.includes('?')) {
|
|
120
|
+
const dir = dirname(part);
|
|
121
|
+
const pat = basename(part);
|
|
122
|
+
const matched = collectFromDir(dir === '.' ? process.cwd() : dir, pat, 'name');
|
|
123
|
+
result.push(...matched);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
result.push(part);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
51
131
|
/** 多圈边缘羽化默认 alpha;若 edgeLayers 更大则自最后一档按衰减补齐。 */
|
|
52
132
|
const DEFAULT_EDGE_LAYER_ALPHAS = [200, 120, 60];
|
|
53
133
|
function buildEdgeLayerAlphas(edgeLayers) {
|
|
@@ -293,10 +373,418 @@ async function splitSpriteSheetToDir(inputPath, outputDir, analysis) {
|
|
|
293
373
|
writeFileSync(join(outputDir, 'split-meta.json'), JSON.stringify(splitMeta, null, 2), 'utf-8');
|
|
294
374
|
return written;
|
|
295
375
|
}
|
|
376
|
+
const ANIMATE_PRESETS = {
|
|
377
|
+
spin: { description: 'Y-axis rotation illusion via scale-x oscillation', example: 'coins, icons, medals' },
|
|
378
|
+
bounce: { description: 'Vertical sine-wave translation', example: 'characters, collectibles' },
|
|
379
|
+
pulse: { description: 'Scale oscillation (grow/shrink loop)', example: 'buttons, highlights, effects' },
|
|
380
|
+
shake: { description: 'Random small xy offsets', example: 'hit effects, alerts, damage' },
|
|
381
|
+
float: { description: 'Gentle up/down float with subtle rotation', example: 'floating items, power-ups' },
|
|
382
|
+
blink: { description: 'Opacity oscillation (appear/disappear)', example: 'cursors, prompts, warnings' },
|
|
383
|
+
swing: { description: 'Pendulum rotation around top-center pivot', example: 'hanging objects, lanterns' },
|
|
384
|
+
wobble: { description: 'Alternating slight rotation left/right', example: 'jelly, soft objects, blobs' },
|
|
385
|
+
};
|
|
386
|
+
/** Compute per-frame affine transform { scaleX, scaleY, translateX, translateY, rotate (deg), opacity } */
|
|
387
|
+
function computeFrameTransforms(preset, frameCount) {
|
|
388
|
+
const frames = [];
|
|
389
|
+
for (let i = 0; i < frameCount; i++) {
|
|
390
|
+
const t = i / frameCount; // 0..1 progress
|
|
391
|
+
const sin = Math.sin(t * Math.PI * 2);
|
|
392
|
+
const cos = Math.cos(t * Math.PI * 2);
|
|
393
|
+
switch (preset) {
|
|
394
|
+
case 'spin':
|
|
395
|
+
// Y-rotation illusion: scale-x oscillates -1..1, slight y scale for depth
|
|
396
|
+
frames.push({ scaleX: cos, scaleY: 1, tx: 0, ty: 0, rotate: 0, opacity: 1 });
|
|
397
|
+
break;
|
|
398
|
+
case 'bounce':
|
|
399
|
+
frames.push({ scaleX: 1, scaleY: 1, tx: 0, ty: Math.round(sin * -12), rotate: 0, opacity: 1 });
|
|
400
|
+
break;
|
|
401
|
+
case 'pulse': {
|
|
402
|
+
const s = 1 + Math.abs(sin) * 0.15;
|
|
403
|
+
frames.push({ scaleX: s, scaleY: s, tx: 0, ty: 0, rotate: 0, opacity: 1 });
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
case 'shake': {
|
|
407
|
+
const r = (seed) => (((Math.sin(seed * 127.3 + i * 43.7) + 1) / 2) * 2 - 1);
|
|
408
|
+
frames.push({ scaleX: 1, scaleY: 1, tx: Math.round(r(0) * 4), ty: Math.round(r(1) * 4), rotate: 0, opacity: 1 });
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
case 'float':
|
|
412
|
+
frames.push({ scaleX: 1, scaleY: 1, tx: 0, ty: Math.round(sin * -8), rotate: sin * 2, opacity: 1 });
|
|
413
|
+
break;
|
|
414
|
+
case 'blink':
|
|
415
|
+
frames.push({ scaleX: 1, scaleY: 1, tx: 0, ty: 0, rotate: 0, opacity: Math.max(0.2, (cos + 1) / 2) });
|
|
416
|
+
break;
|
|
417
|
+
case 'swing':
|
|
418
|
+
frames.push({ scaleX: 1, scaleY: 1, tx: 0, ty: 0, rotate: sin * 18, opacity: 1 });
|
|
419
|
+
break;
|
|
420
|
+
case 'wobble':
|
|
421
|
+
frames.push({ scaleX: 1, scaleY: 1, tx: 0, ty: 0, rotate: sin * 10, opacity: 1 });
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return frames;
|
|
426
|
+
}
|
|
427
|
+
async function renderAnimFrame(inputPath, W, H, tf) {
|
|
428
|
+
// 1. Start with RGBA source
|
|
429
|
+
let imgBuf = await sharp(inputPath).ensureAlpha().toBuffer();
|
|
430
|
+
// 2. Scale: compute target dimensions (minimum 1px)
|
|
431
|
+
const targetW = Math.max(1, Math.round(Math.abs(tf.scaleX) * W));
|
|
432
|
+
const targetH = Math.max(1, Math.round(Math.abs(tf.scaleY) * H));
|
|
433
|
+
if (targetW !== W || targetH !== H) {
|
|
434
|
+
imgBuf = await sharp(imgBuf).resize(targetW, targetH, { fit: 'fill' }).toBuffer();
|
|
435
|
+
}
|
|
436
|
+
// 3. Horizontal flip for negative scaleX (simulate Y-axis rotation)
|
|
437
|
+
if (tf.scaleX < 0) {
|
|
438
|
+
imgBuf = await sharp(imgBuf).flop().toBuffer();
|
|
439
|
+
}
|
|
440
|
+
// 4. Vertical flip for negative scaleY
|
|
441
|
+
if (tf.scaleY < 0) {
|
|
442
|
+
imgBuf = await sharp(imgBuf).flip().toBuffer();
|
|
443
|
+
}
|
|
444
|
+
// 5. Rotation (sharp.rotate adds transparent padding — resize back to fit W×H)
|
|
445
|
+
if (Math.abs(tf.rotate) > 0.1) {
|
|
446
|
+
imgBuf = await sharp(imgBuf)
|
|
447
|
+
.rotate(tf.rotate, { background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
448
|
+
.resize(W, H, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
449
|
+
.toBuffer();
|
|
450
|
+
}
|
|
451
|
+
// 6. Opacity
|
|
452
|
+
if (tf.opacity < 1) {
|
|
453
|
+
const raw = await sharp(imgBuf).raw().toBuffer({ resolveWithObject: true });
|
|
454
|
+
const px = raw.data;
|
|
455
|
+
for (let p = 3; p < px.length; p += 4) {
|
|
456
|
+
px[p] = Math.round((px[p] ?? 255) * tf.opacity);
|
|
457
|
+
}
|
|
458
|
+
imgBuf = await sharp(Buffer.from(px), {
|
|
459
|
+
raw: { width: raw.info.width, height: raw.info.height, channels: 4 },
|
|
460
|
+
})
|
|
461
|
+
.png()
|
|
462
|
+
.toBuffer();
|
|
463
|
+
}
|
|
464
|
+
// 7. Ensure final image fits within W×H before compositing
|
|
465
|
+
const fm = await sharp(imgBuf).metadata();
|
|
466
|
+
let fw = fm.width ?? targetW;
|
|
467
|
+
let fh = fm.height ?? targetH;
|
|
468
|
+
if (fw > W || fh > H) {
|
|
469
|
+
imgBuf = await sharp(imgBuf)
|
|
470
|
+
.resize(W, H, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
471
|
+
.toBuffer();
|
|
472
|
+
const fm2 = await sharp(imgBuf).metadata();
|
|
473
|
+
fw = fm2.width ?? W;
|
|
474
|
+
fh = fm2.height ?? H;
|
|
475
|
+
}
|
|
476
|
+
const left = Math.round((W - fw) / 2 + tf.tx);
|
|
477
|
+
const top = Math.round((H - fh) / 2 + tf.ty);
|
|
478
|
+
return sharp({
|
|
479
|
+
create: { width: W, height: H, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
|
|
480
|
+
})
|
|
481
|
+
.composite([{ input: imgBuf, left: Math.max(0, left), top: Math.max(0, top) }])
|
|
482
|
+
.png()
|
|
483
|
+
.toBuffer();
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Render one frame of a card flip with perspective simulation:
|
|
487
|
+
* - scaleX: horizontal compression ratio (0..1)
|
|
488
|
+
* - progress: 0 = face-on, 1 = fully edge-on (controls height squeeze + shadow)
|
|
489
|
+
* - shadowSide: 'left' for front-to-edge, 'right' for edge-to-back
|
|
490
|
+
*/
|
|
491
|
+
/**
|
|
492
|
+
* t = 0: face-on (no rotation)
|
|
493
|
+
* t = 1: fully edge-on (90°)
|
|
494
|
+
*
|
|
495
|
+
* Physical model:
|
|
496
|
+
* - scaleX = cos(t × π/2) → fast initially, slow near edge (cosine ease-in)
|
|
497
|
+
* - scaleY = 1 - t² × 0.06 → subtle height squeeze, strongest at edge
|
|
498
|
+
* - shadow = sin²(t × π/2) → almost zero when face-on, peaks at edge (sin² curve)
|
|
499
|
+
* - highlight strip on the lit edge (opposite side from shadow)
|
|
500
|
+
*/
|
|
501
|
+
async function renderFlipFrame(srcBuf, W, H, scaleX, t, // rotation progress 0..1 (0=face-on, 1=edge-on)
|
|
502
|
+
shadowSide) {
|
|
503
|
+
// Height: slight squeeze at edge-on, follows t²
|
|
504
|
+
const scaleY = Math.max(0.88, 1 - t * t * 0.06);
|
|
505
|
+
const targetW = Math.max(1, Math.round(scaleX * W));
|
|
506
|
+
const targetH = Math.max(1, Math.round(scaleY * H));
|
|
507
|
+
let imgBuf = await sharp(srcBuf)
|
|
508
|
+
.resize(targetW, targetH, { fit: 'fill' })
|
|
509
|
+
.ensureAlpha()
|
|
510
|
+
.toBuffer();
|
|
511
|
+
// Shadow + highlight overlay (only meaningful when actually rotating)
|
|
512
|
+
if (t > 0.08) {
|
|
513
|
+
// sin² curve: nearly 0 near face-on, peaks sharply near edge
|
|
514
|
+
const shadowT = Math.sin(t * Math.PI / 2);
|
|
515
|
+
const shadowAlpha = Math.min(0.80, shadowT * shadowT * 0.90);
|
|
516
|
+
const highlightAlpha = Math.min(0.25, shadowT * shadowT * 0.30);
|
|
517
|
+
const shadowX1 = shadowSide === 'left' ? '0%' : '100%';
|
|
518
|
+
const shadowX2 = shadowSide === 'left' ? '70%' : '30%';
|
|
519
|
+
const hlX1 = shadowSide === 'left' ? '100%' : '0%';
|
|
520
|
+
const hlX2 = shadowSide === 'left' ? '80%' : '20%';
|
|
521
|
+
const overlaySvg = `<svg width="${targetW}" height="${targetH}" xmlns="http://www.w3.org/2000/svg">
|
|
522
|
+
<defs>
|
|
523
|
+
<linearGradient id="sh" x1="${shadowX1}" y1="0%" x2="${shadowX2}" y2="0%">
|
|
524
|
+
<stop offset="0%" stop-color="black" stop-opacity="${shadowAlpha.toFixed(3)}"/>
|
|
525
|
+
<stop offset="100%" stop-color="black" stop-opacity="0"/>
|
|
526
|
+
</linearGradient>
|
|
527
|
+
<linearGradient id="hl" x1="${hlX1}" y1="0%" x2="${hlX2}" y2="0%">
|
|
528
|
+
<stop offset="0%" stop-color="white" stop-opacity="${highlightAlpha.toFixed(3)}"/>
|
|
529
|
+
<stop offset="100%" stop-color="white" stop-opacity="0"/>
|
|
530
|
+
</linearGradient>
|
|
531
|
+
</defs>
|
|
532
|
+
<rect width="${targetW}" height="${targetH}" fill="url(#sh)"/>
|
|
533
|
+
<rect width="${targetW}" height="${targetH}" fill="url(#hl)"/>
|
|
534
|
+
</svg>`;
|
|
535
|
+
const overlayBuf = await sharp(Buffer.from(overlaySvg)).png().toBuffer();
|
|
536
|
+
imgBuf = await sharp(imgBuf)
|
|
537
|
+
.composite([{ input: overlayBuf, blend: 'over' }])
|
|
538
|
+
.png()
|
|
539
|
+
.toBuffer();
|
|
540
|
+
}
|
|
541
|
+
const left = Math.round((W - targetW) / 2);
|
|
542
|
+
const top = Math.round((H - targetH) / 2);
|
|
543
|
+
return sharp({
|
|
544
|
+
create: { width: W, height: H, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
|
|
545
|
+
})
|
|
546
|
+
.composite([{ input: imgBuf, left, top }])
|
|
547
|
+
.png()
|
|
548
|
+
.toBuffer();
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Two-image flip sprite sheet with 3D perspective simulation:
|
|
552
|
+
* - Height compresses slightly at midpoint (viewing angle effect)
|
|
553
|
+
* - Shadow gradient on the turning edge
|
|
554
|
+
* - Front half: scaleX 1→0 (shadow on left/trailing)
|
|
555
|
+
* - Back half: scaleX 0→1 (shadow on right/trailing)
|
|
556
|
+
*/
|
|
557
|
+
async function buildFlipSpriteSheet(frontPath, backPath, frameCount, columns, outputPath) {
|
|
558
|
+
const meta = await sharp(frontPath).metadata();
|
|
559
|
+
const W = meta.width ?? 128;
|
|
560
|
+
const H = meta.height ?? 128;
|
|
561
|
+
const frontBuf = await sharp(frontPath).ensureAlpha().toBuffer();
|
|
562
|
+
const backBuf = await sharp(backPath)
|
|
563
|
+
.resize(W, H, { fit: 'fill' })
|
|
564
|
+
.ensureAlpha()
|
|
565
|
+
.png()
|
|
566
|
+
.toBuffer();
|
|
567
|
+
const half = Math.floor(frameCount / 2);
|
|
568
|
+
const frameBuffers = [];
|
|
569
|
+
// Front side: angle goes from 0° → ~90°, scaleX = cos(angle)
|
|
570
|
+
// t=0 at frame 0 (face-on), t=1 at frame half-1 (edge-on)
|
|
571
|
+
for (let i = 0; i < half; i++) {
|
|
572
|
+
const t = i / half; // 0 → ~1 (not including 1)
|
|
573
|
+
const angle = t * (Math.PI / 2);
|
|
574
|
+
const scaleX = Math.max(0.01, Math.cos(angle)); // cosine: 1 → 0
|
|
575
|
+
frameBuffers.push(await renderFlipFrame(frontBuf, W, H, scaleX, t, 'left'));
|
|
576
|
+
}
|
|
577
|
+
// Back side: angle goes from ~90° → 0°, scaleX = cos(angle) growing back
|
|
578
|
+
for (let i = 0; i < frameCount - half; i++) {
|
|
579
|
+
const t = 1 - (i + 1) / (frameCount - half); // ~1 → 0 (edge-on → face-on)
|
|
580
|
+
const angle = t * (Math.PI / 2);
|
|
581
|
+
const scaleX = Math.max(0.01, Math.cos(angle));
|
|
582
|
+
frameBuffers.push(await renderFlipFrame(backBuf, W, H, scaleX, t, 'right'));
|
|
583
|
+
}
|
|
584
|
+
// Assemble sprite sheet
|
|
585
|
+
const rows = Math.ceil(frameCount / columns);
|
|
586
|
+
const composites = frameBuffers.map((buf, i) => ({
|
|
587
|
+
input: buf,
|
|
588
|
+
left: (i % columns) * W,
|
|
589
|
+
top: Math.floor(i / columns) * H,
|
|
590
|
+
}));
|
|
591
|
+
ensureDir(outputPath);
|
|
592
|
+
await sharp({
|
|
593
|
+
create: { width: W * columns, height: H * rows, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
|
|
594
|
+
})
|
|
595
|
+
.composite(composites)
|
|
596
|
+
.png()
|
|
597
|
+
.toFile(outputPath);
|
|
598
|
+
}
|
|
599
|
+
async function buildAnimationSpriteSheet(inputPath, preset, frameCount, columns, outputPath) {
|
|
600
|
+
const meta = await sharp(inputPath).metadata();
|
|
601
|
+
const W = meta.width ?? 128;
|
|
602
|
+
const H = meta.height ?? 128;
|
|
603
|
+
const transforms = computeFrameTransforms(preset, frameCount);
|
|
604
|
+
const frameBuffers = [];
|
|
605
|
+
for (const tf of transforms) {
|
|
606
|
+
const buf = await renderAnimFrame(inputPath, W, H, tf);
|
|
607
|
+
frameBuffers.push(buf);
|
|
608
|
+
}
|
|
609
|
+
// Assemble sprite sheet
|
|
610
|
+
const rows = Math.ceil(frameCount / columns);
|
|
611
|
+
const sheetW = W * columns;
|
|
612
|
+
const sheetH = H * rows;
|
|
613
|
+
const composites = frameBuffers.map((buf, i) => ({
|
|
614
|
+
input: buf,
|
|
615
|
+
left: (i % columns) * W,
|
|
616
|
+
top: Math.floor(i / columns) * H,
|
|
617
|
+
}));
|
|
618
|
+
ensureDir(outputPath);
|
|
619
|
+
await sharp({
|
|
620
|
+
create: { width: sheetW, height: sheetH, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
|
|
621
|
+
})
|
|
622
|
+
.composite(composites)
|
|
623
|
+
.png()
|
|
624
|
+
.toFile(outputPath);
|
|
625
|
+
}
|
|
626
|
+
// ─── VFX presets ────────────────────────────────────────────────────────────
|
|
627
|
+
const __vfxDir = join(dirname(fileURLToPath(import.meta.url)), '../../assets/vfx');
|
|
628
|
+
const VFX_PRESETS = [
|
|
629
|
+
{ name: 'explosion_small', file: 'explosion_small.png', frames: 8, columns: 4, size: '~30KB', description: 'Small explosion burst — match clear, block break' },
|
|
630
|
+
{ name: 'sparkle', file: 'sparkle.png', frames: 6, columns: 3, size: '~15KB', description: 'Sparkle burst — score, reward, shine' },
|
|
631
|
+
{ name: 'confetti', file: 'confetti.png', frames: 12, columns: 4, size: '~25KB', description: 'Celebration confetti rain — level complete' },
|
|
632
|
+
{ name: 'starburst', file: 'starburst.png', frames: 8, columns: 4, size: '~20KB', description: 'Star burst radial — bonus, combo' },
|
|
633
|
+
{ name: 'smoke', file: 'smoke.png', frames: 8, columns: 4, size: '~20KB', description: 'Smoke puff — disappear, dash, impact' },
|
|
634
|
+
{ name: 'glow', file: 'glow.png', frames: 6, columns: 3, size: '~10KB', description: 'Glow pulse ring — highlight, select, power-up' },
|
|
635
|
+
{ name: 'impact_ring', file: 'impact_ring.png', frames: 6, columns: 3, size: '~15KB', description: 'Impact ring expand — hit, land, drop' },
|
|
636
|
+
{ name: 'coin_collect', file: 'coin_collect.png', frames: 8, columns: 4, size: '~20KB', description: 'Coin collect flash — currency pickup, reward' },
|
|
637
|
+
];
|
|
638
|
+
/**
|
|
639
|
+
* 在 batch 管线中执行单个步骤(复用 sharp 逻辑,无需启动子进程)。
|
|
640
|
+
*/
|
|
641
|
+
async function runBatchStep(cmd, args, inputPath, outputPath) {
|
|
642
|
+
const getArg = (flag) => {
|
|
643
|
+
const idx = args.indexOf(flag);
|
|
644
|
+
return idx !== -1 ? args[idx + 1] : undefined;
|
|
645
|
+
};
|
|
646
|
+
const hasFlag = (flag) => args.includes(flag);
|
|
647
|
+
ensureDir(outputPath);
|
|
648
|
+
switch (cmd) {
|
|
649
|
+
case 'remove-background': {
|
|
650
|
+
const method = getArg('--method') ?? 'floodfill';
|
|
651
|
+
if (method === 'floodfill') {
|
|
652
|
+
const tolerance = Number(getArg('--tolerance') ?? 30);
|
|
653
|
+
const edgeLayers = Number(getArg('--edge-smooth') ?? 2);
|
|
654
|
+
const { buffer } = await floodFillRemoveBg(inputPath, { tolerance, edgeLayers: Math.min(edgeLayers, 4) });
|
|
655
|
+
writeFileSync(outputPath, buffer);
|
|
656
|
+
}
|
|
657
|
+
else {
|
|
658
|
+
throw new Error('batch does not support --method ai (requires backend). Use floodfill.');
|
|
659
|
+
}
|
|
660
|
+
break;
|
|
661
|
+
}
|
|
662
|
+
case 'resize': {
|
|
663
|
+
const w = getArg('--width') ? Number(getArg('--width')) : undefined;
|
|
664
|
+
const h = getArg('--height') ? Number(getArg('--height')) : undefined;
|
|
665
|
+
const scale = getArg('--scale') ? Number(getArg('--scale')) : undefined;
|
|
666
|
+
const fit = (getArg('--fit') ?? 'cover');
|
|
667
|
+
const meta = await sharp(inputPath).metadata();
|
|
668
|
+
const targetW = scale ? Math.round((meta.width ?? 64) * scale) : w;
|
|
669
|
+
const targetH = scale ? Math.round((meta.height ?? 64) * scale) : h;
|
|
670
|
+
await safeToFile(sharp(inputPath).resize(targetW, targetH, { fit }), outputPath, inputPath);
|
|
671
|
+
break;
|
|
672
|
+
}
|
|
673
|
+
case 'convert': {
|
|
674
|
+
const fmt = (getArg('--format') ?? outputPath.split('.').pop() ?? 'png').toLowerCase();
|
|
675
|
+
const quality = Number(getArg('--quality') ?? 85);
|
|
676
|
+
const lossless = hasFlag('--lossless');
|
|
677
|
+
let pipeline = sharp(inputPath);
|
|
678
|
+
if (fmt === 'jpg' || fmt === 'jpeg')
|
|
679
|
+
pipeline = pipeline.jpeg({ quality });
|
|
680
|
+
else if (fmt === 'webp')
|
|
681
|
+
pipeline = pipeline.webp({ quality, lossless });
|
|
682
|
+
else if (fmt === 'avif')
|
|
683
|
+
pipeline = pipeline.avif({ quality, lossless });
|
|
684
|
+
else
|
|
685
|
+
pipeline = pipeline.png();
|
|
686
|
+
await safeToFile(pipeline, outputPath, inputPath);
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
case 'grayscale':
|
|
690
|
+
await safeToFile(sharp(inputPath).grayscale(), outputPath, inputPath);
|
|
691
|
+
break;
|
|
692
|
+
case 'blur': {
|
|
693
|
+
const sigma = Number(getArg('--sigma') ?? 3);
|
|
694
|
+
await safeToFile(sharp(inputPath).blur(sigma), outputPath, inputPath);
|
|
695
|
+
break;
|
|
696
|
+
}
|
|
697
|
+
case 'tint': {
|
|
698
|
+
const color = getArg('--color') ?? '#ff0000';
|
|
699
|
+
await safeToFile(sharp(inputPath).tint(color), outputPath, inputPath);
|
|
700
|
+
break;
|
|
701
|
+
}
|
|
702
|
+
case 'trim': {
|
|
703
|
+
const threshold = Number(getArg('--threshold') ?? 10);
|
|
704
|
+
await safeToFile(sharp(inputPath).trim({ threshold }), outputPath, inputPath);
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
case 'negate':
|
|
708
|
+
await safeToFile(sharp(inputPath).negate({ alpha: !hasFlag('--no-alpha') }), outputPath, inputPath);
|
|
709
|
+
break;
|
|
710
|
+
case 'rotate': {
|
|
711
|
+
const angle = Number(getArg('--angle') ?? 0);
|
|
712
|
+
const bg = getArg('--background') ?? '#00000000';
|
|
713
|
+
await safeToFile(sharp(inputPath).rotate(angle, { background: bg }), outputPath, inputPath);
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
case 'flip': {
|
|
717
|
+
const dir = getArg('--direction') ?? 'horizontal';
|
|
718
|
+
let pipeline = sharp(inputPath);
|
|
719
|
+
if (dir === 'horizontal')
|
|
720
|
+
pipeline = pipeline.flop();
|
|
721
|
+
else
|
|
722
|
+
pipeline = pipeline.flip();
|
|
723
|
+
await safeToFile(pipeline, outputPath, inputPath);
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
case 'pad': {
|
|
727
|
+
const all = getArg('--all') ? Number(getArg('--all')) : undefined;
|
|
728
|
+
const top = all ?? Number(getArg('--top') ?? 0);
|
|
729
|
+
const bottom = all ?? Number(getArg('--bottom') ?? 0);
|
|
730
|
+
const left = all ?? Number(getArg('--left') ?? 0);
|
|
731
|
+
const right = all ?? Number(getArg('--right') ?? 0);
|
|
732
|
+
const bg = getArg('--background') ?? '#00000000';
|
|
733
|
+
await safeToFile(sharp(inputPath).extend({ top, bottom, left, right, background: bg }), outputPath, inputPath);
|
|
734
|
+
break;
|
|
735
|
+
}
|
|
736
|
+
case 'pixelate': {
|
|
737
|
+
const meta = await sharp(inputPath).metadata();
|
|
738
|
+
const ps = Number(getArg('--pixel-size') ?? 8);
|
|
739
|
+
const w = meta.width ?? 64;
|
|
740
|
+
const h = meta.height ?? 64;
|
|
741
|
+
await safeToFile(sharp(inputPath).resize(Math.max(1, Math.floor(w / ps)), Math.max(1, Math.floor(h / ps)), { kernel: 'nearest' }).resize(w, h, { kernel: 'nearest' }), outputPath, inputPath);
|
|
742
|
+
break;
|
|
743
|
+
}
|
|
744
|
+
default:
|
|
745
|
+
throw new Error(`runBatchStep: unsupported command "${cmd}"`);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
296
748
|
export function registerImageCommands(program) {
|
|
297
749
|
const img = program
|
|
298
750
|
.command('image')
|
|
299
751
|
.description('素材工具:本地图片处理(sharp);remove-background / sprite-split --auto-detect 可调用后端 API');
|
|
752
|
+
// ─── info ────────────────────────────────────────────────────
|
|
753
|
+
img.command('info')
|
|
754
|
+
.description('输出图片元信息(尺寸、格式、通道、文件大小、DPI)')
|
|
755
|
+
.requiredOption('--input <path>', '输入图片路径')
|
|
756
|
+
.option('--json', '以 JSON 格式输出(默认人类可读格式)')
|
|
757
|
+
.action(async (opts) => {
|
|
758
|
+
try {
|
|
759
|
+
assertInputWithinLimit(opts.input);
|
|
760
|
+
const meta = await sharp(opts.input).metadata();
|
|
761
|
+
const fileStat = statSync(opts.input);
|
|
762
|
+
const channels = meta.channels ?? 0;
|
|
763
|
+
const channelLabel = channels === 4 ? 'RGBA' : channels === 3 ? 'RGB' : channels === 1 ? 'Gray' : String(channels);
|
|
764
|
+
if (opts.json) {
|
|
765
|
+
console.log(JSON.stringify({
|
|
766
|
+
file: opts.input,
|
|
767
|
+
format: meta.format,
|
|
768
|
+
width: meta.width,
|
|
769
|
+
height: meta.height,
|
|
770
|
+
channels,
|
|
771
|
+
space: meta.space,
|
|
772
|
+
density: meta.density,
|
|
773
|
+
hasAlpha: meta.hasAlpha,
|
|
774
|
+
fileSize: fileStat.size,
|
|
775
|
+
fileSizeLabel: sizeLabelBytes(fileStat.size),
|
|
776
|
+
}, null, 2));
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
const dpiStr = meta.density ? ` | DPI: ${meta.density}` : '';
|
|
780
|
+
console.log(`File: ${opts.input}`);
|
|
781
|
+
console.log(`Format: ${meta.format ?? 'unknown'} | Size: ${meta.width ?? '?'}x${meta.height ?? '?'} | Channels: ${channels} (${channelLabel}) | File: ${sizeLabelBytes(fileStat.size)}${dpiStr}`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
catch (e) {
|
|
785
|
+
handleError(e);
|
|
786
|
+
}
|
|
787
|
+
});
|
|
300
788
|
// ─── resize ──────────────────────────────────────────────────
|
|
301
789
|
img.command('resize')
|
|
302
790
|
.description('缩放图片(按比例或指定宽高)')
|
|
@@ -327,10 +815,7 @@ export function registerImageCommands(program) {
|
|
|
327
815
|
process.exit(1);
|
|
328
816
|
}
|
|
329
817
|
const fit = parseResizeFitOption(opts.fit);
|
|
330
|
-
|
|
331
|
-
await sharp(opts.input)
|
|
332
|
-
.resize(targetW, targetH, { fit })
|
|
333
|
-
.toFile(opts.output);
|
|
818
|
+
await safeToFile(sharp(opts.input).resize(targetW, targetH, { fit }), opts.output, opts.input);
|
|
334
819
|
const outSz = statSync(opts.output).size;
|
|
335
820
|
console.log(`Resized ${w}x${h} → ${targetW ?? '?'}x${targetH ?? '?'} — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
336
821
|
}
|
|
@@ -350,10 +835,7 @@ export function registerImageCommands(program) {
|
|
|
350
835
|
.action(async (opts) => {
|
|
351
836
|
try {
|
|
352
837
|
assertInputWithinLimit(opts.input);
|
|
353
|
-
|
|
354
|
-
await sharp(opts.input)
|
|
355
|
-
.extract({ left: opts.x, top: opts.y, width: opts.width, height: opts.height })
|
|
356
|
-
.toFile(opts.output);
|
|
838
|
+
await safeToFile(sharp(opts.input).extract({ left: opts.x, top: opts.y, width: opts.width, height: opts.height }), opts.output, opts.input);
|
|
357
839
|
const outSz = statSync(opts.output).size;
|
|
358
840
|
console.log(`Cropped to ${opts.width}x${opts.height} at (${opts.x},${opts.y}) — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
359
841
|
}
|
|
@@ -371,10 +853,7 @@ export function registerImageCommands(program) {
|
|
|
371
853
|
.action(async (opts) => {
|
|
372
854
|
try {
|
|
373
855
|
assertInputWithinLimit(opts.input);
|
|
374
|
-
|
|
375
|
-
await sharp(opts.input)
|
|
376
|
-
.rotate(opts.angle, { background: opts.background })
|
|
377
|
-
.toFile(opts.output);
|
|
856
|
+
await safeToFile(sharp(opts.input).rotate(opts.angle, { background: opts.background }), opts.output, opts.input);
|
|
378
857
|
const outSz = statSync(opts.output).size;
|
|
379
858
|
console.log(`Rotated ${opts.angle}° — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
380
859
|
}
|
|
@@ -402,8 +881,7 @@ export function registerImageCommands(program) {
|
|
|
402
881
|
console.error('Error: --direction must be horizontal or vertical');
|
|
403
882
|
process.exit(1);
|
|
404
883
|
}
|
|
405
|
-
|
|
406
|
-
await pipeline.toFile(opts.output);
|
|
884
|
+
await safeToFile(pipeline, opts.output, opts.input);
|
|
407
885
|
const outSz = statSync(opts.output).size;
|
|
408
886
|
console.log(`Flipped ${opts.direction} — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
409
887
|
}
|
|
@@ -428,10 +906,7 @@ export function registerImageCommands(program) {
|
|
|
428
906
|
const pad = opts.all !== undefined
|
|
429
907
|
? { top: opts.all, bottom: opts.all, left: opts.all, right: opts.all }
|
|
430
908
|
: { top: opts.top, bottom: opts.bottom, left: opts.left, right: opts.right };
|
|
431
|
-
|
|
432
|
-
await sharp(opts.input)
|
|
433
|
-
.extend({ ...pad, background: opts.background })
|
|
434
|
-
.toFile(opts.output);
|
|
909
|
+
await safeToFile(sharp(opts.input).extend({ ...pad, background: opts.background }), opts.output, opts.input);
|
|
435
910
|
const meta = await sharp(opts.output).metadata();
|
|
436
911
|
const outSz = statSync(opts.output).size;
|
|
437
912
|
console.log(`Padded to ${meta.width}x${meta.height} — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
@@ -470,8 +945,7 @@ export function registerImageCommands(program) {
|
|
|
470
945
|
pipeline = pipeline.png();
|
|
471
946
|
break;
|
|
472
947
|
}
|
|
473
|
-
|
|
474
|
-
await pipeline.toFile(opts.output);
|
|
948
|
+
await safeToFile(pipeline, opts.output, opts.input);
|
|
475
949
|
const outSz = statSync(opts.output).size;
|
|
476
950
|
const ratio = origSize > 0 ? ((1 - outSz / origSize) * 100).toFixed(1) : '0.0';
|
|
477
951
|
console.log(`Converted to ${fmt} — ${opts.output} (${sizeLabelBytes(outSz)}, ${ratio}% reduction)`);
|
|
@@ -488,8 +962,7 @@ export function registerImageCommands(program) {
|
|
|
488
962
|
.action(async (opts) => {
|
|
489
963
|
try {
|
|
490
964
|
assertInputWithinLimit(opts.input);
|
|
491
|
-
|
|
492
|
-
await sharp(opts.input).grayscale().toFile(opts.output);
|
|
965
|
+
await safeToFile(sharp(opts.input).grayscale(), opts.output, opts.input);
|
|
493
966
|
const outSz = statSync(opts.output).size;
|
|
494
967
|
console.log(`Grayscale — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
495
968
|
}
|
|
@@ -506,8 +979,7 @@ export function registerImageCommands(program) {
|
|
|
506
979
|
.action(async (opts) => {
|
|
507
980
|
try {
|
|
508
981
|
assertInputWithinLimit(opts.input);
|
|
509
|
-
|
|
510
|
-
await sharp(opts.input).blur(opts.sigma).toFile(opts.output);
|
|
982
|
+
await safeToFile(sharp(opts.input).blur(opts.sigma), opts.output, opts.input);
|
|
511
983
|
const outSz = statSync(opts.output).size;
|
|
512
984
|
console.log(`Blurred (sigma=${opts.sigma}) — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
513
985
|
}
|
|
@@ -524,8 +996,7 @@ export function registerImageCommands(program) {
|
|
|
524
996
|
.action(async (opts) => {
|
|
525
997
|
try {
|
|
526
998
|
assertInputWithinLimit(opts.input);
|
|
527
|
-
|
|
528
|
-
await sharp(opts.input).tint(opts.color).toFile(opts.output);
|
|
999
|
+
await safeToFile(sharp(opts.input).tint(opts.color), opts.output, opts.input);
|
|
529
1000
|
const outSz = statSync(opts.output).size;
|
|
530
1001
|
console.log(`Tinted with ${opts.color} — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
531
1002
|
}
|
|
@@ -543,8 +1014,7 @@ export function registerImageCommands(program) {
|
|
|
543
1014
|
try {
|
|
544
1015
|
assertInputWithinLimit(opts.input);
|
|
545
1016
|
const before = await sharp(opts.input).metadata();
|
|
546
|
-
|
|
547
|
-
await sharp(opts.input).trim({ threshold: opts.threshold }).toFile(opts.output);
|
|
1017
|
+
await safeToFile(sharp(opts.input).trim({ threshold: opts.threshold }), opts.output, opts.input);
|
|
548
1018
|
const after = await sharp(opts.output).metadata();
|
|
549
1019
|
const outSz = statSync(opts.output).size;
|
|
550
1020
|
console.log(`Trimmed ${before.width}x${before.height} → ${after.width}x${after.height} — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
@@ -562,8 +1032,7 @@ export function registerImageCommands(program) {
|
|
|
562
1032
|
.action(async (opts) => {
|
|
563
1033
|
try {
|
|
564
1034
|
assertInputWithinLimit(opts.input);
|
|
565
|
-
|
|
566
|
-
await sharp(opts.input).negate({ alpha: opts.alpha !== false }).toFile(opts.output);
|
|
1035
|
+
await safeToFile(sharp(opts.input).negate({ alpha: opts.alpha !== false }), opts.output, opts.input);
|
|
567
1036
|
const outSz = statSync(opts.output).size;
|
|
568
1037
|
console.log(`Negated — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
569
1038
|
}
|
|
@@ -592,8 +1061,7 @@ export function registerImageCommands(program) {
|
|
|
592
1061
|
else {
|
|
593
1062
|
overlayOpts.gravity = opts.gravity;
|
|
594
1063
|
}
|
|
595
|
-
|
|
596
|
-
await sharp(opts.base).composite([overlayOpts]).toFile(opts.output);
|
|
1064
|
+
await safeToFile(sharp(opts.base).composite([overlayOpts]), opts.output, opts.base);
|
|
597
1065
|
const meta = await sharp(opts.output).metadata();
|
|
598
1066
|
const outSz = statSync(opts.output).size;
|
|
599
1067
|
console.log(`Composited — ${meta.width}x${meta.height} — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
@@ -616,11 +1084,7 @@ export function registerImageCommands(program) {
|
|
|
616
1084
|
const h = meta.height ?? 64;
|
|
617
1085
|
const smallW = Math.max(1, Math.floor(w / opts.pixelSize));
|
|
618
1086
|
const smallH = Math.max(1, Math.floor(h / opts.pixelSize));
|
|
619
|
-
|
|
620
|
-
await sharp(opts.input)
|
|
621
|
-
.resize(smallW, smallH, { kernel: 'nearest' })
|
|
622
|
-
.resize(w, h, { kernel: 'nearest' })
|
|
623
|
-
.toFile(opts.output);
|
|
1087
|
+
await safeToFile(sharp(opts.input).resize(smallW, smallH, { kernel: 'nearest' }).resize(w, h, { kernel: 'nearest' }), opts.output, opts.input);
|
|
624
1088
|
const outSz = statSync(opts.output).size;
|
|
625
1089
|
console.log(`Pixelated (block=${opts.pixelSize}px) — ${opts.output} (${sizeLabelBytes(outSz)})`);
|
|
626
1090
|
}
|
|
@@ -696,17 +1160,31 @@ export function registerImageCommands(program) {
|
|
|
696
1160
|
// ─── sprite-sheet ────────────────────────────────────────────
|
|
697
1161
|
img.command('sprite-sheet')
|
|
698
1162
|
.description('将多张图合并为精灵图,并输出帧坐标 JSON(完整本地实现)')
|
|
699
|
-
.
|
|
1163
|
+
.option('--inputs <paths>', '输入图片路径,逗号分隔,支持 glob 模式(如 "tiles/*.png")')
|
|
1164
|
+
.option('--input-dir <dir>', '从目录读取所有图片(与 --inputs 二选一)')
|
|
1165
|
+
.option('--glob <pattern>', '配合 --input-dir 使用的文件匹配模式(默认 "*.png")', '*.png')
|
|
1166
|
+
.option('--sort <mode>', '排序方式:name(默认,自然排序)| modified | none', 'name')
|
|
700
1167
|
.requiredOption('--output <basePath>', '输出基路径(自动生成 .png 和 .json)')
|
|
701
1168
|
.option('--columns <n>', '列数(默认自动按平方根计算)', cliParseInt)
|
|
702
1169
|
.option('--padding <n>', '帧间距(像素,默认 0)', cliParseInt, 0)
|
|
703
1170
|
.option('--cell-width <n>', '统一格子宽度(不填则用第一张图宽度)', cliParseInt)
|
|
704
1171
|
.option('--cell-height <n>', '统一格子高度(不填则用第一张图高度)', cliParseInt)
|
|
1172
|
+
.option('--background <color>', '精灵图背景色:transparent(默认)| white | black | #RRGGBB。某些引擎透明区域会显示为黑色,此时用 white 更安全', 'transparent')
|
|
705
1173
|
.action(async (opts) => {
|
|
706
1174
|
try {
|
|
707
|
-
|
|
1175
|
+
let paths;
|
|
1176
|
+
if (opts.inputDir) {
|
|
1177
|
+
paths = collectFromDir(opts.inputDir, opts.glob ?? '*.png', opts.sort ?? 'name');
|
|
1178
|
+
}
|
|
1179
|
+
else if (opts.inputs) {
|
|
1180
|
+
paths = expandInputPaths(opts.inputs);
|
|
1181
|
+
}
|
|
1182
|
+
else {
|
|
1183
|
+
console.error('Error: provide --inputs or --input-dir');
|
|
1184
|
+
process.exit(1);
|
|
1185
|
+
}
|
|
708
1186
|
if (!paths.length) {
|
|
709
|
-
console.error('Error:
|
|
1187
|
+
console.error('Error: no input files found');
|
|
710
1188
|
process.exit(1);
|
|
711
1189
|
}
|
|
712
1190
|
for (let i = 0; i < paths.length; i++) {
|
|
@@ -732,8 +1210,29 @@ export function registerImageCommands(program) {
|
|
|
732
1210
|
composites.push({ input: resized, left: x, top: y });
|
|
733
1211
|
frames[frameName] = { x, y, w: cellW, h: cellH };
|
|
734
1212
|
}
|
|
1213
|
+
// 解析背景色:transparent → alpha=0 黑色画布;其他颜色 → 用 sharp 解析
|
|
1214
|
+
const bgColor = opts.background ?? 'transparent';
|
|
1215
|
+
let canvasBackground;
|
|
1216
|
+
if (bgColor === 'transparent') {
|
|
1217
|
+
canvasBackground = { r: 0, g: 0, b: 0, alpha: 0 };
|
|
1218
|
+
}
|
|
1219
|
+
else if (bgColor === 'white') {
|
|
1220
|
+
canvasBackground = { r: 255, g: 255, b: 255, alpha: 1 };
|
|
1221
|
+
}
|
|
1222
|
+
else if (bgColor === 'black') {
|
|
1223
|
+
canvasBackground = { r: 0, g: 0, b: 0, alpha: 1 };
|
|
1224
|
+
}
|
|
1225
|
+
else {
|
|
1226
|
+
// 支持 #RRGGBB / #RRGGBBAA 十六进制
|
|
1227
|
+
const hex = bgColor.replace('#', '');
|
|
1228
|
+
const r = parseInt(hex.slice(0, 2), 16) || 0;
|
|
1229
|
+
const g = parseInt(hex.slice(2, 4), 16) || 0;
|
|
1230
|
+
const b = parseInt(hex.slice(4, 6), 16) || 0;
|
|
1231
|
+
const a = hex.length >= 8 ? (parseInt(hex.slice(6, 8), 16) || 0) / 255 : 1;
|
|
1232
|
+
canvasBackground = { r, g, b, alpha: a };
|
|
1233
|
+
}
|
|
735
1234
|
const canvas = await sharp({
|
|
736
|
-
create: { width: totalW, height: totalH, channels: 4, background:
|
|
1235
|
+
create: { width: totalW, height: totalH, channels: 4, background: canvasBackground },
|
|
737
1236
|
}).png().toBuffer();
|
|
738
1237
|
const sheetBuf = await sharp(canvas).composite(composites).png().toBuffer();
|
|
739
1238
|
const basePath = opts.output.replace(/\.(png|json)$/i, '');
|
|
@@ -748,6 +1247,337 @@ export function registerImageCommands(program) {
|
|
|
748
1247
|
handleError(e);
|
|
749
1248
|
}
|
|
750
1249
|
});
|
|
1250
|
+
// ─── batch ───────────────────────────────────────────────────
|
|
1251
|
+
img.command('batch')
|
|
1252
|
+
.description([
|
|
1253
|
+
'对一批图片执行相同的处理步骤(声明式管线)',
|
|
1254
|
+
'',
|
|
1255
|
+
' 示例:去背 + 统一尺寸',
|
|
1256
|
+
' playcraft image batch \\',
|
|
1257
|
+
' --input-dir assets/images/raw/ \\',
|
|
1258
|
+
' --output-dir assets/images/processed/ \\',
|
|
1259
|
+
' --steps "remove-background,resize --width 128 --height 128"',
|
|
1260
|
+
'',
|
|
1261
|
+
' 示例:批量转格式',
|
|
1262
|
+
' playcraft image batch \\',
|
|
1263
|
+
' --inputs "raw/*.jpg" \\',
|
|
1264
|
+
' --output-dir out/ \\',
|
|
1265
|
+
' --steps "convert --format png"',
|
|
1266
|
+
].join('\n'))
|
|
1267
|
+
.option('--input-dir <dir>', '输入目录(与 --inputs 二选一)')
|
|
1268
|
+
.option('--glob <pattern>', '配合 --input-dir 的文件匹配(默认 "*.png,*.jpg,*.jpeg,*.webp")')
|
|
1269
|
+
.option('--inputs <paths>', '逗号分隔的输入文件列表,支持 glob')
|
|
1270
|
+
.requiredOption('--output-dir <dir>', '输出目录(保持文件名,统一输出为 PNG)')
|
|
1271
|
+
.requiredOption('--steps <pipeline>', '处理步骤,逗号分隔,每步是 image 子命令 + 参数(如 "remove-background,resize --width 128 --height 128")')
|
|
1272
|
+
.option('--parallel <n>', '并发处理数(默认 4)', cliParseInt, 4)
|
|
1273
|
+
.option('--skip-existing', '跳过输出目录中已存在的文件')
|
|
1274
|
+
.action(async (opts) => {
|
|
1275
|
+
try {
|
|
1276
|
+
// 收集输入文件
|
|
1277
|
+
let inputs;
|
|
1278
|
+
if (opts.inputDir) {
|
|
1279
|
+
const globPatterns = (opts.glob ?? '*.png,*.jpg,*.jpeg,*.webp').split(',').map((p) => p.trim());
|
|
1280
|
+
const allFiles = [];
|
|
1281
|
+
for (const pat of globPatterns) {
|
|
1282
|
+
const matched = collectFromDir(opts.inputDir, pat, 'name');
|
|
1283
|
+
allFiles.push(...matched);
|
|
1284
|
+
}
|
|
1285
|
+
inputs = [...new Set(allFiles)];
|
|
1286
|
+
inputs.sort((a, b) => basename(a).localeCompare(basename(b), undefined, { numeric: true, sensitivity: 'base' }));
|
|
1287
|
+
}
|
|
1288
|
+
else if (opts.inputs) {
|
|
1289
|
+
inputs = expandInputPaths(opts.inputs);
|
|
1290
|
+
}
|
|
1291
|
+
else {
|
|
1292
|
+
console.error('Error: provide --input-dir or --inputs');
|
|
1293
|
+
process.exit(1);
|
|
1294
|
+
}
|
|
1295
|
+
if (!inputs.length) {
|
|
1296
|
+
console.error('Error: no input files found');
|
|
1297
|
+
process.exit(1);
|
|
1298
|
+
}
|
|
1299
|
+
// 解析 steps 管线:逗号分隔,但要忽略参数值中的逗号(通过分割子命令名称来识别)
|
|
1300
|
+
const knownSubcommands = ['remove-background', 'resize', 'crop', 'rotate', 'flip', 'pad', 'convert', 'grayscale', 'blur', 'tint', 'trim', 'negate', 'pixelate'];
|
|
1301
|
+
const stepStr = opts.steps;
|
|
1302
|
+
const steps = [];
|
|
1303
|
+
// 按已知子命令名前缀分割
|
|
1304
|
+
let remaining = stepStr;
|
|
1305
|
+
while (remaining.trim()) {
|
|
1306
|
+
let found = false;
|
|
1307
|
+
for (const sc of knownSubcommands) {
|
|
1308
|
+
if (remaining.trim().startsWith(sc)) {
|
|
1309
|
+
const afterCmd = remaining.trim().slice(sc.length).trim();
|
|
1310
|
+
// 找下个子命令的位置(遇到已知子命令才截断)
|
|
1311
|
+
let nextCmdIdx = afterCmd.length;
|
|
1312
|
+
for (const nextSc of knownSubcommands) {
|
|
1313
|
+
// look for ",nextSc" or " nextSc" pattern
|
|
1314
|
+
const comma = afterCmd.indexOf(',' + nextSc);
|
|
1315
|
+
if (comma !== -1 && comma < nextCmdIdx)
|
|
1316
|
+
nextCmdIdx = comma;
|
|
1317
|
+
}
|
|
1318
|
+
const argsStr = afterCmd.slice(0, nextCmdIdx).trim().replace(/^,/, '').trim();
|
|
1319
|
+
// Parse args string into array (basic shell-like split)
|
|
1320
|
+
const args = [];
|
|
1321
|
+
const argRegex = /(?:[^\s"']+|"[^"]*"|'[^']*')+/g;
|
|
1322
|
+
let m;
|
|
1323
|
+
while ((m = argRegex.exec(argsStr)) !== null) {
|
|
1324
|
+
args.push(m[0].replace(/^["']|["']$/g, ''));
|
|
1325
|
+
}
|
|
1326
|
+
steps.push({ cmd: sc, args });
|
|
1327
|
+
remaining = afterCmd.slice(nextCmdIdx).trim().replace(/^,/, '').trim();
|
|
1328
|
+
found = true;
|
|
1329
|
+
break;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
if (!found) {
|
|
1333
|
+
console.error(`Error: unknown step in --steps: "${remaining.trim()}"`);
|
|
1334
|
+
console.error(`Known subcommands: ${knownSubcommands.join(', ')}`);
|
|
1335
|
+
process.exit(1);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
if (!steps.length) {
|
|
1339
|
+
console.error('Error: --steps is empty or invalid');
|
|
1340
|
+
process.exit(1);
|
|
1341
|
+
}
|
|
1342
|
+
mkdirSync(opts.outputDir, { recursive: true });
|
|
1343
|
+
let done = 0, skipped = 0, failed = 0;
|
|
1344
|
+
const total = inputs.length;
|
|
1345
|
+
const concurrency = Math.max(1, opts.parallel);
|
|
1346
|
+
const processOne = async (inputPath) => {
|
|
1347
|
+
const outName = basename(inputPath).replace(/\.[^.]+$/, '.png');
|
|
1348
|
+
const outPath = join(opts.outputDir, outName);
|
|
1349
|
+
if (opts.skipExisting && existsSync(outPath)) {
|
|
1350
|
+
skipped++;
|
|
1351
|
+
process.stdout.write(`\r[${done + skipped + failed}/${total}] skipping ${outName} (exists)`);
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
// Apply each step sequentially using a temp file chain
|
|
1355
|
+
let currentPath = inputPath;
|
|
1356
|
+
let tmpFiles = [];
|
|
1357
|
+
try {
|
|
1358
|
+
for (let si = 0; si < steps.length; si++) {
|
|
1359
|
+
const step = steps[si];
|
|
1360
|
+
const isLast = si === steps.length - 1;
|
|
1361
|
+
const stepOut = isLast ? outPath : `${outPath}.step${si}.tmp`;
|
|
1362
|
+
if (!isLast)
|
|
1363
|
+
tmpFiles.push(stepOut);
|
|
1364
|
+
// Build a minimal CLI-like invocation by manipulating sharp directly
|
|
1365
|
+
await runBatchStep(step.cmd, step.args, currentPath, stepOut);
|
|
1366
|
+
currentPath = stepOut;
|
|
1367
|
+
}
|
|
1368
|
+
done++;
|
|
1369
|
+
process.stdout.write(`\r[${done + skipped + failed}/${total}] done: ${outName} `);
|
|
1370
|
+
}
|
|
1371
|
+
catch (e) {
|
|
1372
|
+
failed++;
|
|
1373
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1374
|
+
process.stdout.write(`\n[${done + skipped + failed}/${total}] FAILED: ${outName} — ${msg}\n`);
|
|
1375
|
+
}
|
|
1376
|
+
finally {
|
|
1377
|
+
for (const tmp of tmpFiles) {
|
|
1378
|
+
try {
|
|
1379
|
+
if (existsSync(tmp)) {
|
|
1380
|
+
const { unlinkSync } = await import('fs');
|
|
1381
|
+
unlinkSync(tmp);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
catch { /* ignore */ }
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
};
|
|
1388
|
+
// Process with concurrency limit
|
|
1389
|
+
const queue = [...inputs];
|
|
1390
|
+
const workers = [];
|
|
1391
|
+
const runWorker = async () => {
|
|
1392
|
+
while (queue.length) {
|
|
1393
|
+
const item = queue.shift();
|
|
1394
|
+
if (item)
|
|
1395
|
+
await processOne(item);
|
|
1396
|
+
}
|
|
1397
|
+
};
|
|
1398
|
+
for (let i = 0; i < Math.min(concurrency, inputs.length); i++) {
|
|
1399
|
+
workers.push(runWorker());
|
|
1400
|
+
}
|
|
1401
|
+
await Promise.all(workers);
|
|
1402
|
+
console.log(`\n\nBatch complete: ${done} done, ${skipped} skipped, ${failed} failed (${total} total)`);
|
|
1403
|
+
console.log(`Output: ${opts.outputDir}`);
|
|
1404
|
+
if (failed > 0)
|
|
1405
|
+
process.exit(1);
|
|
1406
|
+
}
|
|
1407
|
+
catch (e) {
|
|
1408
|
+
handleError(e);
|
|
1409
|
+
}
|
|
1410
|
+
});
|
|
1411
|
+
// ─── pipeline ────────────────────────────────────────────────
|
|
1412
|
+
img.command('pipeline')
|
|
1413
|
+
.description([
|
|
1414
|
+
'从 YAML/JSON 配置文件驱动的多步图片处理管线',
|
|
1415
|
+
'',
|
|
1416
|
+
' 示例:',
|
|
1417
|
+
' playcraft image pipeline --config pipeline.yaml',
|
|
1418
|
+
'',
|
|
1419
|
+
' 配置文件格式(YAML):',
|
|
1420
|
+
' input:',
|
|
1421
|
+
' dir: assets/images/raw/',
|
|
1422
|
+
' glob: "*.png"',
|
|
1423
|
+
' steps:',
|
|
1424
|
+
' - command: remove-background',
|
|
1425
|
+
' method: floodfill',
|
|
1426
|
+
' - command: resize',
|
|
1427
|
+
' width: 128',
|
|
1428
|
+
' height: 128',
|
|
1429
|
+
' output:',
|
|
1430
|
+
' dir: assets/images/processed/',
|
|
1431
|
+
' assemble: # 可选:最后合并为精灵图',
|
|
1432
|
+
' sprite-sheet:',
|
|
1433
|
+
' output: assets/bundles/tiles',
|
|
1434
|
+
' columns: 9',
|
|
1435
|
+
' padding: 2',
|
|
1436
|
+
].join('\n'))
|
|
1437
|
+
.requiredOption('--config <path>', '配置文件路径(.yaml / .yml / .json)')
|
|
1438
|
+
.option('--dry-run', '只打印将执行的操作,不实际处理')
|
|
1439
|
+
.action(async (opts) => {
|
|
1440
|
+
try {
|
|
1441
|
+
const configRaw = readFileSync(opts.config, 'utf-8');
|
|
1442
|
+
const ext = extname(opts.config).toLowerCase();
|
|
1443
|
+
let config;
|
|
1444
|
+
if (ext === '.json') {
|
|
1445
|
+
config = JSON.parse(configRaw);
|
|
1446
|
+
}
|
|
1447
|
+
else {
|
|
1448
|
+
config = yaml.load(configRaw);
|
|
1449
|
+
}
|
|
1450
|
+
// 收集输入文件
|
|
1451
|
+
let inputs = [];
|
|
1452
|
+
if (config.input?.dir) {
|
|
1453
|
+
const globPat = config.input.glob ?? '*.png';
|
|
1454
|
+
inputs = collectFromDir(config.input.dir, globPat, 'name');
|
|
1455
|
+
}
|
|
1456
|
+
else if (config.input?.files) {
|
|
1457
|
+
for (const f of config.input.files) {
|
|
1458
|
+
inputs.push(...expandInputPaths(f));
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
if (!inputs.length) {
|
|
1462
|
+
console.error('Error: no input files found from config.input');
|
|
1463
|
+
process.exit(1);
|
|
1464
|
+
}
|
|
1465
|
+
const outputDir = config.output?.dir ?? './pipeline-output';
|
|
1466
|
+
const steps = config.steps ?? [];
|
|
1467
|
+
if (!steps.length) {
|
|
1468
|
+
console.error('Error: config.steps is empty');
|
|
1469
|
+
process.exit(1);
|
|
1470
|
+
}
|
|
1471
|
+
// Convert config steps to batch-compatible format
|
|
1472
|
+
const batchSteps = steps.map((step) => {
|
|
1473
|
+
const { command: cmd, ...rest } = step;
|
|
1474
|
+
const args = [];
|
|
1475
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
1476
|
+
if (v !== undefined && v !== null && v !== false) {
|
|
1477
|
+
args.push(`--${k}`);
|
|
1478
|
+
if (v !== true)
|
|
1479
|
+
args.push(String(v));
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
return { cmd, args };
|
|
1483
|
+
});
|
|
1484
|
+
console.log(`Pipeline config: ${opts.config}`);
|
|
1485
|
+
console.log(`Input: ${inputs.length} file(s) from ${config.input?.dir ?? 'files list'}`);
|
|
1486
|
+
console.log(`Steps: ${batchSteps.map((s) => s.cmd).join(' → ')}`);
|
|
1487
|
+
console.log(`Output: ${outputDir}`);
|
|
1488
|
+
if (opts.dryRun) {
|
|
1489
|
+
console.log('\n[dry-run] Steps that would execute:');
|
|
1490
|
+
for (const s of batchSteps) {
|
|
1491
|
+
console.log(` ${s.cmd} ${s.args.join(' ')}`);
|
|
1492
|
+
}
|
|
1493
|
+
console.log(`[dry-run] ${inputs.length} file(s) would be processed.`);
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
mkdirSync(outputDir, { recursive: true });
|
|
1497
|
+
let done = 0, failed = 0;
|
|
1498
|
+
const total = inputs.length;
|
|
1499
|
+
for (const inputPath of inputs) {
|
|
1500
|
+
const outName = basename(inputPath).replace(/\.[^.]+$/, '.png');
|
|
1501
|
+
const outPath = join(outputDir, outName);
|
|
1502
|
+
let currentPath = inputPath;
|
|
1503
|
+
const tmpFiles = [];
|
|
1504
|
+
try {
|
|
1505
|
+
for (let si = 0; si < batchSteps.length; si++) {
|
|
1506
|
+
const step = batchSteps[si];
|
|
1507
|
+
const isLast = si === batchSteps.length - 1;
|
|
1508
|
+
const stepOut = isLast ? outPath : `${outPath}.step${si}.tmp`;
|
|
1509
|
+
if (!isLast)
|
|
1510
|
+
tmpFiles.push(stepOut);
|
|
1511
|
+
await runBatchStep(step.cmd, step.args, currentPath, stepOut);
|
|
1512
|
+
currentPath = stepOut;
|
|
1513
|
+
}
|
|
1514
|
+
done++;
|
|
1515
|
+
process.stdout.write(`\r[${done + failed}/${total}] done: ${outName} `);
|
|
1516
|
+
}
|
|
1517
|
+
catch (e) {
|
|
1518
|
+
failed++;
|
|
1519
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1520
|
+
process.stdout.write(`\n[${done + failed}/${total}] FAILED: ${outName} — ${msg}\n`);
|
|
1521
|
+
}
|
|
1522
|
+
finally {
|
|
1523
|
+
for (const tmp of tmpFiles) {
|
|
1524
|
+
try {
|
|
1525
|
+
if (existsSync(tmp)) {
|
|
1526
|
+
const { unlinkSync } = await import('fs');
|
|
1527
|
+
unlinkSync(tmp);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
catch { /* ignore */ }
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
console.log(`\n\nPipeline complete: ${done} done, ${failed} failed (${total} total)`);
|
|
1535
|
+
console.log(`Output: ${outputDir}`);
|
|
1536
|
+
// Optional assemble step
|
|
1537
|
+
const assembleConfig = config.assemble?.['sprite-sheet'];
|
|
1538
|
+
if (assembleConfig && done > 0) {
|
|
1539
|
+
console.log('\nRunning assemble: sprite-sheet...');
|
|
1540
|
+
const sheetFiles = collectFromDir(outputDir, '*.png', 'name');
|
|
1541
|
+
if (sheetFiles.length) {
|
|
1542
|
+
const outBase = assembleConfig.output ?? join(outputDir, 'sprite-sheet');
|
|
1543
|
+
const cols = assembleConfig.columns ?? Math.ceil(Math.sqrt(sheetFiles.length));
|
|
1544
|
+
const padding = assembleConfig.padding ?? 0;
|
|
1545
|
+
const firstMeta = await sharp(sheetFiles[0]).metadata();
|
|
1546
|
+
const cellW = firstMeta.width ?? 64;
|
|
1547
|
+
const cellH = firstMeta.height ?? 64;
|
|
1548
|
+
const rows = Math.ceil(sheetFiles.length / cols);
|
|
1549
|
+
const totalW = cols * cellW + (cols - 1) * padding;
|
|
1550
|
+
const totalH = rows * cellH + (rows - 1) * padding;
|
|
1551
|
+
const composites = [];
|
|
1552
|
+
const frames = {};
|
|
1553
|
+
for (let i = 0; i < sheetFiles.length; i++) {
|
|
1554
|
+
const col = i % cols;
|
|
1555
|
+
const row = Math.floor(i / cols);
|
|
1556
|
+
const x = col * (cellW + padding);
|
|
1557
|
+
const y = row * (cellH + padding);
|
|
1558
|
+
const frameName = basename(sheetFiles[i]);
|
|
1559
|
+
const resized = await sharp(sheetFiles[i]).resize(cellW, cellH).toBuffer();
|
|
1560
|
+
composites.push({ input: resized, left: x, top: y });
|
|
1561
|
+
frames[frameName] = { x, y, w: cellW, h: cellH };
|
|
1562
|
+
}
|
|
1563
|
+
const canvas = await sharp({ create: { width: totalW, height: totalH, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } }).png().toBuffer();
|
|
1564
|
+
const sheetBuf = await sharp(canvas).composite(composites).png().toBuffer();
|
|
1565
|
+
const pngPath = `${outBase}.png`;
|
|
1566
|
+
const jsonPath = `${outBase}.json`;
|
|
1567
|
+
ensureDir(pngPath);
|
|
1568
|
+
writeFileSync(pngPath, sheetBuf);
|
|
1569
|
+
writeFileSync(jsonPath, JSON.stringify({ frames, meta: { image: pngPath, size: { w: totalW, h: totalH }, scale: 1 } }, null, 2), 'utf-8');
|
|
1570
|
+
console.log(`Sprite sheet: ${pngPath} (${cols}x${rows}, ${sheetFiles.length} frames, ${sizeLabel(sheetBuf)})`);
|
|
1571
|
+
console.log(`Frame data: ${jsonPath}`);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
if (failed > 0)
|
|
1575
|
+
process.exit(1);
|
|
1576
|
+
}
|
|
1577
|
+
catch (e) {
|
|
1578
|
+
handleError(e);
|
|
1579
|
+
}
|
|
1580
|
+
});
|
|
751
1581
|
// ─── remove-background ─────────────────────────────────────────
|
|
752
1582
|
img.command('remove-background')
|
|
753
1583
|
.description([
|
|
@@ -821,4 +1651,468 @@ export function registerImageCommands(program) {
|
|
|
821
1651
|
handleError(e);
|
|
822
1652
|
}
|
|
823
1653
|
});
|
|
1654
|
+
// ─── animate ─────────────────────────────────────────────────────────────
|
|
1655
|
+
img
|
|
1656
|
+
.command('animate')
|
|
1657
|
+
.description('Generate a sprite sheet animation from a static image using built-in presets')
|
|
1658
|
+
.option('--input <path>', 'Source image path (PNG/JPG)')
|
|
1659
|
+
.option('--back <path>', 'Back-side image for flip animation (enables two-image flip mode)')
|
|
1660
|
+
.option('--output <path>', 'Output sprite sheet path (PNG)')
|
|
1661
|
+
.option('--preset <name>', `Animation preset: ${Object.keys(ANIMATE_PRESETS).join(', ')} (default: bounce)`, 'bounce')
|
|
1662
|
+
.option('--frames <n>', 'Number of animation frames (default: 8)', '8')
|
|
1663
|
+
.option('--columns <n>', 'Columns in output sprite sheet (default: 4)', '4')
|
|
1664
|
+
.option('--list-presets', 'List all available presets and exit')
|
|
1665
|
+
.action(async (opts) => {
|
|
1666
|
+
try {
|
|
1667
|
+
if (opts.listPresets) {
|
|
1668
|
+
console.log('\nAvailable animation presets:\n');
|
|
1669
|
+
for (const [name, info] of Object.entries(ANIMATE_PRESETS)) {
|
|
1670
|
+
console.log(` ${name.padEnd(10)} ${info.description}`);
|
|
1671
|
+
console.log(` Best for: ${info.example}`);
|
|
1672
|
+
}
|
|
1673
|
+
console.log('\n flip Two-image card flip (requires --back <path>)');
|
|
1674
|
+
console.log(' Best for: cards, tiles, board game pieces');
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
if (!opts.input || !opts.output) {
|
|
1678
|
+
console.error('Error: --input and --output are required');
|
|
1679
|
+
process.exit(1);
|
|
1680
|
+
}
|
|
1681
|
+
if (!existsSync(opts.input)) {
|
|
1682
|
+
console.error(`Error: input file not found: ${opts.input}`);
|
|
1683
|
+
process.exit(1);
|
|
1684
|
+
}
|
|
1685
|
+
const frames = parseInt(opts.frames, 10);
|
|
1686
|
+
const columns = parseInt(opts.columns, 10);
|
|
1687
|
+
if (frames < 2 || frames > 64) {
|
|
1688
|
+
console.error('Error: --frames must be between 2 and 64');
|
|
1689
|
+
process.exit(1);
|
|
1690
|
+
}
|
|
1691
|
+
const t0 = Date.now();
|
|
1692
|
+
// Two-image flip mode: front shrinks → back expands
|
|
1693
|
+
if (opts.back || opts.preset === 'flip') {
|
|
1694
|
+
if (!opts.back) {
|
|
1695
|
+
console.error('Error: --preset flip requires --back <backside-image>');
|
|
1696
|
+
process.exit(1);
|
|
1697
|
+
}
|
|
1698
|
+
if (!existsSync(opts.back)) {
|
|
1699
|
+
console.error(`Error: back image not found: ${opts.back}`);
|
|
1700
|
+
process.exit(1);
|
|
1701
|
+
}
|
|
1702
|
+
await buildFlipSpriteSheet(opts.input, opts.back, frames, columns, opts.output);
|
|
1703
|
+
}
|
|
1704
|
+
else {
|
|
1705
|
+
const preset = opts.preset;
|
|
1706
|
+
if (!ANIMATE_PRESETS[preset]) {
|
|
1707
|
+
console.error(`Error: unknown preset '${preset}'. Available: ${Object.keys(ANIMATE_PRESETS).join(', ')}, flip`);
|
|
1708
|
+
process.exit(1);
|
|
1709
|
+
}
|
|
1710
|
+
await buildAnimationSpriteSheet(opts.input, preset, frames, columns, opts.output);
|
|
1711
|
+
}
|
|
1712
|
+
const elapsed = ((Date.now() - t0) / 1000).toFixed(2);
|
|
1713
|
+
const outBuf = readFileSync(opts.output);
|
|
1714
|
+
const meta = await sharp(opts.input).metadata();
|
|
1715
|
+
console.log(`Animation generated: ${opts.output} (${sizeLabel(outBuf)}, ${elapsed}s)`);
|
|
1716
|
+
console.log(` Preset: ${opts.back ? 'flip' : opts.preset} | Frames: ${frames} | Layout: ${columns}×${Math.ceil(frames / columns)} | Frame size: ${meta.width}×${meta.height}`);
|
|
1717
|
+
}
|
|
1718
|
+
catch (e) {
|
|
1719
|
+
handleError(e);
|
|
1720
|
+
}
|
|
1721
|
+
});
|
|
1722
|
+
// ─── list-vfx ────────────────────────────────────────────────────────────
|
|
1723
|
+
img
|
|
1724
|
+
.command('list-vfx')
|
|
1725
|
+
.description('List bundled VFX sprite sheet presets')
|
|
1726
|
+
.option('--json', 'Output as JSON')
|
|
1727
|
+
.action((opts) => {
|
|
1728
|
+
if (opts.json) {
|
|
1729
|
+
console.log(JSON.stringify({ vfxPresets: VFX_PRESETS }, null, 2));
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
console.log('\nBundled VFX Sprite Sheet Presets:\n');
|
|
1733
|
+
for (const vfx of VFX_PRESETS) {
|
|
1734
|
+
const filePath = join(__vfxDir, vfx.file);
|
|
1735
|
+
const exists = existsSync(filePath);
|
|
1736
|
+
const status = exists ? vfx.size : '(not bundled — use generate-image to create)';
|
|
1737
|
+
console.log(` ${vfx.name.padEnd(20)} ${status.padEnd(10)} ${vfx.description}`);
|
|
1738
|
+
console.log(` ${''.padEnd(20)} ${vfx.frames} frames, ${vfx.columns} columns`);
|
|
1739
|
+
}
|
|
1740
|
+
console.log('\nUsage: playcraft image use-vfx --preset <name> --output assets/images/vfx/<name>.png');
|
|
1741
|
+
});
|
|
1742
|
+
// ─── use-vfx ─────────────────────────────────────────────────────────────
|
|
1743
|
+
img
|
|
1744
|
+
.command('use-vfx')
|
|
1745
|
+
.description('Copy a bundled VFX preset to your project')
|
|
1746
|
+
.requiredOption('--preset <name>', 'VFX preset name (see list-vfx)')
|
|
1747
|
+
.requiredOption('--output <path>', 'Output file path')
|
|
1748
|
+
.option('--tint <hex>', 'Optional color tint to apply (e.g. "#FF4400")')
|
|
1749
|
+
.option('--scale <n>', 'Optional scale factor', '1.0')
|
|
1750
|
+
.action(async (opts) => {
|
|
1751
|
+
try {
|
|
1752
|
+
const preset = VFX_PRESETS.find((v) => v.name === opts.preset);
|
|
1753
|
+
if (!preset) {
|
|
1754
|
+
console.error(`Error: VFX preset '${opts.preset}' not found. Run 'playcraft image list-vfx' to see available presets.`);
|
|
1755
|
+
process.exit(1);
|
|
1756
|
+
}
|
|
1757
|
+
const srcPath = join(__vfxDir, preset.file);
|
|
1758
|
+
if (!existsSync(srcPath)) {
|
|
1759
|
+
console.log(`VFX sprite '${preset.name}' is not bundled yet. Generating via AI is recommended:`);
|
|
1760
|
+
console.log(`\n playcraft tools generate-image \\`);
|
|
1761
|
+
console.log(` --prompt "${preset.description} sprite sheet ${preset.frames} frames transparent background" \\`);
|
|
1762
|
+
console.log(` --output ${opts.output}`);
|
|
1763
|
+
console.log(`\nThen use: playcraft image sprite-split --input ${opts.output} --auto-detect`);
|
|
1764
|
+
process.exit(1);
|
|
1765
|
+
}
|
|
1766
|
+
const scale = parseFloat(opts.scale);
|
|
1767
|
+
const outDir = dirname(resolve(opts.output));
|
|
1768
|
+
if (!existsSync(outDir))
|
|
1769
|
+
mkdirSync(outDir, { recursive: true });
|
|
1770
|
+
if (!opts.tint && scale === 1.0) {
|
|
1771
|
+
copyFileSync(srcPath, opts.output);
|
|
1772
|
+
const sz = sizeLabel(readFileSync(opts.output));
|
|
1773
|
+
console.log(`VFX copied: ${opts.output} (${sz})`);
|
|
1774
|
+
console.log(` ${preset.frames} frames, ${preset.columns} cols — ${preset.description}`);
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
// Apply tint/scale via sharp
|
|
1778
|
+
let pipeline = sharp(srcPath);
|
|
1779
|
+
if (scale !== 1.0) {
|
|
1780
|
+
const info = await sharp(srcPath).metadata();
|
|
1781
|
+
const w = Math.round((info.width ?? 256) * scale);
|
|
1782
|
+
const h = Math.round((info.height ?? 256) * scale);
|
|
1783
|
+
pipeline = pipeline.resize(w, h, { fit: 'fill' });
|
|
1784
|
+
}
|
|
1785
|
+
if (opts.tint) {
|
|
1786
|
+
const hex = opts.tint.replace('#', '');
|
|
1787
|
+
const r = parseInt(hex.substring(0, 2), 16);
|
|
1788
|
+
const g = parseInt(hex.substring(2, 4), 16);
|
|
1789
|
+
const b = parseInt(hex.substring(4, 6), 16);
|
|
1790
|
+
// Tint: multiply RGB channels by tint color
|
|
1791
|
+
pipeline = pipeline.tint({ r, g, b });
|
|
1792
|
+
}
|
|
1793
|
+
const buf = await pipeline.png().toBuffer();
|
|
1794
|
+
writeOutput(opts.output, buf);
|
|
1795
|
+
console.log(`VFX copied: ${opts.output} (${sizeLabel(buf)})`);
|
|
1796
|
+
if (opts.tint)
|
|
1797
|
+
console.log(` Tint: ${opts.tint}`);
|
|
1798
|
+
if (scale !== 1.0)
|
|
1799
|
+
console.log(` Scale: ${scale}x`);
|
|
1800
|
+
}
|
|
1801
|
+
catch (e) {
|
|
1802
|
+
handleError(e);
|
|
1803
|
+
}
|
|
1804
|
+
});
|
|
1805
|
+
// ─── gen-tile-back ───────────────────────────────────────────────────────
|
|
1806
|
+
img
|
|
1807
|
+
.command('gen-tile-back')
|
|
1808
|
+
.description('Programmatically generate a clean mahjong tile back (no AI needed)')
|
|
1809
|
+
.option('--output <path>', 'Output PNG path', 'tile_back.png')
|
|
1810
|
+
.option('--width <n>', 'Tile width in pixels', '512')
|
|
1811
|
+
.option('--height <n>', 'Tile height in pixels', '640')
|
|
1812
|
+
.option('--bg-color <hex>', 'Background color', '#1a5c2e')
|
|
1813
|
+
.option('--border-color <hex>', 'Border color', '#ffffff')
|
|
1814
|
+
.option('--style <name>', 'Style preset: classic | minimal | ornate', 'classic')
|
|
1815
|
+
.action(async (opts) => {
|
|
1816
|
+
try {
|
|
1817
|
+
const W = parseInt(opts.width, 10);
|
|
1818
|
+
const H = parseInt(opts.height, 10);
|
|
1819
|
+
const bg = opts.bgColor;
|
|
1820
|
+
const border = opts.borderColor;
|
|
1821
|
+
const style = opts.style;
|
|
1822
|
+
const buf = await generateTileBack(W, H, bg, border, style);
|
|
1823
|
+
ensureDir(opts.output);
|
|
1824
|
+
writeFileSync(opts.output, buf);
|
|
1825
|
+
console.log(`Tile back generated: ${opts.output} (${sizeLabel(buf)}, ${W}×${H}, style: ${style})`);
|
|
1826
|
+
}
|
|
1827
|
+
catch (e) {
|
|
1828
|
+
handleError(e);
|
|
1829
|
+
}
|
|
1830
|
+
});
|
|
1831
|
+
// ─── decompose-layers ──────────────────────────────────────────────────────
|
|
1832
|
+
img
|
|
1833
|
+
.command('decompose-layers')
|
|
1834
|
+
.description([
|
|
1835
|
+
'302 Qwen-Image-Layered 整图语义分层:将合成图拆成 N 个 RGBA 图层',
|
|
1836
|
+
'',
|
|
1837
|
+
' 示例:',
|
|
1838
|
+
' playcraft image decompose-layers --input composite.png --output-dir ./layers',
|
|
1839
|
+
' playcraft image decompose-layers --input https://example.com/a.png --num-layers 4',
|
|
1840
|
+
'',
|
|
1841
|
+
' 需 Admin 配置 provider=302、modelKind=image 的 API Key;耗时约 1–2 分钟。',
|
|
1842
|
+
' 本地文件需 storage 公网 URL,或 --input 使用 HTTPS 直链。',
|
|
1843
|
+
].join('\n'))
|
|
1844
|
+
.requiredOption('--input <path>', '输入图片路径或 HTTPS URL')
|
|
1845
|
+
.option('--output-dir <dir>', '输出目录', './decompose-layers-output')
|
|
1846
|
+
.option('--num-layers <n>', '分层数量 2–10', cliParseInt, 4)
|
|
1847
|
+
.option('--prompt <text>', '可选分层语义引导', '')
|
|
1848
|
+
.option('--no-safety-checker', '关闭安全检测')
|
|
1849
|
+
.option('--output-format <fmt>', '输出格式', 'png')
|
|
1850
|
+
.action(async (opts) => {
|
|
1851
|
+
try {
|
|
1852
|
+
const payload = {
|
|
1853
|
+
numLayers: opts.numLayers,
|
|
1854
|
+
prompt: opts.prompt || '',
|
|
1855
|
+
enableSafetyChecker: opts.safetyChecker !== false,
|
|
1856
|
+
outputFormat: opts.outputFormat,
|
|
1857
|
+
};
|
|
1858
|
+
const input = opts.input;
|
|
1859
|
+
const isUrl = input.startsWith('http://') || input.startsWith('https://');
|
|
1860
|
+
if (isUrl) {
|
|
1861
|
+
payload.imageUrl = input;
|
|
1862
|
+
console.log(`Using image URL: ${input}`);
|
|
1863
|
+
}
|
|
1864
|
+
else {
|
|
1865
|
+
assertInputWithinLimit(input);
|
|
1866
|
+
payload.imageBase64 = readFileSync(input).toString('base64');
|
|
1867
|
+
console.log(`Uploading local image: ${input}`);
|
|
1868
|
+
}
|
|
1869
|
+
console.log(`Decomposing into ${payload.numLayers} layer(s) via 302 Qwen-Image-Layered (may take 1–2 min)…`);
|
|
1870
|
+
const t0 = Date.now();
|
|
1871
|
+
const client = new AgentApiClient();
|
|
1872
|
+
const result = await client.post('/image-layered/decompose', payload);
|
|
1873
|
+
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
1874
|
+
const outputDir = opts.outputDir;
|
|
1875
|
+
mkdirSync(outputDir, { recursive: true });
|
|
1876
|
+
for (const layer of result.layers) {
|
|
1877
|
+
const ext = layer.mimeType.includes('png') ? 'png' : 'png';
|
|
1878
|
+
const filename = `layer_${String(layer.index).padStart(2, '0')}.${ext}`;
|
|
1879
|
+
writeFileSync(join(outputDir, filename), Buffer.from(layer.imageBase64, 'base64'));
|
|
1880
|
+
const dim = layer.width && layer.height ? ` ${layer.width}×${layer.height}` : '';
|
|
1881
|
+
console.log(` ${filename}${dim}`);
|
|
1882
|
+
}
|
|
1883
|
+
const meta = {
|
|
1884
|
+
source: input,
|
|
1885
|
+
requestId: result.requestId,
|
|
1886
|
+
numLayers: result.numLayers,
|
|
1887
|
+
elapsed: `${elapsed}s`,
|
|
1888
|
+
hasNsfwConcepts: result.hasNsfwConcepts,
|
|
1889
|
+
layers: result.layers.map((l) => ({
|
|
1890
|
+
index: l.index,
|
|
1891
|
+
url: l.url,
|
|
1892
|
+
width: l.width,
|
|
1893
|
+
height: l.height,
|
|
1894
|
+
seed: l.seed,
|
|
1895
|
+
})),
|
|
1896
|
+
};
|
|
1897
|
+
writeFileSync(join(outputDir, 'decompose-layers-meta.json'), JSON.stringify(meta, null, 2), 'utf-8');
|
|
1898
|
+
console.log(`\nDone in ${elapsed}s — ${result.layers.length} layer(s) → ${outputDir}/`);
|
|
1899
|
+
}
|
|
1900
|
+
catch (e) {
|
|
1901
|
+
handleError(e);
|
|
1902
|
+
}
|
|
1903
|
+
});
|
|
1904
|
+
// ─── segment ─────────────────────────────────────────────────────────────
|
|
1905
|
+
img
|
|
1906
|
+
.command('segment')
|
|
1907
|
+
.description([
|
|
1908
|
+
'SAM3 图片语义分割(开放词汇):文本或框 prompt → mask + 可选 RGBA 抠图',
|
|
1909
|
+
'',
|
|
1910
|
+
' 单 prompt 示例:',
|
|
1911
|
+
' playcraft image segment --input banner.png --text "icon" --output-dir ./out',
|
|
1912
|
+
'',
|
|
1913
|
+
' 多 prompt 批量:',
|
|
1914
|
+
' playcraft image segment --input banner.png \\',
|
|
1915
|
+
' --prompts \'[{"text":"icon"},{"text":"logo"}]\' --output-dir ./out',
|
|
1916
|
+
'',
|
|
1917
|
+
' 注意:服务首次冷启动约需 15s,后端会自动等待就绪后再执行分割。',
|
|
1918
|
+
].join('\n'))
|
|
1919
|
+
.requiredOption('--input <path>', '输入图片路径(PNG / JPG)')
|
|
1920
|
+
.option('--text <prompt>', '文本 prompt(如 icon、logo、orange button)')
|
|
1921
|
+
.option('--prompts <json>', '批量 prompt JSON 数组,如 \'[{"text":"icon"},{"text":"logo"}]\'(与 --text 二选一)')
|
|
1922
|
+
.option('--boxes <json>', '框 prompt JSON,如 \'[[x1,y1,x2,y2],...]\' (xyxy 绝对像素)')
|
|
1923
|
+
.option('--box-labels <json>', '框标签 JSON,如 \'[1,0]\'(1=positive, 0=negative)')
|
|
1924
|
+
.option('--threshold <n>', '实例得分阈值(默认 0.3)', parseFloat, 0.3)
|
|
1925
|
+
.option('--mask-threshold <n>', 'mask 二值化阈值(默认 0.5)', parseFloat)
|
|
1926
|
+
.option('--no-rgba', '不返回 RGBA 抠图(默认开启)')
|
|
1927
|
+
.option('--output-dir <dir>', '输出目录(默认 ./segment-output)', './segment-output')
|
|
1928
|
+
.action(async (opts) => {
|
|
1929
|
+
try {
|
|
1930
|
+
assertInputWithinLimit(opts.input);
|
|
1931
|
+
const imageBase64 = readFileSync(opts.input).toString('base64');
|
|
1932
|
+
// 构造 prompts 列表
|
|
1933
|
+
let prompts;
|
|
1934
|
+
if (opts.prompts) {
|
|
1935
|
+
try {
|
|
1936
|
+
prompts = JSON.parse(opts.prompts);
|
|
1937
|
+
if (!Array.isArray(prompts) || prompts.length === 0) {
|
|
1938
|
+
throw new Error('prompts must be a non-empty array');
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
catch (err) {
|
|
1942
|
+
console.error(`Error: --prompts is not valid JSON: ${err.message}`);
|
|
1943
|
+
process.exit(1);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
else if (opts.text || opts.boxes) {
|
|
1947
|
+
const singlePrompt = {};
|
|
1948
|
+
if (opts.text)
|
|
1949
|
+
singlePrompt.text = opts.text;
|
|
1950
|
+
if (opts.boxes) {
|
|
1951
|
+
try {
|
|
1952
|
+
singlePrompt.boxes = JSON.parse(opts.boxes);
|
|
1953
|
+
}
|
|
1954
|
+
catch {
|
|
1955
|
+
console.error('Error: --boxes is not valid JSON');
|
|
1956
|
+
process.exit(1);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
if (opts.boxLabels) {
|
|
1960
|
+
try {
|
|
1961
|
+
singlePrompt.boxLabels = JSON.parse(opts.boxLabels);
|
|
1962
|
+
}
|
|
1963
|
+
catch {
|
|
1964
|
+
console.error('Error: --box-labels is not valid JSON');
|
|
1965
|
+
process.exit(1);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
prompts = [singlePrompt];
|
|
1969
|
+
}
|
|
1970
|
+
else {
|
|
1971
|
+
console.error('Error: provide --text, --boxes, or --prompts');
|
|
1972
|
+
process.exit(1);
|
|
1973
|
+
}
|
|
1974
|
+
const payload = {
|
|
1975
|
+
imageBase64,
|
|
1976
|
+
prompts,
|
|
1977
|
+
threshold: opts.threshold,
|
|
1978
|
+
returnRgba: opts.rgba !== false,
|
|
1979
|
+
...(opts.maskThreshold !== undefined ? { maskThreshold: opts.maskThreshold } : {}),
|
|
1980
|
+
};
|
|
1981
|
+
console.log(`Segmenting ${opts.input} with ${prompts.length} prompt(s) (threshold=${opts.threshold})...`);
|
|
1982
|
+
const t0 = Date.now();
|
|
1983
|
+
const client = new AgentApiClient();
|
|
1984
|
+
const result = await client.post('/sam3/segment', payload);
|
|
1985
|
+
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
1986
|
+
// 保存结果
|
|
1987
|
+
const outputDir = opts.outputDir;
|
|
1988
|
+
mkdirSync(outputDir, { recursive: true });
|
|
1989
|
+
const meta = {
|
|
1990
|
+
source: opts.input,
|
|
1991
|
+
imageSize: result.imageSize,
|
|
1992
|
+
elapsed: `${elapsed}s`,
|
|
1993
|
+
prompts: prompts.length,
|
|
1994
|
+
results: [],
|
|
1995
|
+
};
|
|
1996
|
+
let totalInstances = 0;
|
|
1997
|
+
for (const promptResult of result.results) {
|
|
1998
|
+
const label = promptResult.prompt.text
|
|
1999
|
+
? promptResult.prompt.text.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 40)
|
|
2000
|
+
: `boxes_${promptResult.promptIndex}`;
|
|
2001
|
+
const promptDir = join(outputDir, `prompt_${promptResult.promptIndex}_${label}`);
|
|
2002
|
+
mkdirSync(promptDir, { recursive: true });
|
|
2003
|
+
const instanceSummaries = [];
|
|
2004
|
+
for (let i = 0; i < promptResult.instances.length; i++) {
|
|
2005
|
+
const inst = promptResult.instances[i];
|
|
2006
|
+
const prefix = String(i).padStart(2, '0');
|
|
2007
|
+
writeFileSync(join(promptDir, `${prefix}_mask.png`), Buffer.from(inst.maskBase64, 'base64'));
|
|
2008
|
+
if (inst.rgbaBase64) {
|
|
2009
|
+
writeFileSync(join(promptDir, `${prefix}_rgba.png`), Buffer.from(inst.rgbaBase64, 'base64'));
|
|
2010
|
+
}
|
|
2011
|
+
instanceSummaries.push({
|
|
2012
|
+
index: i,
|
|
2013
|
+
score: inst.score.toFixed(3),
|
|
2014
|
+
bbox: inst.bbox,
|
|
2015
|
+
hasMask: true,
|
|
2016
|
+
hasRgba: !!inst.rgbaBase64,
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
totalInstances += promptResult.count;
|
|
2020
|
+
meta.results.push({
|
|
2021
|
+
promptIndex: promptResult.promptIndex,
|
|
2022
|
+
prompt: promptResult.prompt,
|
|
2023
|
+
count: promptResult.count,
|
|
2024
|
+
instances: instanceSummaries,
|
|
2025
|
+
});
|
|
2026
|
+
console.log(` prompt[${promptResult.promptIndex}] "${promptResult.prompt.text ?? '(boxes)'}" → ${promptResult.count} instance(s) → ${promptDir}`);
|
|
2027
|
+
}
|
|
2028
|
+
writeFileSync(join(outputDir, 'segment-meta.json'), JSON.stringify(meta, null, 2), 'utf-8');
|
|
2029
|
+
console.log(`\nDone in ${elapsed}s — ${totalInstances} total instance(s) across ${result.results.length} prompt(s)`);
|
|
2030
|
+
console.log(`Output: ${outputDir}/ (segment-meta.json + per-prompt subdirs)`);
|
|
2031
|
+
}
|
|
2032
|
+
catch (e) {
|
|
2033
|
+
handleError(e);
|
|
2034
|
+
}
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
// ─── Tile back SVG generator ─────────────────────────────────────────────────
|
|
2038
|
+
async function generateTileBack(W, H, bg, borderColor, style) {
|
|
2039
|
+
const r = Math.round(W * 0.08); // corner radius
|
|
2040
|
+
const ob = Math.round(W * 0.04); // outer border inset
|
|
2041
|
+
const sw1 = Math.round(W * 0.025); // outer border stroke width
|
|
2042
|
+
const ib = ob + sw1 + Math.round(W * 0.025); // inner border inset
|
|
2043
|
+
const sw2 = Math.round(W * 0.012); // inner border stroke width
|
|
2044
|
+
// Corner diamond ornament helper
|
|
2045
|
+
const diamond = (cx, cy, size, color) => `<polygon points="${cx},${cy - size} ${cx + size},${cy} ${cx},${cy + size} ${cx - size},${cy}" fill="${color}"/>`;
|
|
2046
|
+
// Corner flower (4 circles around a center) for ornate style
|
|
2047
|
+
const flower = (cx, cy, size, color) => {
|
|
2048
|
+
const r2 = size * 0.45;
|
|
2049
|
+
const off = size * 0.55;
|
|
2050
|
+
return [
|
|
2051
|
+
`<circle cx="${cx}" cy="${cy}" r="${r2 * 0.7}" fill="${color}"/>`,
|
|
2052
|
+
`<circle cx="${cx - off}" cy="${cy}" r="${r2}" fill="${color}"/>`,
|
|
2053
|
+
`<circle cx="${cx + off}" cy="${cy}" r="${r2}" fill="${color}"/>`,
|
|
2054
|
+
`<circle cx="${cx}" cy="${cy - off}" r="${r2}" fill="${color}"/>`,
|
|
2055
|
+
`<circle cx="${cx}" cy="${cy + off}" r="${r2}" fill="${color}"/>`,
|
|
2056
|
+
].join('');
|
|
2057
|
+
};
|
|
2058
|
+
const ornSize = Math.round(W * 0.055);
|
|
2059
|
+
const ornInset = ib + sw2 + Math.round(W * 0.06);
|
|
2060
|
+
const cornerPositions = [
|
|
2061
|
+
[ornInset, ornInset],
|
|
2062
|
+
[W - ornInset, ornInset],
|
|
2063
|
+
[ornInset, H - ornInset],
|
|
2064
|
+
[W - ornInset, H - ornInset],
|
|
2065
|
+
];
|
|
2066
|
+
const corners = cornerPositions
|
|
2067
|
+
.map(([cx, cy]) => style === 'ornate'
|
|
2068
|
+
? flower(cx, cy, ornSize, borderColor)
|
|
2069
|
+
: diamond(cx, cy, ornSize, borderColor))
|
|
2070
|
+
.join('');
|
|
2071
|
+
// Center pattern
|
|
2072
|
+
const cx = W / 2;
|
|
2073
|
+
const cy = H / 2;
|
|
2074
|
+
const centerPattern = (() => {
|
|
2075
|
+
if (style === 'minimal')
|
|
2076
|
+
return '';
|
|
2077
|
+
if (style === 'ornate') {
|
|
2078
|
+
const big = Math.round(W * 0.12);
|
|
2079
|
+
return flower(cx, cy, big, borderColor);
|
|
2080
|
+
}
|
|
2081
|
+
// classic: nested diamonds
|
|
2082
|
+
const s1 = Math.round(W * 0.14);
|
|
2083
|
+
const s2 = Math.round(W * 0.09);
|
|
2084
|
+
return [
|
|
2085
|
+
diamond(cx, cy, s1, borderColor),
|
|
2086
|
+
diamond(cx, cy, s2, bg),
|
|
2087
|
+
diamond(cx, cy, s2 * 0.5, borderColor),
|
|
2088
|
+
].join('');
|
|
2089
|
+
})();
|
|
2090
|
+
// Side mid ornaments (classic + ornate)
|
|
2091
|
+
const sideOrnaments = style === 'minimal' ? '' : [
|
|
2092
|
+
// top mid
|
|
2093
|
+
diamond(cx, ib + sw2 + ornSize * 0.8, ornSize * 0.6, borderColor),
|
|
2094
|
+
// bottom mid
|
|
2095
|
+
diamond(cx, H - ib - sw2 - ornSize * 0.8, ornSize * 0.6, borderColor),
|
|
2096
|
+
// left mid
|
|
2097
|
+
diamond(ib + sw2 + ornSize * 0.8, cy, ornSize * 0.6, borderColor),
|
|
2098
|
+
// right mid
|
|
2099
|
+
diamond(W - ib - sw2 - ornSize * 0.8, cy, ornSize * 0.6, borderColor),
|
|
2100
|
+
].join('');
|
|
2101
|
+
const svg = `<svg width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg">
|
|
2102
|
+
<!-- background -->
|
|
2103
|
+
<rect width="${W}" height="${H}" fill="${bg}" rx="${r}" ry="${r}"/>
|
|
2104
|
+
<!-- outer border -->
|
|
2105
|
+
<rect x="${ob}" y="${ob}" width="${W - 2 * ob}" height="${H - 2 * ob}"
|
|
2106
|
+
fill="none" stroke="${borderColor}" stroke-width="${sw1}" rx="${r - ob}" ry="${r - ob}"/>
|
|
2107
|
+
<!-- inner border -->
|
|
2108
|
+
<rect x="${ib}" y="${ib}" width="${W - 2 * ib}" height="${H - 2 * ib}"
|
|
2109
|
+
fill="none" stroke="${borderColor}" stroke-width="${sw2}" rx="${Math.max(2, r - ib)}" ry="${Math.max(2, r - ib)}"/>
|
|
2110
|
+
<!-- corner ornaments -->
|
|
2111
|
+
${corners}
|
|
2112
|
+
<!-- side ornaments -->
|
|
2113
|
+
${sideOrnaments}
|
|
2114
|
+
<!-- center pattern -->
|
|
2115
|
+
${centerPattern}
|
|
2116
|
+
</svg>`;
|
|
2117
|
+
return sharp(Buffer.from(svg)).png().toBuffer();
|
|
824
2118
|
}
|