From fe7a565eef473c60af29a4083d86284a29fee82b Mon Sep 17 00:00:00 2001 From: EansonLee <804645701@qq.com> Date: Fri, 13 Jun 2025 18:49:27 +0800 Subject: [PATCH 1/2] Update version to 0.4.3. Added WebP image conversion functionality, with support for enabling WebP conversion via command-line arguments, environment variables, and configuration files. Updated package.json to include the new CLI tools and dependencies, and modified related configurations to support the WebP option. --- README.md | 61 +++++ image-converter-usage.zh.md | 106 +++++++++ package.json | 10 +- pnpm-lock.yaml | 322 +++++++++++++++++++++++++ src/cli.ts | 3 +- src/config.ts | 67 ++++++ src/index.ts | 4 + src/mcp.ts | 119 +++++++++- src/utils/imageConverter.ts | 423 +++++++++++++++++++++++++++++++++ src/utils/imageConverterCli.ts | 43 ++++ tsup.config.ts | 7 +- 11 files changed, 1156 insertions(+), 9 deletions(-) create mode 100644 image-converter-usage.zh.md create mode 100644 src/utils/imageConverter.ts create mode 100644 src/utils/imageConverterCli.ts diff --git a/README.md b/README.md index f7c97000..08beec6a 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,67 @@ The `figma-developer-mcp` server can be configured by adding the following to yo Or you can set `FIGMA_API_KEY` and `PORT` in the `env` field. +## WebP Image Conversion + +This server supports automatic conversion of downloaded PNG images to WebP format for better performance and smaller file sizes. WebP typically provides 25-35% smaller file sizes compared to PNG with similar visual quality. + +### Enabling WebP Conversion + +You can enable WebP conversion in several ways: + +#### Command Line Arguments + +```bash +npx figma-developer-mcp --figma-api-key=YOUR-KEY --webp-enabled=true --webp-quality=80 --webp-keep-original=false +``` + +#### Environment Variables + +``` +FIGMA_API_KEY=your-key +WEBP_ENABLED=true +WEBP_QUALITY=80 +WEBP_KEEP_ORIGINAL=false +``` + +#### In MCP Configuration + +```json +{ + "mcpServers": { + "Framelink Figma MCP": { + "command": "npx", + "args": [ + "-y", + "figma-developer-mcp", + "--figma-api-key=YOUR-KEY", + "--webp-enabled=true", + "--webp-quality=80", + "--webp-keep-original=false", + "--stdio" + ] + } + } +} +``` + +### WebP Configuration Options + +| Option | Description | Default | +|--------|-------------|---------| +| `webp-enabled` | Enable WebP conversion | `false` | +| `webp-quality` | WebP compression quality (1-100) | `80` | +| `webp-keep-original` | Keep original PNG files after conversion | `false` | + +### Using the WebP Conversion Tool + +You can also manually convert PNG images to WebP using the `convert_png_to_webp` tool: + +``` +// In your AI client +Please convert the PNG images at [paths] to WebP format for better performance. +``` + If you need more information on how to configure the Framelink Figma MCP server, see the [Framelink docs](https://www.framelink.ai/docs/quickstart?utm_source=github&utm_medium=referral&utm_campaign=readme). ## Star History diff --git a/image-converter-usage.zh.md b/image-converter-usage.zh.md new file mode 100644 index 00000000..09072bc5 --- /dev/null +++ b/image-converter-usage.zh.md @@ -0,0 +1,106 @@ +# WebP 图片转换工具使用指南 +# WebP Image Converter User Guide + +这个工具可以将指定目录中的图片(JPG、PNG、GIF 等)批量转换为 WebP 格式,以减小文件体积并提高加载速度。 +This tool can batch convert images (JPG, PNG, GIF, etc.) in a specified directory to WebP format, reducing file size and improving loading speed. + +## 作为命令行工具使用 +## Using as a Command Line Tool + +安装完成后,可以通过以下命令使用: +After installation, you can use it with the following commands: + +```bash +# 使用 npm 脚本 +# Using npm script +npm run webp -- <目录路径> [选项] + +# 或者使用全局安装的命令 +# Or using globally installed command +webp-converter <目录路径> [选项] +``` + +### 命令行选项 +### Command Line Options + +- `--quality`, `-q` <0-100>: WebP 压缩质量,默认为 80 + WebP compression quality, default is 80 +- `--recursive`, `-r`: 递归处理子目录 + Process subdirectories recursively +- `--no-keep-original`, `-d`: 不保留原始图片 + Don't keep original images +- `--verbose`, `-v`: 显示详细日志 + Display detailed logs +- `--help`, `-h`: 显示帮助信息 + Display help information + +### 示例 +### Examples + +```bash +# 转换 images 目录中的所有图片,质量为 85%,递归处理子目录,显示详细日志 +# Convert all images in the images directory with 85% quality, recursively process subdirectories, and display detailed logs +npm run webp -- ./images -q 85 -r -v + +# 转换 assets/images 目录中的所有图片,不保留原始图片 +# Convert all images in the assets/images directory without keeping original images +npm run webp -- ./assets/images -d +``` + +## 作为 API 使用 +## Using as an API + +在代码中可以直接导入并使用 `convertImagesToWebp` 函数: +You can directly import and use the `convertImagesToWebp` function in your code: + +```typescript +import { convertImagesToWebp } from 'figma-developer-mcp'; + +async function convertImages() { + try { + const stats = await convertImagesToWebp('./images', { + quality: 85, + recursive: true, + keepOriginal: true, + verbose: true + }); + + console.log(`转换完成,共处理 ${stats.totalFiles} 个文件,转换 ${stats.convertedFiles} 个文件`); + console.log(`节省空间: ${(stats.totalSizeBefore - stats.totalSizeAfter) / 1024 / 1024} MB`); + + // English version + console.log(`Conversion completed, processed ${stats.totalFiles} files, converted ${stats.convertedFiles} files`); + console.log(`Saved space: ${(stats.totalSizeBefore - stats.totalSizeAfter) / 1024 / 1024} MB`); + } catch (error) { + console.error('转换失败:', error); + console.error('Conversion failed:', error); + } +} + +convertImages(); +``` + +### 选项说明 +### Options Description + +`convertImagesToWebp` 函数接受以下选项: +The `convertImagesToWebp` function accepts the following options: + +- `quality`: WebP 压缩质量 (0-100),默认为 80 + WebP compression quality (0-100), default is 80 +- `recursive`: 是否递归处理子目录,默认为 false + Whether to process subdirectories recursively, default is false +- `keepOriginal`: 是否保留原始图片,默认为 true + Whether to keep original images, default is true +- `verbose`: 是否显示详细日志,默认为 false + Whether to display detailed logs, default is false + +## 注意事项 +## Notes + +1. 转换后的图片将保存在原目录中,文件名与原图一致,仅扩展名替换为 .webp + Converted images will be saved in the original directory with the same filename, only the extension is replaced with .webp +2. 如果目录中已存在同名的 WebP 文件,该图片将被跳过 + If a WebP file with the same name already exists in the directory, the image will be skipped +3. 使用 `--no-keep-original` 选项时请谨慎,这将删除原始图片文件 + Please be cautious when using the `--no-keep-original` option as it will delete the original image files \ No newline at end of file diff --git a/package.json b/package.json index 3a43ac7c..ca3f35c5 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "figma-developer-mcp", - "version": "0.4.1", + "version": "0.4.3", "description": "Model Context Protocol server for Figma integration", "type": "module", "main": "dist/index.js", "bin": { - "figma-developer-mcp": "dist/cli.js" + "figma-developer-mcp": "dist/cli.js", + "webp-converter": "dist/utils/imageConverterCli.js" }, "files": [ "dist", @@ -33,7 +34,8 @@ "prerelease": "pnpm build", "release": "changeset publish && git push --follow-tags", "pub:release": "pnpm build && npm publish", - "pub:release:beta": "pnpm build && npm publish --tag beta" + "pub:release:beta": "pnpm build && npm publish --tag beta", + "webp": "node dist/utils/imageConverterCli.js" }, "engines": { "node": ">=18.0.0" @@ -60,6 +62,7 @@ "express": "^4.21.2", "js-yaml": "^4.1.0", "remeda": "^2.20.1", + "sharp": "^0.34.2", "yargs": "^17.7.2", "zod": "^3.24.2" }, @@ -70,6 +73,7 @@ "@types/jest": "^29.5.14", "@types/js-yaml": "^4.0.9", "@types/node": "^20.17.0", + "@types/sharp": "^0.32.0", "@typescript-eslint/eslint-plugin": "^8.24.0", "@typescript-eslint/parser": "^8.24.0", "eslint": "^9.20.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdb3cb06..8c74145b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: remeda: specifier: ^2.20.1 version: 2.20.1 + sharp: + specifier: ^0.34.2 + version: 0.34.2 yargs: specifier: ^17.7.2 version: 17.7.2 @@ -57,6 +60,9 @@ importers: '@types/node': specifier: ^20.17.0 version: 20.17.0 + '@types/sharp': + specifier: ^0.32.0 + version: 0.32.0 '@typescript-eslint/eslint-plugin': specifier: ^8.24.0 version: 8.24.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1)(typescript@5.7.3))(eslint@9.20.1)(typescript@5.7.3) @@ -324,6 +330,9 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@emnapi/runtime@1.4.3': + resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==} + '@esbuild/aix-ppc64@0.23.1': resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==} engines: {node: '>=18'} @@ -679,6 +688,135 @@ packages: resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==} engines: {node: '>=18.18'} + '@img/sharp-darwin-arm64@0.34.2': + resolution: {integrity: sha512-OfXHZPppddivUJnqyKoi5YVeHRkkNE2zUFT2gbpKxp/JZCFYEYubnMg+gOp6lWfasPrTS+KPosKqdI+ELYVDtg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.2': + resolution: {integrity: sha512-dYvWqmjU9VxqXmjEtjmvHnGqF8GrVjM2Epj9rJ6BUIXvk8slvNDJbhGFvIoXzkDhrJC2jUxNLz/GUjjvSzfw+g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.1.0': + resolution: {integrity: sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.1.0': + resolution: {integrity: sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.1.0': + resolution: {integrity: sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.1.0': + resolution: {integrity: sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.1.0': + resolution: {integrity: sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.1.0': + resolution: {integrity: sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.1.0': + resolution: {integrity: sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.1.0': + resolution: {integrity: sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.1.0': + resolution: {integrity: sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.2': + resolution: {integrity: sha512-D8n8wgWmPDakc83LORcfJepdOSN6MvWNzzz2ux0MnIbOqdieRZwVYY32zxVx+IFUT8er5KPcyU3XXsn+GzG/0Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.2': + resolution: {integrity: sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.2': + resolution: {integrity: sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.2': + resolution: {integrity: sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.2': + resolution: {integrity: sha512-NEE2vQ6wcxYav1/A22OOxoSOGiKnNmDzCYFOZ949xFmrWZOVII1Bp3NqVVpvj+3UeHMFyN5eP/V5hzViQ5CZNA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.2': + resolution: {integrity: sha512-DOYMrDm5E6/8bm/yQLCWyuDJwUnlevR8xtF8bs+gjZ7cyUNYXiSf/E8Kp0Ss5xasIaXSHzb888V1BE4i1hFhAA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.2': + resolution: {integrity: sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.2': + resolution: {integrity: sha512-cfP/r9FdS63VA5k0xiqaNaEoGxBg9k7uE+RQGzuK9fHt7jib4zAVVseR9LsE4gJcNWgT6APKMNnCcnyOtmSEUQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.2': + resolution: {integrity: sha512-QLjGGvAbj0X/FXl8n1WbtQ6iVBpWU7JO94u/P2M4a8CFYsvQi4GW2mRy/JqkRx0qpBzaOdKJKw8uc930EX2AHw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.2': + resolution: {integrity: sha512-aUdT6zEYtDKCaxkofmmJDJYGCf0+pJg3eU9/oBuqvEeoB9dKI6ZLc/1iLJCTuJQDO4ptntAlkUmHgGjyuobZbw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -838,51 +976,61 @@ packages: resolution: {integrity: sha512-bvXVU42mOVcF4le6XSjscdXjqx8okv4n5vmwgzcmtvFdifQ5U4dXFYaCB87namDRKlUL9ybVtLQ9ztnawaSzvg==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.36.0': resolution: {integrity: sha512-JFIQrDJYrxOnyDQGYkqnNBtjDwTgbasdbUiQvcU8JmGDfValfH1lNpng+4FWlhaVIR4KPkeddYjsVVbmJYvDcg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.36.0': resolution: {integrity: sha512-KqjYVh3oM1bj//5X7k79PSCZ6CvaVzb7Qs7VMWS+SlWB5M8p3FqufLP9VNp4CazJ0CsPDLwVD9r3vX7Ci4J56A==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.36.0': resolution: {integrity: sha512-QiGnhScND+mAAtfHqeT+cB1S9yFnNQ/EwCg5yE3MzoaZZnIV0RV9O5alJAoJKX/sBONVKeZdMfO8QSaWEygMhw==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.36.0': resolution: {integrity: sha512-1ZPyEDWF8phd4FQtTzMh8FQwqzvIjLsl6/84gzUxnMNFBtExBtpL51H67mV9xipuxl1AEAerRBgBwFNpkw8+Lg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.36.0': resolution: {integrity: sha512-VMPMEIUpPFKpPI9GZMhJrtu8rxnp6mJR3ZzQPykq4xc2GmdHj3Q4cA+7avMyegXy4n1v+Qynr9fR88BmyO74tg==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.36.0': resolution: {integrity: sha512-ttE6ayb/kHwNRJGYLpuAvB7SMtOeQnVXEIpMtAvx3kepFQeowVED0n1K9nAdraHUPJ5hydEMxBpIR7o4nrm8uA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.36.0': resolution: {integrity: sha512-4a5gf2jpS0AIe7uBjxDeUMNcFmaRTbNv7NxI5xOCs4lhzsVyGR/0qBXduPnoWf6dGC365saTiwag8hP1imTgag==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.36.0': resolution: {integrity: sha512-5KtoW8UWmwFKQ96aQL3LlRXX16IMwyzMq/jSSVIIyAANiE1doaQsx/KRyhAvpHlPjPiSU/AYX/8m+lQ9VToxFQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.36.0': resolution: {integrity: sha512-sycrYZPrv2ag4OCvaN5js+f01eoZ2U+RmT5as8vhxiFz+kxwlHrsxOwKPSA8WyS+Wc6Epid9QeI/IkQ9NkgYyQ==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.36.0': resolution: {integrity: sha512-qbqt4N7tokFwwSVlWDsjfoHgviS3n/vZ8LK0h1uLG9TYIRuUTJC88E1xb3LM2iqZ/WTqNQjYrtmtGmrmmawB6A==} @@ -998,6 +1146,10 @@ packages: '@types/serve-static@1.15.7': resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==} + '@types/sharp@0.32.0': + resolution: {integrity: sha512-OOi3kL+FZDnPhVzsfD37J88FNeZh6gQsGcLc95NbeURRGvmSjeXiDcyWzF2o3yh/gQAUn2uhh/e+CPCa5nwAxw==} + deprecated: This is a stub types definition. sharp provides its own type definitions, so you do not need this installed. + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -1275,6 +1427,13 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1389,6 +1548,10 @@ packages: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} + detect-libc@2.0.4: + resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} + engines: {node: '>=8'} + detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -1822,6 +1985,9 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-arrayish@0.3.2: + resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} @@ -2537,6 +2703,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.0: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} @@ -2556,6 +2727,10 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.2: + resolution: {integrity: sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2587,6 +2762,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-swizzle@0.2.2: + resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -2766,6 +2944,9 @@ packages: '@swc/wasm': optional: true + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.4.0: resolution: {integrity: sha512-b+eZbPCjz10fRryaAA7C8xlIHnf8VnsaRqydheLIqwG/Mcpfk8Z5zp3HayX7GaTygkigHl5cBUs+IhcySiIexQ==} engines: {node: '>=18'} @@ -3288,6 +3469,11 @@ snapshots: '@jridgewell/trace-mapping': 0.3.9 optional: true + '@emnapi/runtime@1.4.3': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.23.1': optional: true @@ -3496,6 +3682,87 @@ snapshots: '@humanwhocodes/retry@0.4.1': {} + '@img/sharp-darwin-arm64@0.34.2': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.1.0 + optional: true + + '@img/sharp-darwin-x64@0.34.2': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.1.0 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.1.0': + optional: true + + '@img/sharp-libvips-darwin-x64@1.1.0': + optional: true + + '@img/sharp-libvips-linux-arm64@1.1.0': + optional: true + + '@img/sharp-libvips-linux-arm@1.1.0': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.1.0': + optional: true + + '@img/sharp-libvips-linux-s390x@1.1.0': + optional: true + + '@img/sharp-libvips-linux-x64@1.1.0': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.1.0': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.1.0': + optional: true + + '@img/sharp-linux-arm64@0.34.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.1.0 + optional: true + + '@img/sharp-linux-arm@0.34.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.1.0 + optional: true + + '@img/sharp-linux-s390x@0.34.2': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.1.0 + optional: true + + '@img/sharp-linux-x64@0.34.2': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.1.0 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.1.0 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.1.0 + optional: true + + '@img/sharp-wasm32@0.34.2': + dependencies: + '@emnapi/runtime': 1.4.3 + optional: true + + '@img/sharp-win32-arm64@0.34.2': + optional: true + + '@img/sharp-win32-ia32@0.34.2': + optional: true + + '@img/sharp-win32-x64@0.34.2': + optional: true + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -3925,6 +4192,10 @@ snapshots: '@types/node': 20.17.0 '@types/send': 0.17.4 + '@types/sharp@0.32.0': + dependencies: + sharp: 0.34.2 + '@types/stack-utils@2.0.3': {} '@types/yargs-parser@21.0.3': {} @@ -4260,6 +4531,16 @@ snapshots: color-name@1.1.4: {} + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.2 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + commander@4.1.1: {} concat-map@0.0.1: {} @@ -4343,6 +4624,8 @@ snapshots: detect-indent@6.1.0: {} + detect-libc@2.0.4: {} + detect-newline@3.1.0: {} diff-sequences@29.6.3: {} @@ -4894,6 +5177,8 @@ snapshots: is-arrayish@0.2.1: {} + is-arrayish@0.3.2: {} + is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -5699,6 +5984,8 @@ snapshots: semver@7.7.1: {} + semver@7.7.2: {} + send@0.19.0: dependencies: debug: 2.6.9 @@ -5754,6 +6041,34 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.34.2: + dependencies: + color: 4.2.3 + detect-libc: 2.0.4 + semver: 7.7.2 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.2 + '@img/sharp-darwin-x64': 0.34.2 + '@img/sharp-libvips-darwin-arm64': 1.1.0 + '@img/sharp-libvips-darwin-x64': 1.1.0 + '@img/sharp-libvips-linux-arm': 1.1.0 + '@img/sharp-libvips-linux-arm64': 1.1.0 + '@img/sharp-libvips-linux-ppc64': 1.1.0 + '@img/sharp-libvips-linux-s390x': 1.1.0 + '@img/sharp-libvips-linux-x64': 1.1.0 + '@img/sharp-libvips-linuxmusl-arm64': 1.1.0 + '@img/sharp-libvips-linuxmusl-x64': 1.1.0 + '@img/sharp-linux-arm': 0.34.2 + '@img/sharp-linux-arm64': 0.34.2 + '@img/sharp-linux-s390x': 0.34.2 + '@img/sharp-linux-x64': 0.34.2 + '@img/sharp-linuxmusl-arm64': 0.34.2 + '@img/sharp-linuxmusl-x64': 0.34.2 + '@img/sharp-wasm32': 0.34.2 + '@img/sharp-win32-arm64': 0.34.2 + '@img/sharp-win32-ia32': 0.34.2 + '@img/sharp-win32-x64': 0.34.2 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -5792,6 +6107,10 @@ snapshots: signal-exit@4.1.0: {} + simple-swizzle@0.2.2: + dependencies: + is-arrayish: 0.3.2 + sisteransi@1.0.5: {} slash@3.0.0: {} @@ -5961,6 +6280,9 @@ snapshots: yn: 3.1.1 optional: true + tslib@2.8.1: + optional: true + tsup@8.4.0(tsx@4.19.2)(typescript@5.7.3): dependencies: bundle-require: 5.1.0(esbuild@0.25.1) diff --git a/src/cli.ts b/src/cli.ts index 14a1740b..26cb1d19 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,7 +18,8 @@ export async function startServer(): Promise { const server = createServer(config.auth, { isHTTP: !isStdioMode, - outputFormat: config.outputFormat + outputFormat: config.outputFormat, + webp: config.webp }); if (isStdioMode) { diff --git a/src/config.ts b/src/config.ts index 280abccd..c30b01b7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,11 +10,19 @@ interface ServerConfig { auth: FigmaAuthOptions; port: number; outputFormat: "yaml" | "json"; + webp: { + enabled: boolean; + quality: number; + keepOriginal: boolean; + }; configSources: { figmaApiKey: "cli" | "env"; figmaOAuthToken: "cli" | "env" | "none"; port: "cli" | "env" | "default"; outputFormat: "cli" | "env" | "default"; + webpEnabled: "cli" | "env" | "default"; + webpQuality: "cli" | "env" | "default"; + webpKeepOriginal: "cli" | "env" | "default"; }; } @@ -28,6 +36,9 @@ interface CliArgs { "figma-oauth-token"?: string; port?: number; json?: boolean; + "webp-enabled"?: boolean; + "webp-quality"?: number; + "webp-keep-original"?: boolean; } export function getServerConfig(isStdioMode: boolean): ServerConfig { @@ -51,6 +62,21 @@ export function getServerConfig(isStdioMode: boolean): ServerConfig { description: "Output data from tools in JSON format instead of YAML", default: false, }, + "webp-enabled": { + type: "boolean", + description: "Enable WebP conversion for downloaded PNG images", + default: false, + }, + "webp-quality": { + type: "number", + description: "WebP compression quality (1-100)", + default: 80, + }, + "webp-keep-original": { + type: "boolean", + description: "Keep original PNG images after WebP conversion", + default: false, + }, }) .help() .version(process.env.NPM_PACKAGE_VERSION ?? "unknown") @@ -65,11 +91,19 @@ export function getServerConfig(isStdioMode: boolean): ServerConfig { const config: Omit = { port: 3333, outputFormat: "yaml", + webp: { + enabled: false, + quality: 80, + keepOriginal: false, + }, configSources: { figmaApiKey: "env", figmaOAuthToken: "none", port: "default", outputFormat: "default", + webpEnabled: "default", + webpQuality: "default", + webpKeepOriginal: "default", }, }; @@ -111,6 +145,34 @@ export function getServerConfig(isStdioMode: boolean): ServerConfig { config.configSources.outputFormat = "env"; } + // Handle WebP settings + if (argv["webp-enabled"] !== undefined) { + config.webp.enabled = argv["webp-enabled"]; + config.configSources.webpEnabled = "cli"; + } else if (process.env.WEBP_ENABLED) { + config.webp.enabled = process.env.WEBP_ENABLED.toLowerCase() === "true"; + config.configSources.webpEnabled = "env"; + } + + if (argv["webp-quality"] !== undefined) { + config.webp.quality = Math.min(100, Math.max(1, argv["webp-quality"])); + config.configSources.webpQuality = "cli"; + } else if (process.env.WEBP_QUALITY) { + const quality = parseInt(process.env.WEBP_QUALITY, 10); + if (!isNaN(quality) && quality >= 1 && quality <= 100) { + config.webp.quality = quality; + config.configSources.webpQuality = "env"; + } + } + + if (argv["webp-keep-original"] !== undefined) { + config.webp.keepOriginal = argv["webp-keep-original"]; + config.configSources.webpKeepOriginal = "cli"; + } else if (process.env.WEBP_KEEP_ORIGINAL) { + config.webp.keepOriginal = process.env.WEBP_KEEP_ORIGINAL.toLowerCase() === "true"; + config.configSources.webpKeepOriginal = "env"; + } + // Validate configuration if (!auth.figmaApiKey && !auth.figmaOAuthToken) { console.error( @@ -137,6 +199,11 @@ export function getServerConfig(isStdioMode: boolean): ServerConfig { console.log( `- OUTPUT_FORMAT: ${config.outputFormat} (source: ${config.configSources.outputFormat})`, ); + console.log(`- WebP Conversion: ${config.webp.enabled ? "Enabled" : "Disabled"} (source: ${config.configSources.webpEnabled})`); + if (config.webp.enabled) { + console.log(` - Quality: ${config.webp.quality} (source: ${config.configSources.webpQuality})`); + console.log(` - Keep Original: ${config.webp.keepOriginal ? "Yes" : "No"} (source: ${config.configSources.webpKeepOriginal})`); + } console.log(); // Empty line for better readability } diff --git a/src/index.ts b/src/index.ts index 61b9bffd..dcfaf66a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,3 +4,7 @@ export type { SimplifiedDesign } from "./services/simplify-node-response.js"; export type { FigmaService } from "./services/figma.js"; export { getServerConfig } from "./config.js"; export { startServer } from "./cli.js"; + +// Export image conversion utilities +export { convertImagesToWebp } from "./utils/imageConverter.js"; +export type { ConvertToWebpOptions } from "./utils/imageConverter.js"; diff --git a/src/mcp.ts b/src/mcp.ts index fc0bdba2..2dfa3798 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -4,6 +4,7 @@ import { FigmaService, type FigmaAuthOptions } from "./services/figma.js"; import type { SimplifiedDesign } from "./services/simplify-node-response.js"; import yaml from "js-yaml"; import { Logger } from "./utils/logger.js"; +import { convertPngToWebp } from "./utils/imageConverter.js"; const serverInfo = { name: "Figma MCP Server", @@ -13,16 +14,21 @@ const serverInfo = { type CreateServerOptions = { isHTTP?: boolean; outputFormat?: "yaml" | "json"; + webp?: { + enabled: boolean; + quality: number; + keepOriginal: boolean; + }; }; function createServer( authOptions: FigmaAuthOptions, - { isHTTP = false, outputFormat = "yaml" }: CreateServerOptions = {}, + { isHTTP = false, outputFormat = "yaml", webp = { enabled: false, quality: 80, keepOriginal: false } }: CreateServerOptions = {}, ) { const server = new McpServer(serverInfo); // const figmaService = new FigmaService(figmaApiKey); const figmaService = new FigmaService(authOptions); - registerTools(server, figmaService, outputFormat); + registerTools(server, figmaService, outputFormat, webp); Logger.isHTTP = isHTTP; @@ -33,6 +39,7 @@ function registerTools( server: McpServer, figmaService: FigmaService, outputFormat: "yaml" | "json", + webp: { enabled: boolean; quality: number; keepOriginal: boolean }, ): void { // Tool to get file information server.tool( @@ -156,9 +163,17 @@ function registerTools( .optional() .default({}) .describe("Options for SVG export"), + convertToWebp: z + .boolean() + .optional() + .describe("Whether to convert PNG images to WebP format. If not specified, uses the server's default configuration."), }, - async ({ fileKey, nodes, localPath, svgOptions, pngScale }) => { + async ({ fileKey, nodes, localPath, svgOptions, pngScale, convertToWebp }) => { try { + // 确定是否需要转换为WebP + // Determine if WebP conversion is needed + const shouldConvertToWebp = convertToWebp !== undefined ? convertToWebp : webp.enabled; + const imageFills = nodes.filter(({ imageRef }) => !!imageRef) as { nodeId: string; imageRef: string; @@ -188,12 +203,48 @@ function registerTools( // If any download fails, return false const saveSuccess = !downloads.find((success) => !success); + + // 如果启用了WebP转换,并且下载成功,则转换PNG为WebP + // If WebP conversion is enabled and download was successful, convert PNG to WebP + let webpResult = ""; + if (saveSuccess && shouldConvertToWebp) { + try { + // 筛选出PNG图片 + // Filter PNG images + const pngFiles = downloads.filter(path => + typeof path === 'string' && path.toLowerCase().endsWith('.png') + ) as string[]; + + if (pngFiles.length > 0) { + Logger.log(`Converting ${pngFiles.length} PNG images to WebP format`); + + // 转换为WebP + // Convert to WebP + const stats = await convertPngToWebp(pngFiles, { + quality: webp.quality, + keepOriginal: webp.keepOriginal, + verbose: true + }); + + const savedSize = stats.totalSizeBefore - stats.totalSizeAfter; + const compressionRatio = stats.totalSizeBefore > 0 + ? (savedSize / stats.totalSizeBefore * 100).toFixed(2) + : '0'; + + webpResult = ` Additionally, ${stats.convertedFiles}/${pngFiles.length} PNG images were converted to WebP format, saving ${compressionRatio}% space.`; + } + } catch (error) { + Logger.error("WebP conversion failed:", error); + webpResult = " WebP conversion was attempted but failed."; + } + } + return { content: [ { type: "text", text: saveSuccess - ? `Success, ${downloads.length} images downloaded: ${downloads.join(", ")}` + ? `Success, ${downloads.length} images downloaded: ${downloads.join(", ")}${webpResult}` : "Failed", }, ], @@ -207,6 +258,66 @@ function registerTools( } }, ); + + // Tool to convert PNG images to WebP format + server.tool( + "convert_png_to_webp", + "Convert downloaded PNG images to WebP format with compression", + { + imagePaths: z + .string() + .array() + .describe("Array of paths to the PNG images that need to be converted to WebP"), + quality: z + .number() + .positive() + .max(100) + .optional() + .default(80) + .describe("WebP compression quality (1-100). Higher values mean better quality but larger file size. Default is 80."), + keepOriginal: z + .boolean() + .optional() + .default(false) + .describe("Whether to keep the original PNG images after conversion. Default is false (will delete original PNGs)."), + verbose: z + .boolean() + .optional() + .default(false) + .describe("Whether to output detailed logs during conversion. Default is false."), + }, + async ({ imagePaths, quality, keepOriginal, verbose }) => { + try { + Logger.log(`Converting ${imagePaths.length} PNG images to WebP format (quality: ${quality}, keepOriginal: ${keepOriginal})`); + + const stats = await convertPngToWebp(imagePaths, { + quality, + keepOriginal, + verbose + }); + + const savedSize = stats.totalSizeBefore - stats.totalSizeAfter; + const compressionRatio = stats.totalSizeBefore > 0 + ? (savedSize / stats.totalSizeBefore * 100).toFixed(2) + : '0'; + + return { + content: [ + { + type: "text", + text: `Conversion completed: ${stats.convertedFiles}/${stats.totalFiles} images converted to WebP. ${stats.skippedFiles} skipped, ${stats.errorFiles} errors. Space saved: ${compressionRatio}%` + }, + ], + }; + } catch (error) { + Logger.error(`Error converting PNG images to WebP:`, error); + return { + isError: true, + content: [{ type: "text", text: `Error converting images: ${error}` }], + }; + } + }, + ); } export { createServer }; diff --git a/src/utils/imageConverter.ts b/src/utils/imageConverter.ts new file mode 100644 index 00000000..92db504b --- /dev/null +++ b/src/utils/imageConverter.ts @@ -0,0 +1,423 @@ +import fs from 'fs'; +import path from 'path'; +import sharp from 'sharp'; +import { Logger } from './logger.js'; + +/** + * 支持的图片格式 + * Supported image formats + */ +const SUPPORTED_FORMATS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.tiff', '.avif']; + +/** + * 将图片转换为 WebP 格式的选项 + * Options for converting images to WebP format + */ +export interface ConvertToWebpOptions { + /** + * WebP 压缩质量 (0-100),默认为 80 + * WebP compression quality (0-100), default is 80 + */ + quality?: number; + /** + * 是否递归处理子目录,默认为 false + * Whether to process subdirectories recursively, default is false + */ + recursive?: boolean; + /** + * 是否保留原始图片,默认为 true + * Whether to keep the original images, default is true + */ + keepOriginal?: boolean; + /** + * 是否显示详细日志,默认为 false + * Whether to display detailed logs, default is false + */ + verbose?: boolean; +} + +/** + * 转换结果统计 + * Conversion result statistics + */ +interface ConversionStats { + totalFiles: number; + convertedFiles: number; + skippedFiles: number; + errorFiles: number; + totalSizeBefore: number; + totalSizeAfter: number; +} + +/** + * 将目录中的图片转换为 WebP 格式 + * Convert images in a directory to WebP format + * + * @param directoryPath 要处理的目录路径 (Directory path to process) + * @param options 转换选项 (Conversion options) + * @returns 转换结果统计 (Conversion statistics) + */ +export async function convertImagesToWebp( + directoryPath: string, + options: ConvertToWebpOptions = {} +): Promise { + const { + quality = 80, + recursive = false, + keepOriginal = true, + verbose = false + } = options; + + const stats: ConversionStats = { + totalFiles: 0, + convertedFiles: 0, + skippedFiles: 0, + errorFiles: 0, + totalSizeBefore: 0, + totalSizeAfter: 0 + }; + + if (!fs.existsSync(directoryPath)) { + Logger.error(`目录不存在: ${directoryPath}`); + throw new Error(`Directory does not exist: ${directoryPath}`); + } + + try { + await processDirectory(directoryPath, stats, { quality, recursive, keepOriginal, verbose }); + + // 计算节省的空间和压缩率 + // Calculate saved space and compression ratio + const savedSize = stats.totalSizeBefore - stats.totalSizeAfter; + const compressionRatio = stats.totalSizeBefore > 0 + ? (savedSize / stats.totalSizeBefore * 100).toFixed(2) + : '0'; + + Logger.log(` +转换完成: +- 总文件数: ${stats.totalFiles} +- 已转换: ${stats.convertedFiles} +- 已跳过: ${stats.skippedFiles} +- 错误: ${stats.errorFiles} +- 原始大小: ${formatSize(stats.totalSizeBefore)} +- 转换后大小: ${formatSize(stats.totalSizeAfter)} +- 节省空间: ${formatSize(savedSize)} (${compressionRatio}%) + +Conversion completed: +- Total files: ${stats.totalFiles} +- Converted: ${stats.convertedFiles} +- Skipped: ${stats.skippedFiles} +- Errors: ${stats.errorFiles} +- Original size: ${formatSize(stats.totalSizeBefore)} +- Size after conversion: ${formatSize(stats.totalSizeAfter)} +- Saved space: ${formatSize(savedSize)} (${compressionRatio}%) +`); + + return stats; + } catch (error) { + Logger.error('转换过程中发生错误:', error); + Logger.error('Error during conversion:', error); + throw error; + } +} + +/** + * 处理目录中的图片 + * Process images in a directory + */ +async function processDirectory( + directoryPath: string, + stats: ConversionStats, + options: Required +): Promise { + const { quality, recursive, keepOriginal, verbose } = options; + + const entries = fs.readdirSync(directoryPath, { withFileTypes: true }); + + for (const entry of entries) { + const entryPath = path.join(directoryPath, entry.name); + + if (entry.isDirectory() && recursive) { + // 递归处理子目录 + // Process subdirectories recursively + await processDirectory(entryPath, stats, options); + } else if (entry.isFile()) { + const ext = path.extname(entry.name).toLowerCase(); + + // 检查是否为支持的图片格式 + // Check if it's a supported image format + if (SUPPORTED_FORMATS.includes(ext) && ext !== '.webp') { + stats.totalFiles++; + + // 如果已经有同名的 WebP 文件,则跳过 + // Skip if a WebP file with the same name already exists + const baseName = path.basename(entry.name, ext); + const webpPath = path.join(directoryPath, `${baseName}.webp`); + + if (fs.existsSync(webpPath)) { + if (verbose) { + Logger.log(`跳过 (已存在 WebP): ${entryPath}`); + Logger.log(`Skip (WebP already exists): ${entryPath}`); + } + stats.skippedFiles++; + continue; + } + + try { + // 获取原始文件大小 + // Get original file size + const originalStats = fs.statSync(entryPath); + stats.totalSizeBefore += originalStats.size; + + // 转换图片 + // Convert image + if (verbose) { + Logger.log(`转换中: ${entryPath}`); + Logger.log(`Converting: ${entryPath}`); + } + + await sharp(entryPath) + .webp({ quality }) + .toFile(webpPath); + + // 获取转换后文件大小 + // Get file size after conversion + const webpStats = fs.statSync(webpPath); + stats.totalSizeAfter += webpStats.size; + + // 如果不保留原始图片,则删除 + // Delete original image if not keeping it + if (!keepOriginal) { + fs.unlinkSync(entryPath); + } + + stats.convertedFiles++; + + if (verbose) { + const originalSize = formatSize(originalStats.size); + const webpSize = formatSize(webpStats.size); + const savedPercent = ((originalStats.size - webpStats.size) / originalStats.size * 100).toFixed(2); + Logger.log(`转换成功: ${entryPath} (${originalSize} → ${webpSize}, 节省 ${savedPercent}%)`); + Logger.log(`Conversion successful: ${entryPath} (${originalSize} → ${webpSize}, saved ${savedPercent}%)`); + } + } catch (error) { + stats.errorFiles++; + Logger.error(`转换失败: ${entryPath}`, error); + Logger.error(`Conversion failed: ${entryPath}`, error); + } + } + } + } +} + +/** + * 格式化文件大小显示 + * Format file size for display + */ +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} + +/** + * 从命令行运行图片转换 + * Run image conversion from command line + * @param args 命令行参数 (Command line arguments) + */ +export async function runFromCli(args: string[]): Promise { + // 简单的命令行参数解析 + // Simple command line argument parsing + const directoryPath = args[0]; + + if (!directoryPath) { + Logger.error('请提供目录路径'); + Logger.error('Please provide a directory path'); + process.exit(1); + } + + const options: ConvertToWebpOptions = { + quality: 80, + recursive: false, + keepOriginal: true, + verbose: false + }; + + // 解析选项 + // Parse options + for (let i = 1; i < args.length; i++) { + const arg = args[i].toLowerCase(); + + if (arg === '--quality' || arg === '-q') { + const quality = parseInt(args[++i], 10); + if (!isNaN(quality) && quality >= 0 && quality <= 100) { + options.quality = quality; + } + } else if (arg === '--recursive' || arg === '-r') { + options.recursive = true; + } else if (arg === '--no-keep-original' || arg === '-d') { + options.keepOriginal = false; + } else if (arg === '--verbose' || arg === '-v') { + options.verbose = true; + } + } + + try { + await convertImagesToWebp(directoryPath, options); + } catch (error) { + Logger.error('转换失败:', error); + Logger.error('Conversion failed:', error); + process.exit(1); + } +} + +/** + * 将PNG图片转换为WebP格式 + * Convert PNG images to WebP format + * + * @param imagePaths 要处理的图片路径数组 (Array of image paths to process) + * @param options 转换选项 (Conversion options) + * @returns 转换结果统计 (Conversion statistics) + */ +export async function convertPngToWebp( + imagePaths: string[], + options: ConvertToWebpOptions = {} +): Promise { + const { + quality = 80, + keepOriginal = false, + verbose = false + } = options; + + const stats: ConversionStats = { + totalFiles: 0, + convertedFiles: 0, + skippedFiles: 0, + errorFiles: 0, + totalSizeBefore: 0, + totalSizeAfter: 0 + }; + + if (imagePaths.length === 0) { + Logger.log('没有提供图片路径'); + Logger.log('No image paths provided'); + return stats; + } + + try { + for (const imagePath of imagePaths) { + // 检查文件是否存在 + // Check if file exists + if (!fs.existsSync(imagePath)) { + Logger.error(`文件不存在: ${imagePath}`); + Logger.error(`File does not exist: ${imagePath}`); + stats.errorFiles++; + continue; + } + + // 检查是否为PNG文件 + // Check if it's a PNG file + const ext = path.extname(imagePath).toLowerCase(); + if (ext !== '.png') { + Logger.log(`跳过非PNG文件: ${imagePath}`); + Logger.log(`Skipping non-PNG file: ${imagePath}`); + stats.skippedFiles++; + continue; + } + + stats.totalFiles++; + + // 生成WebP文件路径 + // Generate WebP file path + const dirPath = path.dirname(imagePath); + const baseName = path.basename(imagePath, ext); + const webpPath = path.join(dirPath, `${baseName}.webp`); + + // 如果已经有同名的WebP文件,则跳过 + // Skip if a WebP file with the same name already exists + if (fs.existsSync(webpPath)) { + if (verbose) { + Logger.log(`跳过 (已存在WebP): ${imagePath}`); + Logger.log(`Skip (WebP already exists): ${imagePath}`); + } + stats.skippedFiles++; + continue; + } + + try { + // 获取原始文件大小 + // Get original file size + const originalStats = fs.statSync(imagePath); + stats.totalSizeBefore += originalStats.size; + + // 转换图片 + // Convert image + if (verbose) { + Logger.log(`转换中: ${imagePath}`); + Logger.log(`Converting: ${imagePath}`); + } + + await sharp(imagePath) + .webp({ quality }) + .toFile(webpPath); + + // 获取转换后文件大小 + // Get file size after conversion + const webpStats = fs.statSync(webpPath); + stats.totalSizeAfter += webpStats.size; + + // 如果不保留原始图片,则删除 + // Delete original image if not keeping it + if (!keepOriginal) { + fs.unlinkSync(imagePath); + } + + stats.convertedFiles++; + + if (verbose) { + const originalSize = formatSize(originalStats.size); + const webpSize = formatSize(webpStats.size); + const savedPercent = ((originalStats.size - webpStats.size) / originalStats.size * 100).toFixed(2); + Logger.log(`转换成功: ${imagePath} (${originalSize} → ${webpSize}, 节省 ${savedPercent}%)`); + Logger.log(`Conversion successful: ${imagePath} (${originalSize} → ${webpSize}, saved ${savedPercent}%)`); + } + } catch (error) { + stats.errorFiles++; + Logger.error(`转换失败: ${imagePath}`, error); + Logger.error(`Conversion failed: ${imagePath}`, error); + } + } + + // 计算节省的空间和压缩率 + // Calculate saved space and compression ratio + const savedSize = stats.totalSizeBefore - stats.totalSizeAfter; + const compressionRatio = stats.totalSizeBefore > 0 + ? (savedSize / stats.totalSizeBefore * 100).toFixed(2) + : '0'; + + Logger.log(` +转换完成: +- 总文件数: ${stats.totalFiles} +- 已转换: ${stats.convertedFiles} +- 已跳过: ${stats.skippedFiles} +- 错误: ${stats.errorFiles} +- 原始大小: ${formatSize(stats.totalSizeBefore)} +- 转换后大小: ${formatSize(stats.totalSizeAfter)} +- 节省空间: ${formatSize(savedSize)} (${compressionRatio}%) + +Conversion completed: +- Total files: ${stats.totalFiles} +- Converted: ${stats.convertedFiles} +- Skipped: ${stats.skippedFiles} +- Errors: ${stats.errorFiles} +- Original size: ${formatSize(stats.totalSizeBefore)} +- Size after conversion: ${formatSize(stats.totalSizeAfter)} +- Saved space: ${formatSize(savedSize)} (${compressionRatio}%) +`); + + return stats; + } catch (error) { + Logger.error('转换过程中发生错误:', error); + Logger.error('Error during conversion:', error); + throw error; + } +} \ No newline at end of file diff --git a/src/utils/imageConverterCli.ts b/src/utils/imageConverterCli.ts new file mode 100644 index 00000000..86d0c624 --- /dev/null +++ b/src/utils/imageConverterCli.ts @@ -0,0 +1,43 @@ +#!/usr/bin/env node + +import { runFromCli } from './imageConverter.js'; + +// 从命令行参数中获取参数,排除前两个参数(node 和脚本路径) +// Get command line arguments, excluding the first two parameters (node and script path) +const args = process.argv.slice(2); + +// 显示帮助信息 +// Display help information +if (args.includes('--help') || args.includes('-h') || args.length === 0) { + console.log(` +图片转 WebP 工具 - 将图片批量转换为 WebP 格式 +WebP Image Converter - Batch convert images to WebP format + +用法 (Usage): + node imageConverterCli.js <目录路径 (directory path)> [选项 (options)] + +选项 (Options): + --quality, -q <0-100> WebP 压缩质量,默认为 80 + WebP compression quality, default is 80 + --recursive, -r 递归处理子目录 + Process subdirectories recursively + --no-keep-original, -d 不保留原始图片 + Don't keep original images + --verbose, -v 显示详细日志 + Display detailed logs + --help, -h 显示此帮助信息 + Display this help information + +示例 (Examples): + node imageConverterCli.js ./images -q 85 -r -v + `); + process.exit(0); +} + +// 运行转换 +// Run conversion +runFromCli(args).catch(error => { + console.error('执行失败:', error); + console.error('Execution failed:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/tsup.config.ts b/tsup.config.ts index bd543579..bc9218e6 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -5,7 +5,12 @@ const packageVersion = process.env.npm_package_version; export default defineConfig({ clean: true, - entry: ["src/index.ts", "src/cli.ts"], + entry: [ + "src/index.ts", + "src/cli.ts", + "src/utils/imageConverter.ts", + "src/utils/imageConverterCli.ts" + ], format: ["esm"], minify: !isDev, target: "esnext", From 5e9f430fbac48de12f596dbf8aa0663a2014e40d Mon Sep 17 00:00:00 2001 From: EansonLee <804645701@qq.com> Date: Fri, 13 Jun 2025 18:55:47 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dconfig.ts=E4=B8=ADenvFile?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=BA=90=E7=9A=84=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/config.ts b/src/config.ts index fc049aec..773bf5ce 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,6 +21,7 @@ interface ServerConfig { webpEnabled: "cli" | "env" | "default"; webpQuality: "cli" | "env" | "default"; webpKeepOriginal: "cli" | "env" | "default"; + envFile: "cli" | "default"; }; } @@ -122,6 +123,7 @@ export function getServerConfig(isStdioMode: boolean): ServerConfig { webpEnabled: "default", webpQuality: "default", webpKeepOriginal: "default", + envFile: envFileSource, }, };