feat: 重构 NodeJS 版本图片处理工具

This commit is contained in:
奇趣保罗 2026-06-01 22:16:50 +08:00
parent 0922035a4c
commit d277a6118f
4 changed files with 572 additions and 0 deletions

149
photo-toolkit/format.js Normal file
View File

@ -0,0 +1,149 @@
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const {
question,
getImageDimensions,
detectImageType,
getTargetSize,
getOutputExtension,
IMAGE_TYPES,
IMAGE_TYPE_LABELS,
} = require('./utils');
// 处理单个图片
function processImage(inputPath, outputPath) {
const filename = path.basename(inputPath);
const dimensions = getImageDimensions(inputPath);
const { type, reason } = detectImageType(inputPath, dimensions);
const outputExt = getOutputExtension(type);
const newFilename = path.basename(outputPath, path.extname(outputPath)) + outputExt;
const finalOutputPath = path.join(path.dirname(outputPath), newFilename);
if (fs.existsSync(finalOutputPath)) {
console.log(`⏭️ 跳过已存在的文件: ${newFilename}`);
return false;
}
console.log(`\n📸 处理: ${filename}`);
console.log(` 类型: ${IMAGE_TYPE_LABELS[type]} (${reason})`);
console.log(` 尺寸: ${dimensions.width}x${dimensions.height}`);
try {
if (type === IMAGE_TYPES.SCREENSHOT) {
execSync(
`magick "${inputPath}" -gravity center -quality 80 "${finalOutputPath}"`,
{ stdio: 'inherit' }
);
} else {
const newSize = getTargetSize(type, dimensions.width, dimensions.height);
console.log(` 目标尺寸: ${newSize}`);
execSync(
`magick "${inputPath}" -gravity center -resize "${newSize}^" -extent "${newSize}" -quality 80 "${finalOutputPath}"`,
{ stdio: 'inherit' }
);
}
console.log(` ✅ 图片转换完成 → ${outputExt}`);
} catch (error) {
console.error(` ❌ 图片转换失败`);
throw error;
}
try {
execSync(
`exiftool -overwrite_original -gps:all= -artist="奇趣保罗" -CreatorTool= "${finalOutputPath}"`,
{ stdio: 'inherit' }
);
console.log(` ✅ 元数据处理完成`);
} catch (error) {
console.error(` ❌ 元数据处理失败`);
throw error;
}
return true;
}
// 格式转换主函数
async function runFormatTool(rl) {
console.log('\n🎨 图片格式转换工具');
console.log('='.repeat(50));
console.log('支持类型: iPhone (4:3 JPG) · 索尼相机 (3:2 JPG) · 截图 (WebP)');
const inputDir = await question(rl, '\n📁 请输入图片所在目录 (留空使用当前目录): ');
const sourceDir = inputDir.trim() || process.cwd();
if (!fs.existsSync(sourceDir)) {
console.error(`❌ 目录不存在: ${sourceDir}`);
return;
}
const outputDir = await question(rl, '📁 请输入输出目录 (留空使用 output): ');
const targetDir = outputDir.trim() || path.join(sourceDir, 'output');
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
console.log(`✅ 已创建输出目录: ${targetDir}`);
}
const extensions = ['.heic', '.heif', '.png', '.jpg', '.jpeg', '.HEIC', '.HEIF', '.PNG', '.JPG', '.JPEG'];
const files = fs.readdirSync(sourceDir).filter(file => {
const ext = path.extname(file);
const fullPath = path.join(sourceDir, file);
const isFile = fs.statSync(fullPath).isFile();
const hasValidExt = extensions.includes(ext);
const notModified = !file.includes('MODIFIED') && !file.includes('MERGED');
return isFile && hasValidExt && notModified;
});
if (files.length === 0) {
console.log('\n⚠ 未找到符合条件的图片文件');
return;
}
console.log(`\n📋 找到 ${files.length} 个文件:`);
files.forEach((file, index) => {
const inputPath = path.join(sourceDir, file);
const dimensions = getImageDimensions(inputPath);
const { type, reason } = detectImageType(inputPath, dimensions);
console.log(` ${index + 1}. ${file}${IMAGE_TYPE_LABELS[type]} (${reason})`);
});
const confirm = await question(rl, '\n❓ 是否开始处理? (y/n): ');
if (confirm.toLowerCase() !== 'y' && confirm.toLowerCase() !== 'yes') {
console.log('❌ 已取消');
return;
}
console.log('\n🚀 开始处理...\n');
let processed = 0;
let skipped = 0;
let failed = 0;
for (let i = 0; i < files.length; i++) {
const file = files[i];
const inputPath = path.join(sourceDir, file);
const outputPath = path.join(targetDir, file);
console.log(`\n[${i + 1}/${files.length}]`);
try {
const result = processImage(inputPath, outputPath);
if (result) {
processed++;
} else {
skipped++;
}
} catch (error) {
console.error(`❌ 处理失败: ${file}`);
failed++;
}
}
console.log('\n' + '='.repeat(50));
console.log('📊 处理完成!');
console.log(` ✅ 成功: ${processed}`);
console.log(` ⏭️ 跳过: ${skipped}`);
console.log(` ❌ 失败: ${failed}`);
console.log(` 📁 输出目录: ${targetDir}`);
}
module.exports = { runFormatTool };

62
photo-toolkit/index.js Executable file
View File

@ -0,0 +1,62 @@
#!/usr/bin/env node
const { createInterface, showMenu } = require('./utils');
const { runFormatTool } = require('./format');
const { runRecoverExifTool } = require('./recover-exif');
async function main() {
const rl = createInterface();
console.log('\n🎨 图片工具箱');
console.log('=' .repeat(50));
console.log('欢迎使用图片处理工具集合!\n');
try {
while (true) {
const options = [
'📸 图片格式转换 (压缩并调整尺寸)',
'🔧 EXIF 信息恢复 (从原始图片恢复元数据)',
'❌ 退出'
];
const choice = await showMenu(rl, '\n请选择功能', options);
if (choice === -1) {
console.log('❌ 无效的选择,请重新输入');
continue;
}
if (choice === 0) {
// 图片格式转换
await runFormatTool(rl);
} else if (choice === 1) {
// EXIF 信息恢复
await runRecoverExifTool(rl);
} else if (choice === 2) {
// 退出
console.log('\n👋 再见!');
break;
}
// 询问是否继续
const continueChoice = await new Promise((resolve) => {
rl.question('\n是否继续使用其他功能? (y/n): ', resolve);
});
if (continueChoice.toLowerCase() !== 'y' && continueChoice.toLowerCase() !== 'yes') {
console.log('\n👋 再见!');
break;
}
}
} catch (error) {
console.error('\n❌ 发生错误:', error.message);
} finally {
rl.close();
}
}
// 运行主程序
main().catch(error => {
console.error('❌ 程序异常:', error);
process.exit(1);
});

View File

@ -0,0 +1,208 @@
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { question, normalizeBasename } = require('./utils');
// 转换 PNG 为 JPG
function convertPngToJpg(pngFile, targetDir, succeedDir) {
const filename = path.basename(pngFile);
const baseNoExt = path.basename(pngFile, path.extname(pngFile));
const baseFilename = normalizeBasename(baseNoExt);
const jpgFile = path.join(targetDir, `${baseFilename}.jpg`);
const succeedJpgFile = path.join(succeedDir, `${baseFilename}.jpg`);
// 如果当前目录或 succeed 目录下同名 JPG 文件已存在,则跳过
if (fs.existsSync(jpgFile) || fs.existsSync(succeedJpgFile)) {
console.log(`⏭️ ${jpgFile}${succeedJpgFile} 已存在,跳过 PNG 转换`);
return null;
}
try {
execSync(`convert "${pngFile}" -quality 100 "${jpgFile}"`, { stdio: 'inherit' });
console.log(`${filename} 已转换为 JPG: ${jpgFile}`);
return jpgFile;
} catch (error) {
console.error(`❌ PNG 转换失败: ${filename}`);
return null;
}
}
// 查找源文件
function findSourceFile(sourceDir, baseFilename, originalFilename) {
// 先尝试精确匹配
const exactMatch = path.join(sourceDir, originalFilename);
if (fs.existsSync(exactMatch)) {
return exactMatch;
}
// 尝试常见扩展名
const extensions = ['jpg', 'jpeg', 'JPG', 'JPEG', 'heic', 'heif', 'HIF', 'png', 'PNG', 'tif', 'tiff'];
for (const ext of extensions) {
const candidate = path.join(sourceDir, `${baseFilename}.${ext}`);
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
// 恢复 EXIF 信息
function recoverExif(targetFile, sourceFile, succeedDir) {
const filename = path.basename(targetFile);
const ext = path.extname(filename);
const baseNoExt = path.basename(filename, ext);
let succeedFile = path.join(succeedDir, filename);
// 检查 succeed 目录下是否已经存在同名文件,如果存在则添加时间戳
if (fs.existsSync(succeedFile)) {
const timestamp = Date.now();
const newFilename = `${baseNoExt}_${timestamp}${ext}`;
succeedFile = path.join(succeedDir, newFilename);
console.log(`⚠️ ${filename} 在 succeed 目录下已存在,重命名为 ${newFilename}`);
}
try {
// 使用 exiftool 复制 EXIF 信息
execSync(
`exiftool -overwrite_original -all= -UserComment= -tagsFromFile "${sourceFile}" "${targetFile}"`,
{ stdio: 'inherit' }
);
// 移动到 succeed 文件夹
fs.renameSync(targetFile, succeedFile);
console.log(`✅ 匹配成功:从 ${path.basename(sourceFile)} 复制到 ${path.basename(succeedFile)},已移动到 succeed 文件夹`);
return true;
} catch (error) {
console.error(`❌ EXIF 恢复失败: ${filename}`);
return false;
}
}
// 处理大写 JPG 文件(去除 GPS 信息)
function processUppercaseJpg(targetFile) {
try {
execSync(
`exiftool -overwrite_original -UserComment= -gps:all= -artist="奇趣保罗" -CreatorTool= "${targetFile}"`,
{ stdio: 'inherit' }
);
return true;
} catch (error) {
console.error(`❌ 处理失败: ${path.basename(targetFile)}`);
return false;
}
}
// EXIF 恢复主函数
async function runRecoverExifTool(rl) {
console.log('\n🔧 EXIF 信息恢复工具');
console.log('=' .repeat(50));
// 获取源文件夹(原始 EXIF
const sourceInput = await question(rl, '\n📁 请输入源文件夹路径 (包含原始 EXIF 的图片): ');
const sourceDir = sourceInput.trim();
if (!sourceDir || !fs.existsSync(sourceDir)) {
console.error(`❌ 源文件夹不存在: ${sourceDir}`);
return;
}
// 获取目标文件夹(被修改的)
const targetInput = await question(rl, '📁 请输入目标文件夹路径 (被修改的图片): ');
const targetDir = targetInput.trim();
if (!targetDir || !fs.existsSync(targetDir)) {
console.error(`❌ 目标文件夹不存在: ${targetDir}`);
return;
}
// 创建 succeed 目录
const succeedDir = path.join(targetDir, 'succeed');
if (!fs.existsSync(succeedDir)) {
fs.mkdirSync(succeedDir, { recursive: true });
console.log(`✅ 已创建 succeed 目录: ${succeedDir}`);
}
console.log('\n🚀 开始处理...\n');
let stats = {
pngConverted: 0,
exifRecovered: 0,
exifFailed: 0,
uppercaseProcessed: 0
};
// 第一步:处理 PNG 文件
console.log('📋 步骤 1: 转换 PNG 文件为 JPG\n');
const pngFiles = fs.readdirSync(targetDir).filter(file => {
const ext = path.extname(file).toLowerCase();
const fullPath = path.join(targetDir, file);
return (ext === '.png') && fs.statSync(fullPath).isFile() && !fullPath.includes('/succeed/');
});
for (const pngFile of pngFiles) {
const fullPath = path.join(targetDir, pngFile);
const result = convertPngToJpg(fullPath, targetDir, succeedDir);
if (result) {
stats.pngConverted++;
}
}
// 第二步:恢复 EXIF 信息
console.log('\n📋 步骤 2: 恢复 EXIF 信息\n');
const jpgFiles = fs.readdirSync(targetDir).filter(file => {
const ext = path.extname(file).toLowerCase();
const fullPath = path.join(targetDir, file);
return (ext === '.jpg' || ext === '.jpeg') && fs.statSync(fullPath).isFile() && !fullPath.includes('/succeed/');
});
for (const jpgFile of jpgFiles) {
const fullPath = path.join(targetDir, jpgFile);
const filename = path.basename(jpgFile);
const baseNoExt = path.basename(jpgFile, path.extname(jpgFile));
const baseFilename = normalizeBasename(baseNoExt);
// 查找源文件
const sourceFile = findSourceFile(sourceDir, baseFilename, filename);
if (sourceFile) {
const result = recoverExif(fullPath, sourceFile, succeedDir);
if (result) {
stats.exifRecovered++;
} else {
stats.exifFailed++;
}
} else {
console.log(`⚠️ ${filename} 未找到对应源文件(尝试的基名:${baseFilename}),保留在原位置`);
stats.exifFailed++;
}
}
// 第三步:处理大写 JPG 文件
console.log('\n📋 步骤 3: 处理大写 JPG 文件(去除 GPS 信息)\n');
const uppercaseJpgFiles = fs.readdirSync(targetDir).filter(file => {
const ext = path.extname(file);
const fullPath = path.join(targetDir, file);
return ext === '.JPG' && fs.statSync(fullPath).isFile() && !fullPath.includes('/succeed/');
});
for (const jpgFile of uppercaseJpgFiles) {
const fullPath = path.join(targetDir, jpgFile);
const result = processUppercaseJpg(fullPath);
if (result) {
stats.uppercaseProcessed++;
}
}
// 显示统计
console.log('\n' + '='.repeat(50));
console.log('📊 处理完成!');
console.log(` 🔄 PNG 转换: ${stats.pngConverted}`);
console.log(` ✅ EXIF 恢复成功: ${stats.exifRecovered}`);
console.log(` ❌ EXIF 恢复失败: ${stats.exifFailed}`);
console.log(` 🔧 大写 JPG 处理: ${stats.uppercaseProcessed}`);
console.log(` 📁 成功文件目录: ${succeedDir}`);
}
module.exports = { runRecoverExifTool };

153
photo-toolkit/utils.js Normal file
View File

@ -0,0 +1,153 @@
const path = require('path');
const { execSync } = require('child_process');
const readline = require('readline');
const IMAGE_TYPES = {
IPHONE: 'iphone',
SONY: 'sony',
SCREENSHOT: 'screenshot',
};
const IMAGE_TYPE_LABELS = {
[IMAGE_TYPES.IPHONE]: '📱 iPhone (4:3)',
[IMAGE_TYPES.SONY]: '📷 索尼相机 (3:2)',
[IMAGE_TYPES.SCREENSHOT]: '🖼️ 截图 → WebP',
};
// 创建 readline 接口
function createInterface() {
return readline.createInterface({
input: process.stdin,
output: process.stdout
});
}
// 提问函数
function question(rl, prompt) {
return new Promise((resolve) => {
rl.question(prompt, resolve);
});
}
// 获取图片尺寸
function getImageDimensions(filePath) {
try {
const width = execSync(`identify -format "%w" "${filePath}"`, { encoding: 'utf-8' }).trim();
const height = execSync(`identify -format "%h" "${filePath}"`, { encoding: 'utf-8' }).trim();
return { width: parseInt(width), height: parseInt(height) };
} catch (error) {
console.error(`获取图片尺寸失败: ${filePath}`);
throw error;
}
}
// 读取 EXIF 中的 Make / Model
function getExifMakeModel(filePath) {
try {
const output = execSync(`exiftool -Make -Model -s3 "${filePath}"`, { encoding: 'utf-8' }).trim();
const [make = '', model = ''] = output.split('\n');
return { make: make.trim(), model: model.trim() };
} catch {
return { make: '', model: '' };
}
}
// 根据宽高比推断 iPhone (4:3) 或索尼 (3:2)
function detectTypeFromAspectRatio(width, height) {
const ratio = Math.max(width, height) / Math.min(width, height);
const diff43 = Math.abs(ratio - 4 / 3);
const diff32 = Math.abs(ratio - 3 / 2);
return diff43 <= diff32 ? IMAGE_TYPES.IPHONE : IMAGE_TYPES.SONY;
}
// 检测图片类型iPhone / 索尼相机 / 截图
function detectImageType(filePath, dimensions) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.png') {
return { type: IMAGE_TYPES.SCREENSHOT, reason: 'PNG 格式' };
}
const { make, model } = getExifMakeModel(filePath);
const makeUpper = make.toUpperCase();
const modelUpper = model.toUpperCase();
if (makeUpper === 'APPLE' || modelUpper.includes('IPHONE')) {
return { type: IMAGE_TYPES.IPHONE, reason: `EXIF: ${make} ${model}`.trim() };
}
if (makeUpper.includes('SONY') || modelUpper.includes('ILCE') || modelUpper.startsWith('DSC-')) {
return { type: IMAGE_TYPES.SONY, reason: `EXIF: ${make} ${model}`.trim() };
}
if (['.heic', '.heif'].includes(ext)) {
return { type: IMAGE_TYPES.IPHONE, reason: 'HEIC/HEIF 格式' };
}
const { width, height } = dimensions;
const type = detectTypeFromAspectRatio(width, height);
return { type, reason: `宽高比推断 (${width}x${height})` };
}
// 根据类型和方向获取目标尺寸,截图为 null不裁剪
function getTargetSize(type, width, height) {
const landscape = width >= height;
switch (type) {
case IMAGE_TYPES.IPHONE:
return landscape ? '2000x1500' : '1500x2000';
case IMAGE_TYPES.SONY:
return landscape ? '2250x1500' : '1500x2250';
case IMAGE_TYPES.SCREENSHOT:
return null;
default:
return landscape ? '2250x1500' : '1500x2250';
}
}
// 获取输出扩展名
function getOutputExtension(type) {
return type === IMAGE_TYPES.SCREENSHOT ? '.webp' : '.jpg';
}
// 规范化文件名(去掉 -Modified 或 _Modified 后缀)
function normalizeBasename(name) {
// 匹配 -Modified 或 _Modified不区分大小写
const match = name.match(/^(.*?)[-_]?modified$/i);
if (match && match[1]) {
return match[1];
}
return name;
}
// 显示菜单并获取选择
async function showMenu(rl, title, options) {
console.log(`\n${title}`);
console.log('='.repeat(50));
options.forEach((option, index) => {
console.log(`${index + 1}. ${option}`);
});
const choice = await question(rl, '\n请选择 (输入数字): ');
const index = parseInt(choice) - 1;
if (index >= 0 && index < options.length) {
return index;
}
return -1;
}
module.exports = {
createInterface,
question,
getImageDimensions,
getExifMakeModel,
detectImageType,
detectTypeFromAspectRatio,
getTargetSize,
getOutputExtension,
normalizeBasename,
showMenu,
IMAGE_TYPES,
IMAGE_TYPE_LABELS,
};