新增图片批量处理

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

@ -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;
}
ensureRawMode(true);
// 短暂延迟后再开启raw mode
setImmediate(() => {
ensureRawMode(true);
});
}
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() {
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 {

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}`);
}
}