新增图片批量处理
This commit is contained in:
85
lib/plugins/image/config.js
Normal file
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
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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user