diff --git a/photo-toolkit/format.js b/photo-toolkit/format.js new file mode 100644 index 0000000..c980c80 --- /dev/null +++ b/photo-toolkit/format.js @@ -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 }; diff --git a/photo-toolkit/index.js b/photo-toolkit/index.js new file mode 100755 index 0000000..e6d5898 --- /dev/null +++ b/photo-toolkit/index.js @@ -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); +}); diff --git a/photo-toolkit/recover-exif.js b/photo-toolkit/recover-exif.js new file mode 100644 index 0000000..bc58bf3 --- /dev/null +++ b/photo-toolkit/recover-exif.js @@ -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 }; diff --git a/photo-toolkit/utils.js b/photo-toolkit/utils.js new file mode 100644 index 0000000..2f7624a --- /dev/null +++ b/photo-toolkit/utils.js @@ -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, +};