新增图片批量处理
@ -21,7 +21,12 @@
|
||||
"Bash(do cp lib/$dir/*.js lib/plugins/$dir/)",
|
||||
"Bash(done)",
|
||||
"Bash(timeout 3 node:*)",
|
||||
"Bash(find:*)"
|
||||
"Bash(find:*)",
|
||||
"Bash(npm rebuild sharp)",
|
||||
"Bash(npm view:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(dir:*)",
|
||||
"Bash(del:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,5 +2,9 @@
|
||||
"convert_format": {
|
||||
"stp": 1,
|
||||
"glb2": 3
|
||||
},
|
||||
"image_operation": {
|
||||
"compress": 8,
|
||||
"resize": 1
|
||||
}
|
||||
}
|
||||
BIN
compressed/xw_20251220170040 - 副本 (2).png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
compressed/xw_20251220170040 - 副本 (3).png
Normal file
|
After Width: | Height: | Size: 54 KiB |
@ -14,13 +14,19 @@ export function initKeypress() {
|
||||
process.stdin.removeAllListeners("keypress");
|
||||
currentHandler = null;
|
||||
|
||||
// 确保raw mode关闭再重新开启
|
||||
ensureRawMode(false);
|
||||
|
||||
// 初始化readline(只需一次)
|
||||
if (!initialized) {
|
||||
readline.emitKeypressEvents(process.stdin);
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
// 短暂延迟后再开启raw mode
|
||||
setImmediate(() => {
|
||||
ensureRawMode(true);
|
||||
});
|
||||
}
|
||||
|
||||
export function onKey(handler) {
|
||||
|
||||
85
lib/plugins/image/config.js
Normal 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"
|
||||
}
|
||||
];
|
||||
}
|
||||
@ -1,5 +1,135 @@
|
||||
async function run() {
|
||||
throw new Error("尚未实现");
|
||||
import color from "picocolors";
|
||||
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 {
|
||||
|
||||
138
lib/plugins/image/service.js
Normal 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
@ -37,6 +37,7 @@
|
||||
"@gltf-transform/extensions": "^3.10.1",
|
||||
"@gltf-transform/functions": "^3.10.1",
|
||||
"boxen": "^8.0.1",
|
||||
"jimp": "^1.6.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
BIN
resized/xw_20251220170040_200w.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
resized/xw_20251220170040_400w.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
resized/xw_20251220170040_800w.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
@ -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;
|
||||
});
|
||||
@ -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;
|
||||
});
|
||||
BIN
xw_20251220170040 - 副本 (2).png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
xw_20251220170040 - 副本 (3).png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
xw_20251220170040 - 副本.png
Normal file
|
After Width: | Height: | Size: 78 KiB |