UNPKG

photo-watermark-cli

Version:

A modern TypeScript CLI tool to add timestamp watermarks to photos with intelligent size scaling

440 lines 17.8 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.extractPhotoDate = extractPhotoDate; exports.formatDate = formatDate; exports.calculateRelativeFontSize = calculateRelativeFontSize; exports.calculateRelativePadding = calculateRelativePadding; exports.addWatermarkToImage = addWatermarkToImage; exports.adjustImageBrightness = adjustImageBrightness; exports.processBrightnessOnly = processBrightnessOnly; exports.processDirectory = processDirectory; const sharp_1 = __importDefault(require("sharp")); const fs_1 = require("fs"); const path_1 = require("path"); const exif_reader_1 = __importDefault(require("exif-reader")); const scanner_1 = require("./scanner"); const ora_1 = __importDefault(require("ora")); const chalk_1 = __importDefault(require("chalk")); const moment_1 = __importDefault(require("moment")); /** * 从图片中提取拍摄时间 */ async function extractPhotoDate(imagePath) { try { const image = (0, sharp_1.default)(imagePath); const metadata = await image.metadata(); if (metadata.exif) { const exif = (0, exif_reader_1.default)(metadata.exif); // 尝试从不同的EXIF字段获取日期 const dateFields = [ exif.exif?.DateTimeOriginal, exif.exif?.DateTime, exif.exif?.DateTimeDigitized ]; for (const dateField of dateFields) { if (dateField) { return new Date(dateField); } } } // 如果没有EXIF信息,使用文件修改时间 const fileStats = await fs_1.promises.stat(imagePath); return fileStats.mtime; } catch (error) { console.warn(chalk_1.default.yellow(`⚠️ 无法提取 ${(0, path_1.basename)(imagePath)} 的时间信息,使用文件修改时间`)); const fileStats = await fs_1.promises.stat(imagePath); return fileStats.mtime; } } /** * 格式化日期,使用 moment.js 提供更好的格式化支持 */ function formatDate(date, format = 'YYYY-MM-DD HH:mm:ss') { return (0, moment_1.default)(date).format(format); } /** * 计算水印位置 */ function calculateWatermarkPosition(imageWidth, imageHeight, textWidth, textHeight, position, padding = 20) { switch (position) { case 'bottom-left': return { x: padding, y: imageHeight - textHeight - padding }; case 'bottom-right': return { x: imageWidth - textWidth - padding, y: imageHeight - textHeight - padding }; case 'top-left': return { x: padding, y: padding }; case 'top-right': return { x: imageWidth - textWidth - padding, y: padding }; default: return { x: padding, y: imageHeight - textHeight - padding }; } } /** * 计算相对于图片尺寸的字体大小 */ function calculateRelativeFontSize(imageWidth, imageHeight, baseFontSize = 24, baseResolution = 1920) { // 计算图片的较小边作为参考 const minDimension = Math.min(imageWidth, imageHeight); // 根据比例调整字体大小,确保在不同分辨率下保持视觉一致性 const scaleFactor = minDimension / baseResolution; // 设置最小和最大字体大小限制 const minFontSize = 12; const maxFontSize = 200; const calculatedSize = Math.round(baseFontSize * scaleFactor); return Math.max(minFontSize, Math.min(maxFontSize, calculatedSize)); } /** * 计算相对于图片尺寸的边距 */ function calculateRelativePadding(imageWidth, imageHeight, basePadding = 20) { const minDimension = Math.min(imageWidth, imageHeight); const scaleFactor = minDimension / 1920; const minPadding = 10; const maxPadding = 50; const calculatedPadding = Math.round(basePadding * scaleFactor); return Math.max(minPadding, Math.min(maxPadding, calculatedPadding)); } /** * 为单张图片添加水印 */ async function addWatermarkToImage(inputPath, outputPath, config) { try { const { timeFormat = 'YYYY-MM-DD HH:mm:ss', position = 'bottom-left', fontSize = 24, fontColor = 'white', addShadow = true, quality = 95, brightness = 1.0 } = config; // 提取图片拍摄时间 const photoDate = await extractPhotoDate(inputPath); const timeText = formatDate(photoDate, timeFormat); // 获取图片信息 const image = (0, sharp_1.default)(inputPath); const metadata = await image.metadata(); if (!metadata.width || !metadata.height) { throw new Error('无法获取图片尺寸'); } // 创建图像处理管道,首先应用亮度调整 let processedImage = image; // 如果亮度不是默认值(1.0),则应用亮度调整 if (brightness !== 1.0) { // Sharp 的 modulate 方法中,brightness 值的意义: // 1.0 = 原始亮度,> 1.0 = 增亮,< 1.0 = 变暗 processedImage = processedImage.modulate({ brightness }); } // 计算文字尺寸(估算) const relativeFontSize = calculateRelativeFontSize(metadata.width, metadata.height, fontSize); const textWidth = timeText.length * relativeFontSize * 0.6; const textHeight = relativeFontSize; const padding = calculateRelativePadding(metadata.width, metadata.height); // 计算水印位置 const pos = calculateWatermarkPosition(metadata.width, metadata.height, textWidth, textHeight, position, padding); // 创建文字水印的SVG const shadowOffset = addShadow ? 2 : 0; const textSvg = ` <svg width="${metadata.width}" height="${metadata.height}"> <defs> <style> .watermark-text { font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: ${relativeFontSize}px; font-weight: 600; fill: ${fontColor}; } .watermark-shadow { font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: ${relativeFontSize}px; font-weight: 600; fill: black; opacity: 0.5; } </style> </defs> ${addShadow ? `<text x="${pos.x + shadowOffset}" y="${pos.y + textHeight + shadowOffset}" class="watermark-shadow">${timeText}</text>` : ''} <text x="${pos.x}" y="${pos.y + textHeight}" class="watermark-text">${timeText}</text> </svg> `; // 应用水印,保持原始格式 const outputImage = processedImage.composite([{ input: Buffer.from(textSvg) }]); // 根据原始格式保存 const format = metadata.format; switch (format) { case 'jpeg': await outputImage.jpeg({ quality }).toFile(outputPath); break; case 'png': await outputImage.png().toFile(outputPath); break; case 'webp': await outputImage.webp({ quality }).toFile(outputPath); break; case 'tiff': await outputImage.tiff().toFile(outputPath); break; default: await outputImage.jpeg({ quality }).toFile(outputPath); } } catch (error) { throw new Error(`处理图片 ${(0, path_1.basename)(inputPath)} 失败: ${error.message}`); } } /** * 仅调整图片亮度,不添加水印 */ async function adjustImageBrightness(inputPath, outputPath, brightness = 1.0, quality = 95) { try { // 获取图片信息 const image = (0, sharp_1.default)(inputPath); const metadata = await image.metadata(); if (!metadata.width || !metadata.height) { throw new Error('无法获取图片尺寸'); } // 创建图像处理管道,应用亮度调整 let processedImage = image; // 如果亮度不是默认值(1.0),则应用亮度调整 if (brightness !== 1.0) { processedImage = processedImage.modulate({ brightness }); } // 根据原始格式保存 const format = metadata.format; switch (format) { case 'jpeg': await processedImage.jpeg({ quality }).toFile(outputPath); break; case 'png': await processedImage.png().toFile(outputPath); break; case 'webp': await processedImage.webp({ quality }).toFile(outputPath); break; case 'tiff': await processedImage.tiff().toFile(outputPath); break; default: await processedImage.jpeg({ quality }).toFile(outputPath); } } catch (error) { throw new Error(`调整图片亮度 ${(0, path_1.basename)(inputPath)} 失败: ${error.message}`); } } /** * 处理目录下的所有图片,仅调整亮度 */ async function processBrightnessOnly(options) { const { inputDir, outputDir, config, overwrite = false } = options; // 扫描所有支持的图片 const photos = await (0, scanner_1.scanPhotos)(inputDir); if (photos.length === 0) { console.log(chalk_1.default.yellow('未找到支持的图片文件')); return { success: false, processed: 0, skipped: 0, errors: ['未找到支持的图片文件'] }; } console.log(chalk_1.default.blue(`找到 ${photos.length} 张图片`)); // 显示处理统计信息 const processingStats = await getProcessingStats(photos); console.log(chalk_1.default.gray(`总大小: ${formatFileSize(processingStats.totalSize)}`)); console.log(chalk_1.default.gray(`格式分布: ${Object.entries(processingStats.formats).map(([format, count]) => `${format}(${count})`).join(', ')}`)); // 如果指定了输出目录,创建目录结构 if (outputDir) { await fs_1.promises.mkdir(outputDir, { recursive: true }); } const spinner = (0, ora_1.default)('正在调整图片亮度...').start(); let processed = 0; let skipped = 0; const errors = []; for (const photo of photos) { try { const relativePath = (0, path_1.relative)(inputDir, photo); const outputPath = outputDir ? (0, path_1.join)(outputDir, relativePath) : photo; // 如果输出到不同目录,确保目标目录存在 if (outputDir) { await fs_1.promises.mkdir((0, path_1.dirname)(outputPath), { recursive: true }); } // 检查文件是否已存在且不允许覆盖 if (!overwrite && outputPath !== photo) { try { await fs_1.promises.access(outputPath); skipped++; continue; } catch { // 文件不存在,可以继续 } } await adjustImageBrightness(photo, outputPath, config?.brightness || 1.0, config?.quality || 95); processed++; spinner.text = `正在调整图片亮度... (${processed}/${photos.length})`; } catch (error) { const errorMsg = `${(0, path_1.basename)(photo)}: ${error.message}`; errors.push(errorMsg); console.log(`\n${chalk_1.default.red('❌')} ${errorMsg}`); } } spinner.stop(); const success = errors.length === 0; console.log(chalk_1.default.green(`\n✅ 亮度调整完成!成功: ${processed}, 跳过: ${skipped}, 失败: ${errors.length}`)); if (outputDir) { console.log(chalk_1.default.blue(`输出目录: ${outputDir}`)); } return { success, processed, skipped, errors }; } /** * 处理目录下的所有图片 */ async function processDirectory(options) { const { inputDir, outputDir, config, overwrite = false } = options; // 扫描所有支持的图片 const photos = await (0, scanner_1.scanPhotos)(inputDir); if (photos.length === 0) { console.log(chalk_1.default.yellow('未找到支持的图片文件')); return { success: false, processed: 0, skipped: 0, errors: ['未找到支持的图片文件'] }; } console.log(chalk_1.default.blue(`找到 ${photos.length} 张图片`)); // 显示处理统计信息 const processingStats = await getProcessingStats(photos); console.log(chalk_1.default.gray(`总大小: ${formatFileSize(processingStats.totalSize)}`)); console.log(chalk_1.default.gray(`格式分布: ${Object.entries(processingStats.formats).map(([format, count]) => `${format}(${count})`).join(', ')}`)); console.log(chalk_1.default.gray(`分辨率分布: ${Object.entries(processingStats.resolutions).map(([res, count]) => `${res}(${count})`).join(', ')}`)); // 如果指定了输出目录,创建目录结构 if (outputDir) { await fs_1.promises.mkdir(outputDir, { recursive: true }); } const spinner = (0, ora_1.default)('正在处理图片...').start(); let processed = 0; let skipped = 0; const errors = []; for (const photo of photos) { try { const relativePath = (0, path_1.relative)(inputDir, photo); const outputPath = outputDir ? (0, path_1.join)(outputDir, relativePath) : photo; // 如果输出到不同目录,确保目标目录存在 if (outputDir) { await fs_1.promises.mkdir((0, path_1.dirname)(outputPath), { recursive: true }); } // 检查文件是否已存在且不允许覆盖 if (!overwrite && outputPath !== photo) { try { await fs_1.promises.access(outputPath); skipped++; continue; } catch { // 文件不存在,可以继续 } } await addWatermarkToImage(photo, outputPath, { timeFormat: config?.timeFormat || 'YYYY-MM-DD HH:mm:ss', position: config?.position || 'bottom-left', fontSize: config?.fontSize || 24, fontColor: config?.fontColor || 'white', addShadow: config?.addShadow !== false, quality: config?.quality || 95, brightness: config?.brightness || 1.0 }); processed++; spinner.text = `正在处理图片... (${processed}/${photos.length})`; } catch (error) { const errorMsg = `${(0, path_1.basename)(photo)}: ${error.message}`; errors.push(errorMsg); console.log(`\n${chalk_1.default.red('❌')} ${errorMsg}`); } } spinner.stop(); const success = errors.length === 0; console.log(chalk_1.default.green(`\n✅ 处理完成!成功: ${processed}, 跳过: ${skipped}, 失败: ${errors.length}`)); if (outputDir) { console.log(chalk_1.default.blue(`输出目录: ${outputDir}`)); } // 输出处理统计信息 console.log(chalk_1.default.blue('\n图片处理统计信息:')); console.log(` 总文件数: ${processingStats.totalFiles}`); console.log(` 总大小: ${formatFileSize(processingStats.totalSize)}`); const resolutionList = Object.entries(processingStats.resolutions); if (resolutionList.length > 0) { console.log(' 尺寸分布:'); for (const [resolution, count] of resolutionList) { console.log(` ${resolution}: ${count} 张`); } } const formatList = Object.entries(processingStats.formats); if (formatList.length > 0) { console.log(' 格式分布:'); for (const [format, count] of formatList) { console.log(` ${format}: ${count} 张`); } } return { success, processed, skipped, errors }; } /** * 获取图片处理统计信息 */ async function getProcessingStats(photos) { let totalSize = 0; const resolutions = {}; const formats = {}; for (const photo of photos) { try { const fileStats = await fs_1.promises.stat(photo); totalSize += fileStats.size; const image = (0, sharp_1.default)(photo); const metadata = await image.metadata(); if (metadata.width && metadata.height) { const resolution = `${metadata.width}x${metadata.height}`; resolutions[resolution] = (resolutions[resolution] || 0) + 1; } const format = metadata.format || 'unknown'; formats[format] = (formats[format] || 0) + 1; } catch (error) { // 忽略无法读取的文件 } } return { totalFiles: photos.length, totalSize, resolutions, formats }; } /** * 格式化文件大小 */ function formatFileSize(bytes) { const units = ['B', 'KB', 'MB', 'GB']; let size = bytes; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } return `${size.toFixed(1)} ${units[unitIndex]}`; } //# sourceMappingURL=watermark.js.map