便笺: 基于 sharp 的图片压缩成 webp 脚本

公開日: 2026-03-21 18:08 更新日: 2026-03-23 23:41 1636文字 9 min read

この投稿は「日本語」では表示できません。元の投稿を表示しています。
基于 sharp 的图片批量转换成 webp 脚本

前言

因为需要批量压缩大量的图片,就借助 AI 写了一个基于 sharp 库的图片压缩脚本。

安装依赖

npm install sharp commander

如果运行时出现以下报错,可尝试直接使用 WASM 版本

报错
node scripts/image-convert-to-webp.js           26-03-23 - 23:20:06
/data/data/com.termux/files/home/code/temp/astro-blog/node_modules/.pnpm/sharp@0.34.5/node_modules/sharp/lib/sharp.js:120
  throw new Error(help.join('\n'));
        ^

Error: Could not load the "sharp" module using the android-arm64 runtime
Possible solutions:
- Manually install libvips >= 8.17.3
- Add experimental WebAssembly-based dependencies:
    npm install --cpu=wasm32 sharp
    npm install @img/sharp-wasm32
- Consult the installation documentation:
    See https://sharp.pixelplumbing.com/install
    at Object.<anonymous> (/data/data/com.termux/files/home/code/temp/astro-blog/node_modules/.pnpm/sharp@0.34.5/node_modules/sharp/lib/sharp.js:120:9)
    at Module._compile (node:internal/modules/cjs/loader:1829:14)
    at Module._extensions..js (node:internal/modules/cjs/loader:1969:10)
    at Module.load (node:internal/modules/cjs/loader:1552:32)
    at Module._load (node:internal/modules/cjs/loader:1354:12)
    at wrapModuleLoad (node:internal/modules/cjs/loader:255:19)
    at Module.require (node:internal/modules/cjs/loader:1575:12)
    at require (node:internal/modules/helpers:191:16)
    at Object.<anonymous> (/data/data/com.termux/files/home/code/temp/astro-blog/node_modules/.pnpm/sharp@0.34.5/node_modules/sharp/lib/constructor.js:10:1)
    at Module._compile (node:internal/modules/cjs/loader:1829:14)

Node.js v25.8.1
# 安装 WASM 版 sharp
 pnpm add sharp @img/sharp-wasm32 --cpu=wasm32

使用说明

node scripts/image-convert-to-webp.js -h
Usage: image-convert-to-webp [options]

基于 sharp 的图片批量转换为 Webp 格式的 CLI 工具

Options:
  -V, --version           output the version number
  -i, --input <dir>       输入目录(必填),例如:./images
  -o, --output <dir>      输出目录(必填),例如:./output
  -q, --quality <number>  WebP 质量(0-100) (default: "75")
  -e, --ext <extensions>  支持的文件扩展名,逗号分隔 (default:
                          "jpg,jpeg,png,gif,bmp,tiff,webp")
  -r, --recursive         递归搜索子目录 (default: true)
  -n, --nearLossless      启用近无损压缩 (default: false)
  -d, --debug             启用调试模式
  -h, --help              display help for command

源码

import { program } from 'commander';
import fs from 'fs/promises';
import path from 'path';
import sharp from 'sharp';
import { fileURLToPath } from 'url';

// 获取当前模块的路径
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// 支持的图片格式(可根据需要扩展)
const SUPPORTED_FORMATS = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp'];

// 配置命令行参数
program
  .name('image-convert')
  .description('基于 sharp 的图片批量转换 CLI 工具,默认转换为 WebP 格式')
  .version('1.0.0')
  .requiredOption('-i, --input <dir>', '输入目录(必填),例如:./images')
  .requiredOption('-o, --output <dir>', '输出目录(必填),例如:./output')
  .option('-q, --quality <number>', 'WebP 质量(0-100)', '75')
  .option('-e, --ext <extensions>', '支持的文件扩展名,逗号分隔', SUPPORTED_FORMATS.join(','))
  .option('-r, --recursive', '递归搜索子目录', true)
  .option('-n, --nearLossless', '启用近无损压缩', false)
  .option('-d, --debug', '启用调试模式')
  .parse(process.argv);

// 获取命令行参数
const options = program.opts();
let inputDir = options.input;
let outputDir = options.output;
const quality = parseInt(options.quality, 10);
const debug = options.debug || false;
const recursive = options.recursive;
const nearLossless = options.nearLossless;
let supportedFormats = SUPPORTED_FORMATS;

// 处理自定义扩展名
if (options.ext) {
  supportedFormats = options.ext.split(',').map((ext) => ext.trim().toLowerCase());
  if (debug) {
    console.log(`🔧 使用自定义扩展名: ${supportedFormats.join(', ')}`);
  }
}

// 解析为绝对路径
if (!path.isAbsolute(inputDir)) {
  inputDir = path.resolve(process.cwd(), inputDir);
}
if (!path.isAbsolute(outputDir)) {
  outputDir = path.resolve(process.cwd(), outputDir);
}

if (debug) {
  console.log(`🔧 调试模式已启用`);
  console.log(`🔧 当前工作目录: ${process.cwd()}`);
  console.log(`🔧 输入目录: ${inputDir}`);
  console.log(`🔧 输出目录: ${outputDir}`);
  console.log(`🔧 图片质量: ${quality}`);
  console.log(`🔧 递归搜索: ${recursive}`);
  console.log(`🔧 支持的文件格式: ${supportedFormats.join(', ')}`);
}

// 验证质量参数
if (isNaN(quality) || quality < 0 || quality > 100) {
  console.error('❌ 错误:质量参数必须是 0-100 之间的数字');
  process.exit(1);
}

/**
 * 检查目录是否存在,不存在则创建
 * @param {string} dir 目录路径
 */
async function ensureDirExists(dir) {
  try {
    await fs.access(dir);
  } catch {
    await fs.mkdir(dir, { recursive: true });
    if (debug) {
      console.log(`📁 创建目录:${dir}`);
    }
  }
}

/**
 * 递归列出目录中的所有文件
 * @param {string} dir 目录路径
 * @returns {Promise<string[]>} 文件路径数组
 */
async function listAllFiles(dir) {
  const files = [];

  async function scanDirectory(currentDir) {
    try {
      const items = await fs.readdir(currentDir, { withFileTypes: true });

      for (const item of items) {
        const itemPath = path.join(currentDir, item.name);

        if (item.isDirectory() && recursive) {
          await scanDirectory(itemPath);
        } else if (item.isFile()) {
          files.push(itemPath);
        }
      }
    } catch (error) {
      console.error(`❌ 无法读取目录 ${currentDir}:`, error.message);
    }
  }

  await scanDirectory(dir);
  return files;
}

/**
 * 检查文件是否为支持的图片格式
 * @param {string} filePath 文件路径
 * @returns {boolean} 是否为支持的格式
 */
function isSupportedImage(filePath) {
  const ext = path.extname(filePath).toLowerCase().replace('.', '');
  return supportedFormats.includes(ext);
}

/**
 * 转换单张图片
 * @param {string} filePath 源文件路径
 */
async function convertImage(filePath) {
  try {
    // 1. 计算相对路径(保持原目录结构)
    const relativePath = path.relative(inputDir, filePath);
    const outputFilePath = path.join(outputDir, relativePath);
    // 2. 替换文件后缀为 .webp
    const outputWebpPath = outputFilePath.replace(path.extname(outputFilePath), '.webp');
    // 3. 确保输出目录存在
    await ensureDirExists(path.dirname(outputWebpPath));

    // 4. 使用 sharp 转换图片
    await sharp(filePath)
      .webp({
        quality: quality,
        method: 4,
        sns: 50,
        filterStrength: 60,
        filterSharpness: 0,
        alphaQuality: 100,
        nearLossless: nearLossless,
      })
      .toFile(outputWebpPath);

    console.log(`✅ 转换成功:${path.relative(process.cwd(), filePath)} -> ${path.relative(process.cwd(), outputWebpPath)}`);
  } catch (error) {
    console.error(`❌ 转换失败:${filePath}`, error.message);
  }
}

/**
 * 批量转换目录下的所有图片
 */
async function batchConvert() {
  try {
    // 1. 检查输入目录是否存在
    try {
      await fs.access(inputDir);
      if (debug) {
        console.log(`✅ 输入目录存在: ${inputDir}`);
      }
    } catch (error) {
      console.error(`❌ 错误:输入目录不存在 -> ${inputDir}`);
      console.log(`💡 提示:当前目录是 ${process.cwd()}`);
      console.log(`💡 尝试使用绝对路径或检查路径是否正确`);
      process.exit(1);
    }

    // 2. 确保输出目录存在
    await ensureDirExists(outputDir);

    // 3. 获取目录统计信息
    if (debug) {
      try {
        const stat = await fs.stat(inputDir);
        console.log(`📊 输入目录统计:`);
        console.log(`  路径: ${inputDir}`);
        console.log(`  类型: ${stat.isDirectory() ? '目录' : '文件'}`);
        console.log(`  大小: ${stat.size} 字节`);
        console.log(`  修改时间: ${stat.mtime}`);
      } catch (err) {
        console.log(`⚠️ 无法获取目录统计: ${err.message}`);
      }
    }

    // 4. 列出目录中的所有文件
    if (debug) {
      console.log(`🔍 开始搜索图片文件...`);
    }

    const allFiles = await listAllFiles(inputDir);

    if (debug) {
      console.log(`📁 找到 ${allFiles.length} 个文件(包括非图片)`);
      if (allFiles.length > 0 && allFiles.length <= 10) {
        console.log(`📄 文件列表:`);
        allFiles.forEach((file) => {
          const relPath = path.relative(inputDir, file);
          console.log(`  - ${relPath}`);
        });
      } else if (allFiles.length > 10) {
        console.log(`📄 显示前10个文件:`);
        allFiles.slice(0, 10).forEach((file) => {
          const relPath = path.relative(inputDir, file);
          console.log(`  - ${relPath}`);
        });
        console.log(`  ... 还有 ${allFiles.length - 10} 个文件`);
      }
    }

    // 5. 过滤出支持的图片文件
    const imageFiles = allFiles.filter((file) => isSupportedImage(file));

    if (debug) {
      console.log(`🖼️  找到 ${imageFiles.length} 个支持的图片文件`);
      if (imageFiles.length > 0) {
        console.log(`🖼️  图片文件列表:`);
        imageFiles.forEach((file) => {
          const relPath = path.relative(inputDir, file);
          console.log(`  - ${relPath}`);
        });
      }
    }

    if (imageFiles.length === 0) {
      console.log('❌ 错误:输入目录下未找到支持的图片文件');
      console.log('💡 支持的格式:', supportedFormats.join(', '));
      console.log('💡 检查事项:');
      console.log('   1. 确保目录路径正确');
      console.log('   2. 确保目录中包含图片文件');
      console.log('   3. 检查图片文件扩展名是否正确');
      console.log('   4. 使用 -d 参数启用调试模式查看更多信息');
      console.log('   5. 使用 -e 参数指定自定义扩展名,例如: -e "jpg,png"');

      // 列出目录中的文件类型统计
      if (allFiles.length > 0) {
        const extStats = {};
        allFiles.forEach((file) => {
          const ext = path.extname(file).toLowerCase() || '无扩展名';
          extStats[ext] = (extStats[ext] || 0) + 1;
        });

        console.log('\n📊 目录中文件类型统计:');
        Object.entries(extStats).forEach(([ext, count]) => {
          console.log(`   ${ext}: ${count} 个`);
        });
      }

      return;
    }

    console.log(`🎯 找到 ${imageFiles.length} 张图片,开始转换...`);

    // 6. 批量转换(串行执行,避免占用过多内存)
    let successCount = 0;
    let failCount = 0;

    for (let i = 0; i < imageFiles.length; i++) {
      const file = imageFiles[i];
      if (debug) {
        console.log(`🔄 处理第 ${i + 1}/${imageFiles.length} 张: ${path.basename(file)}`);
      }
      try {
        await convertImage(file);
        successCount++;
      } catch (error) {
        failCount++;
        console.error(`❌ 处理失败: ${file}`, error.message);
      }
    }

    console.log(`\n🎉 图片转换完成!`);
    console.log(`✅ 成功: ${successCount} 张`);
    if (failCount > 0) {
      console.log(`❌ 失败: ${failCount} 张`);
    }
  } catch (error) {
    console.error('❌ 批量转换失败:', error.message);
    if (debug) {
      console.error('错误堆栈:', error.stack);
    }
    process.exit(1);
  }
}

// 启动批量转换
batchConvert();

気に入ったならばコメントを残してくださいね~