新增图片批量处理

This commit is contained in:
yinsx
2026-01-15 10:24:02 +08:00
parent 1de2ac8491
commit 943bf4bbd4
18 changed files with 1208 additions and 123 deletions

View File

@ -21,7 +21,12 @@
"Bash(do cp lib/$dir/*.js lib/plugins/$dir/)", "Bash(do cp lib/$dir/*.js lib/plugins/$dir/)",
"Bash(done)", "Bash(done)",
"Bash(timeout 3 node:*)", "Bash(timeout 3 node:*)",
"Bash(find:*)" "Bash(find:*)",
"Bash(npm rebuild sharp)",
"Bash(npm view:*)",
"Bash(ls:*)",
"Bash(dir:*)",
"Bash(del:*)"
] ]
} }
} }

View File

@ -2,5 +2,9 @@
"convert_format": { "convert_format": {
"stp": 1, "stp": 1,
"glb2": 3 "glb2": 3
},
"image_operation": {
"compress": 8,
"resize": 1
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@ -14,13 +14,19 @@ export function initKeypress() {
process.stdin.removeAllListeners("keypress"); process.stdin.removeAllListeners("keypress");
currentHandler = null; currentHandler = null;
// 确保raw mode关闭再重新开启
ensureRawMode(false);
// 初始化readline只需一次 // 初始化readline只需一次
if (!initialized) { if (!initialized) {
readline.emitKeypressEvents(process.stdin); readline.emitKeypressEvents(process.stdin);
initialized = true; initialized = true;
} }
ensureRawMode(true); // 短暂延迟后再开启raw mode
setImmediate(() => {
ensureRawMode(true);
});
} }
export function onKey(handler) { export function onKey(handler) {

View File

@ -0,0 +1,85 @@
import fs from "fs";
export const title = "图片批量处理";
const IMAGE_EXTS = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".tiff", ".avif"];
export function listImageFiles() {
try {
const cwd = process.cwd();
const files = fs.readdirSync(cwd);
return files.filter(file => {
const ext = file.slice(file.lastIndexOf(".")).toLowerCase();
return IMAGE_EXTS.includes(ext);
});
} catch (err) {
console.error("读取文件列表失败:", err);
return [];
}
}
export function getSteps() {
const files = listImageFiles();
return [
{
name: "源文件",
type: "multiselect",
message: files.length ? "选择要处理的图片文件" : "当前目录未找到图片文件",
options: files.map(file => ({ value: file, label: file })),
default: [],
emptyMessage: "请按 Esc 返回并放入图片文件后重试"
},
{
name: "压缩",
type: "select",
message: "选择压缩质量",
options: [
{ value: "skip", label: "跳过此操作" },
{ value: 90, label: "高质量 (90%)" },
{ value: 80, label: "标准 (80%)" },
{ value: 60, label: "中等 (60%)" },
{ value: 40, label: "低质量 (40%)" }
],
default: "skip"
},
{
name: "生成多尺寸",
type: "select",
message: "选择尺寸预设",
options: [
{ value: "skip", label: "跳过此操作" },
{ value: "responsive", label: "响应式 (800/400/200)" },
{ value: "thumbnail", label: "缩略图 (300/150/75)" },
{ value: "large", label: "大图 (1920/1280/640)" }
],
default: "skip"
},
{
name: "统一背景",
type: "select",
message: "选择背景颜色",
options: [
{ value: "skip", label: "跳过此操作" },
{ value: 0xFFFFFFFF, label: "白色" },
{ value: 0x000000FF, label: "黑色" },
{ value: 0xF5F5F5FF, label: "浅灰" },
{ value: 0x333333FF, label: "深灰" }
],
default: "skip"
},
{
name: "裁剪补边",
type: "select",
message: "选择目标画布尺寸",
options: [
{ value: "skip", label: "跳过此操作" },
{ value: "1200x800", label: "1200×800 (3:2)" },
{ value: "1920x1080", label: "1920×1080 (16:9)" },
{ value: "1080x1080", label: "1080×1080 (1:1)" },
{ value: "800x600", label: "800×600 (4:3)" }
],
default: "skip"
}
];
}

View File

@ -1,5 +1,135 @@
async function run() { import color from "picocolors";
throw new Error("尚未实现"); import { createStepUI } from "../../utils/stepui.js";
import { title, getSteps } from "./config.js";
import { stopKeypress, waitForKey, initKeypress } from "../../keyboard.js";
import { record } from "../../stats.js";
import { processImage } from "./service.js";
import path from "path";
const OPERATIONS = [
{ id: "compress", name: "压缩", stepIndex: 1, folder: "compressed" },
{ id: "resize", name: "生成多尺寸", stepIndex: 2, folder: "resized" },
{ id: "background", name: "统一背景", stepIndex: 3, folder: "background" },
{ id: "crop", name: "裁剪补边", stepIndex: 4, folder: "cropped" }
];
const run = async () => {
try {
while (true) {
// 强制清理并重新初始化键盘
stopKeypress();
await new Promise(resolve => setTimeout(resolve, 100));
const steps = getSteps();
const ui = createStepUI({ title, getSteps: () => steps });
const result = await ui.runInteractive();
if (!result) {
stopKeypress();
return "back";
}
const files = result.results[0] || [];
const selectedOp = OPERATIONS.find(op => result.results[op.stepIndex] !== "skip");
stopKeypress();
// 如果没有选择文件或所有操作都跳过,重新开始
if (!selectedOp || !files.length) {
continue;
}
// 有效的选择,继续处理
const params = buildProcessParams(selectedOp.id, result.results[selectedOp.stepIndex]);
ui.showSummary([
"源文件: " + files.join(", "),
"操作: " + selectedOp.name,
getParamLabel(selectedOp.id, params)
]);
const total = files.length;
let success = 0;
const failed = [];
console.log(color.cyan(`开始处理 ${total} 个文件...\\n`));
for (let i = 0; i < files.length; i++) {
const file = files[i];
const progress = `[${i + 1}/${total}]`;
console.log(color.dim(`${progress} 处理中: ${file}`));
try {
const outputs = await processImage(file, selectedOp.id, params);
success++;
record("image_operation", selectedOp.id);
const outputNames = Array.isArray(outputs)
? outputs.map(o => path.relative(process.cwd(), o)).join(", ")
: path.relative(process.cwd(), outputs);
console.log(color.green(`${progress}${file}${outputNames}`));
} catch (err) {
failed.push({ file, reason: err?.message || String(err) });
console.log(color.red(`${progress} ✖ 失败: ${file}`));
console.log(color.dim(" " + String(err?.message || err)));
}
}
console.log("\\n" + color.bgGreen(color.black(" 处理完成 ")));
console.log(color.green(`成功: ${success}`));
if (failed.length) console.log(color.yellow(`失败: ${failed.length}`));
console.log(color.cyan(`输出目录: ${selectedOp.folder}/`));
await waitForKey(color.dim("按 Esc 返回"), key => key?.name === "escape" || (key?.ctrl && key?.name === "c"));
stopKeypress();
return "back";
}
} catch (err) {
stopKeypress();
console.error(color.red("发生错误:"), err);
return "back";
}
};
function getParamLabel(operation, params) {
switch (operation) {
case "compress":
return `压缩质量: ${params.quality}%`;
case "resize":
const presets = {
responsive: "响应式 (800/400/200)",
thumbnail: "缩略图 (300/150/75)",
large: "大图 (1920/1280/640)"
};
return `尺寸预设: ${presets[params.sizes] || "响应式"}`;
case "background":
return `背景颜色: ${params.bgColor === 0xFFFFFFFF ? "白色" : "其他"}`;
case "crop":
return `画布尺寸: ${params.width}×${params.height}`;
default:
return "";
}
}
function buildProcessParams(operation, paramValue) {
switch (operation) {
case "compress":
return { quality: paramValue };
case "resize":
const sizePresets = {
responsive: [{ width: 800 }, { width: 400 }, { width: 200 }],
thumbnail: [{ width: 300 }, { width: 150 }, { width: 75 }],
large: [{ width: 1920 }, { width: 1280 }, { width: 640 }]
};
return { sizes: sizePresets[paramValue] || sizePresets.responsive };
case "background":
return { bgColor: paramValue };
case "crop":
const [width, height] = paramValue.split("x").map(Number);
return { width, height, bgColor: 0xFFFFFFFF };
default:
return null;
}
} }
export default { export default {

View File

@ -0,0 +1,138 @@
import { Jimp } from "jimp";
import path from "path";
import fs from "fs";
export async function compressImage(inputPath, quality = 80) {
const ext = path.extname(inputPath).toLowerCase();
const basename = path.basename(inputPath, ext);
const outputDir = path.join(path.dirname(inputPath), "compressed");
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const outputPath = path.join(outputDir, `${basename}${ext}`);
const image = await Jimp.read(inputPath);
await image.write(outputPath, { quality });
return outputPath;
}
export async function resizeImage(inputPath, sizes = [{ width: 800 }, { width: 400 }, { width: 200 }]) {
const ext = path.extname(inputPath).toLowerCase();
const basename = path.basename(inputPath, ext);
const outputDir = path.join(path.dirname(inputPath), "resized");
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const outputs = [];
for (const size of sizes) {
const outputPath = path.join(outputDir, `${basename}_${size.width}w${ext}`);
const image = await Jimp.read(inputPath);
image.resize({ w: size.width });
await image.write(outputPath);
outputs.push(outputPath);
}
return outputs;
}
export async function addWatermark(inputPath, watermarkPath, position = "southeast") {
const ext = path.extname(inputPath).toLowerCase();
const basename = path.basename(inputPath, ext);
const outputDir = path.join(path.dirname(inputPath), "watermarked");
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const outputPath = path.join(outputDir, `${basename}${ext}`);
const image = await Jimp.read(inputPath);
const watermark = await Jimp.read(watermarkPath);
watermark.resize({ w: Math.floor(image.bitmap.width * 0.2) });
const positions = {
northwest: { x: 10, y: 10 },
northeast: { x: image.bitmap.width - watermark.bitmap.width - 10, y: 10 },
southwest: { x: 10, y: image.bitmap.height - watermark.bitmap.height - 10 },
southeast: { x: image.bitmap.width - watermark.bitmap.width - 10, y: image.bitmap.height - watermark.bitmap.height - 10 }
};
const pos = positions[position];
image.composite(watermark, pos.x, pos.y);
await image.write(outputPath);
return outputPath;
}
export async function unifyBackground(inputPath, bgColor = 0xFFFFFFFF) {
const ext = path.extname(inputPath).toLowerCase();
const basename = path.basename(inputPath, ext);
const outputDir = path.join(path.dirname(inputPath), "background");
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const outputPath = path.join(outputDir, `${basename}${ext}`);
const image = await Jimp.read(inputPath);
const bg = new Jimp({ width: image.bitmap.width, height: image.bitmap.height, color: bgColor });
bg.composite(image, 0, 0);
await bg.write(outputPath);
return outputPath;
}
export async function cropWithPadding(inputPath, targetWidth, targetHeight, bgColor = 0xFFFFFFFF) {
const ext = path.extname(inputPath).toLowerCase();
const basename = path.basename(inputPath, ext);
const outputDir = path.join(path.dirname(inputPath), "cropped");
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const outputPath = path.join(outputDir, `${basename}_${targetWidth}x${targetHeight}${ext}`);
const image = await Jimp.read(inputPath);
const aspectRatio = image.bitmap.width / image.bitmap.height;
const targetRatio = targetWidth / targetHeight;
if (aspectRatio > targetRatio) {
image.resize({ w: targetWidth });
} else {
image.resize({ h: targetHeight });
}
const bg = new Jimp({ width: targetWidth, height: targetHeight, color: bgColor });
const x = Math.floor((targetWidth - image.bitmap.width) / 2);
const y = Math.floor((targetHeight - image.bitmap.height) / 2);
bg.composite(image, x, y);
await bg.write(outputPath);
return outputPath;
}
export async function processImage(file, operation, params) {
switch (operation) {
case "compress":
return await compressImage(file, params.quality);
case "resize":
return await resizeImage(file, params.sizes);
case "watermark":
return await addWatermark(file, params.watermarkPath, params.position);
case "background":
return await unifyBackground(file, params.bgColor);
case "crop":
return await cropWithPadding(file, params.width, params.height, params.bgColor);
default:
throw new Error(`未知操作: ${operation}`);
}
}

861
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -37,6 +37,7 @@
"@gltf-transform/extensions": "^3.10.1", "@gltf-transform/extensions": "^3.10.1",
"@gltf-transform/functions": "^3.10.1", "@gltf-transform/functions": "^3.10.1",
"boxen": "^8.0.1", "boxen": "^8.0.1",
"jimp": "^1.6.0",
"picocolors": "^1.1.1" "picocolors": "^1.1.1"
}, },
"devDependencies": { "devDependencies": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -1,47 +0,0 @@
#!/usr/bin/env node
import { promises as fs } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import esbuild from "esbuild";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, "..");
const distDir = path.join(rootDir, "dist");
async function main() {
await fs.rm(distDir, { recursive: true, force: true });
await fs.mkdir(distDir, { recursive: true });
await esbuild.build({
entryPoints: [path.join(rootDir, "index.js")],
bundle: true,
platform: "node",
target: "node16",
format: "esm",
outfile: path.join(distDir, "index.js"),
minify: true,
external: ["sharp", "@cocos/fbx2gltf"],
});
// 添加 shebang
const outPath = path.join(distDir, "index.js");
const code = await fs.readFile(outPath, "utf8");
await fs.writeFile(outPath, "#!/usr/bin/env node\n" + code.replace(/^#!.*\n?/, ""), "utf8");
await copyDir(path.join(rootDir, "bin"), path.join(distDir, "bin"));
console.log("Build written to", distDir);
}
async function copyDir(src, dest) {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(src);
for (const entry of entries) {
await fs.copyFile(path.join(src, entry), path.join(dest, entry));
}
}
main().catch(err => {
console.error("Build failed:", err);
process.exitCode = 1;
});

View File

@ -1,46 +0,0 @@
#!/usr/bin/env node
import { promises as fs } from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, "..");
const distDir = path.join(rootDir, "dist");
const copyTargets = [
{ from: path.join(rootDir, "index.js"), to: path.join(distDir, "index.js") },
{ from: path.join(rootDir, "lib"), to: path.join(distDir, "lib") },
{ from: path.join(rootDir, "bin"), to: path.join(distDir, "bin") },
{ from: path.join(rootDir, "package.json"), to: path.join(distDir, "package.json") },
{ from: path.join(rootDir, "Readme.md"), to: path.join(distDir, "Readme.md") }
];
async function main() {
await fs.rm(distDir, { recursive: true, force: true });
await fs.mkdir(distDir, { recursive: true });
for (const target of copyTargets) {
await copy(target.from, target.to);
}
console.log("Build written to", distDir);
}
async function copy(src, dest) {
const stat = await fs.stat(src);
if (stat.isDirectory()) {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(src);
for (const entry of entries) {
await copy(path.join(src, entry), path.join(dest, entry));
}
return;
}
await fs.copyFile(src, dest);
}
main().catch(err => {
console.error("Build failed:", err);
process.exitCode = 1;
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB