commit be88621c9e9dd62ef23e68f695a92af3195e9d5c Author: kura Date: Fri Dec 20 23:23:13 2024 +0800 init diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..30f6271 Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0696bfd --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +test/ +output/ +node_modules/ \ No newline at end of file diff --git a/bin/cli.js b/bin/cli.js new file mode 100755 index 0000000..6ea80d8 --- /dev/null +++ b/bin/cli.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node + +import { processImages } from '../index.js'; +import { program } from 'commander'; // 引入 commander +import path from 'path'; +// 添加命令行参数 +program + //中文 + // .option('-i, --input ', '指定输入目录', './') + // .option('-o, --output ', '指定输出目录', './output') + // .option('-q, --quality ', '指定压缩质量 1-100', 60) // 默认质量为60 + // .option('-r, --recursive ', '是否递归处理子目录,递归几层', 0) + // .option('-a, --alpha ', '是否保留透明通道', 1) + // .option('-h, --help', '显示帮助信息') + // .parse(process.argv); + //english + .option('-i, --input ', 'Specify the input directory', './') + .option('-o, --output ', 'Specify the output directory', './') + .option('-q, --quality ', 'Specify the compression quality 1-100', 60) // 默认质量为60 + .option('-r, --recursive ', 'Whether to recursively process subdirectories, how many layers to recurse', 0) + .option('-a, --alpha ', 'Whether to keep the transparent channel', 1) + .option('-h, --help', 'Display help information') + .parse(process.argv); + +const options = program.opts(); // 获取命令行选项 +if (options.help) { + program.help(); +} +//判断参数是否正确 +if (options.quality < 1 || options.quality > 100) { + console.error('Quality parameter must be between 1 and 100'); + process.exit(1); +} +if (options.recursive < 0) { + console.error('Recursive parameter must be greater than 0'); + process.exit(1); +} +if (options.input === '') { + options.input = './'; +} +if (options.output === '') { + options.output = './output'; +} +if (options.alpha === '') { + options.alpha = 1; +} +if (options.recursive === '') { + options.recursive = 0; +} +//recursive最深3层 避免递归过深 出现意外 +if (options.recursive > 5) { + options.recursive = 5; + console.error('depth of recursive is too deep, automatically set to 5'); +} +if (options.alpha != 1 && options.alpha != 0) { + options.alpha = 1; + console.error('alpha parameter error, automatically set to 1, keep alpha'); +} +// 输出目录 加上output +options.output = path.join(options.output, 'output'); +processImages(options); \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..6957c11 --- /dev/null +++ b/index.js @@ -0,0 +1,162 @@ +import { readdir, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; +import { dirname, resolve } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const wasmImports = { + /** @export */ + __assert_fail: (condition, filename, line, func) => { console.log(condition, filename, line, func) }, + /** @export */ + emscripten_resize_heap: (size, old_size) => { console.log(size, old_size) }, + /** @export */ + fd_close: (fd) => { console.log(fd) }, + /** @export */ + fd_seek: (fd, offset, whence) => { console.log(fd, offset, whence) }, + /** @export */ + fd_write: (fd, buf, len, pos) => { console.log(fd, buf, len, pos) }, + emscripten_memcpy_js: (dest, src, len) => { Module.HEAPU8.copyWithin(dest, src, src + len); }, +} +var Module; +// 初始化 WASM 模块 +async function initWasm() { + if (Module) { + return; + } + const wasmBinary = readFileSync(join(__dirname, './wasm/convert_image_to_webp.wasm')); + const info = { + 'env': wasmImports, + 'wasi_snapshot_preview1': wasmImports, + }; + await WebAssembly.instantiate(wasmBinary, info).then(result => { + // console.log('wasmModule ok'); + Module = { + ...result.instance.exports, + HEAPU8: new Uint8Array(result.instance.exports.memory.buffer), + getValue: (ptr) => { return Module.HEAPU8[ptr] }, + }; + // processImages(); + }); + + +} + +export function processImages(options) { + initWasm().then(async () => { + convertCount = 0; + failCount = 0; + await processDirectory(options.input, options.output, options); + //打印结束 + console.log('Compression completed.'); + console.log(`Converted: ${convertCount} Failed: ${failCount}`); + }); +} +//转换个数 +let convertCount = 0; +//失败个数 +let failCount = 0; +// 新增处理目录的递归函数 +function processDirectory(inputDir, outputDir, options, currentDepth = 0) { + return new Promise((resolve, reject) => { + readdir(inputDir, { withFileTypes: true }, async (err, entries) => { + if (err) { + console.log('Failed to scan directory: ' + err); + reject(err); + return; + } + + try { + // 处理子目录 + if (options.recursive && currentDepth < options.recursive) { + const dirPromises = entries + .filter(entry => entry.isDirectory()) + .map(entry => { + const newInputPath = join(inputDir, entry.name); + if (resolve(newInputPath) === resolve(options.output)) { + return Promise.resolve(); + } + const newOutputPath = join(outputDir, entry.name); + return processDirectory(newInputPath, newOutputPath, options, currentDepth + 1); + }); + await Promise.all(dirPromises); + } + + // 处理图片文件 + const imageFiles = entries + .filter(entry => entry.isFile() && /\.(jpg|jpeg|png|gif|tga|bmp|psd|gif|hdr|pic)$/i.test(entry.name)) + .map(entry => entry.name); + + let completedFiles = 0; + let totalFiles = imageFiles.length; + + // 压缩图片文件 + imageFiles.forEach(file => { + const inputPath = join(inputDir, file); + const outputPath = join(outputDir, `${file.split('.')[0]}.webp`); + + try { + // 读取图片文件 + const inputBuffer = readFileSync(inputPath); + + // 转换为 Uint8Array + const inputData = new Uint8Array(inputBuffer); + + // 分配内存 + const outputSizePtr = Module.malloc(4); + const inputDataPtr = Module.malloc(inputData.length); + Module.HEAPU8.set(inputData, inputDataPtr); + + // 调用 WASM 函数进行转换(使用命令行指定的质量) + const webpPtr = Module.convert_image_to_webp( + inputDataPtr, + inputData.length, + 0, + 0, + options.quality, // 使用命令行指定的质量 + outputSizePtr, + options.alpha ? 1 : 0 + ); + + // 获取输出大小 + const outputSize = Module.HEAPU8[outputSizePtr] | + (Module.HEAPU8[outputSizePtr + 1] << 8) | + (Module.HEAPU8[outputSizePtr + 2] << 16) | + (Module.HEAPU8[outputSizePtr + 3] << 24);; + + // 获取 WebP 数据 + const webpData = Buffer.from( + Module.HEAPU8.subarray(webpPtr, webpPtr + outputSize) + ); + + // 释放内存 + Module.free(outputSizePtr); + Module.free(webpPtr); + Module.free(inputDataPtr); + + // 保存文件 + if (!existsSync(outputDir)) { + mkdirSync(outputDir, { recursive: true }); + } + writeFileSync(outputPath, webpData); + + // 更新进度 + completedFiles++; + const originalSize = (inputBuffer.length / 1024).toFixed(2); + const compressedSize = (outputSize / 1024).toFixed(2); + console.log(`Compressed: ${file} ( - ${completedFiles}/${totalFiles})`); + console.log(`Compression ratio: ${originalSize}KB => ${compressedSize}KB =>${((compressedSize / originalSize) * 100).toFixed(2)}%`); + convertCount++; + } catch (error) { + console.error(`Error processing file ${file}:`, error); + failCount++; + } + }); + + resolve(); + } catch (error) { + reject(error); + } + }); + }); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a65fedd --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "img2webp-cli", + "version": "1.0.0", + "description": "Convert images to WebP format using WASM in cli", + "type": "module", + "bin": { + "cp2webp": "./bin/cli.js" + }, + "scripts": { + "test": "node bin/cli.js -i ./test -o ./output", + "goGlobal": "npm link", + "testGlobal": "cp2webp -i /Users/kura/Downloads/PC端图标 -o /Users/kura/Downloads/PC端图标/output -r 2" + }, + "files": [ + "bin", + "wasm" + ], + "keywords": [ + "image", + "webp", + "compression", + "wasm", + "convert", + "cli" + ], + "author": "kuraa", + "license": "MIT", + "dependencies": { + "commander": "^12.1.0" + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..47f84ab --- /dev/null +++ b/readme.md @@ -0,0 +1,78 @@ +# img2webp-cli + +用于将当前目录下所有图像转换为 WebP 格式的命令行工具,带有递归处理子目录与压缩质量选择的功能,使用 WASM 进行处理。 + + +[English](./readme_en.md) +[中文](./readme.md) + +## 安装 + +npm: +```bash +npm install -g img2webp-cli +``` + +yarn: +```bash +yarn global add img2webp-cli +``` + +## 使用 + +* 快捷使用: + +在图片目录下执行: + +```bash +cp2webp +``` + +* 自定义使用: + +```bash +cp2webp -i -o -q -r -a +``` + +### 命令行选项 + +- `-i, --input `: 指定输入目录,默认为当前目录 `./` +- `-o, --output `: 指定输出目录,默认为 `./output` +- `-q, --quality `: 指定压缩质量,范围为 1-100,默认为 60 +- `-r, --recursive `: 是否递归处理子目录,默认为 0(不递归) +- `-a, --alpha `: 是否保留透明通道,1 表示保留,0 表示不保留,默认为 1 +- `-h, --help`: 显示帮助信息 + +## 示例 + +将 `/path/to/input` 目录中的图像转换为 WebP 格式,并将结果输出到 `/path/to/output` 目录: +1. 进入图片目录 +```bash +cd /path/to/input +``` +2. 执行命令 +```bash +cp2webp +``` +or + +任意目录下执行 +```bash +cp2webp -i /path/to/input -o /path/to/output -q 60 -r 1 -a 1 +``` + +## 注意事项 + +- 该工具支持的图像格式包括 JPG, PNG, TGA, BMP, PSD, GIF, HDR, PIC 等常见图片格式(具体支持格式请查看[stb_image](https://github.com/nothings/stb)) +- 默认情况下,输出目录会在指定的输出路径下创建一个名为 `output` 的子目录。 +- 递归处理的最大深度限制为 5 层,避免递归过深,出现意外。 + +## 感谢 + +- [stb_image](https://github.com/nothings/stb) +- [libwebp](https://chromium.googlesource.com/webm/libwebp) +wasm打包了[stb_image](https://github.com/nothings/stb)与[libwebp](https://chromium.googlesource.com/webm/libwebp),感谢作者与团队。 + +## 许可证 + +MIT \ No newline at end of file diff --git a/readme_en.md b/readme_en.md new file mode 100644 index 0000000..e69de29 diff --git a/wasm/convert_image_to_webp.wasm b/wasm/convert_image_to_webp.wasm new file mode 100755 index 0000000..48e06e8 Binary files /dev/null and b/wasm/convert_image_to_webp.wasm differ diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..84a0a67 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +commander@^12.1.0: + version "12.1.0" + resolved "https://registry.npmmirror.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3" + integrity sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==