diff --git a/playground/src/App.vue b/playground/src/App.vue index 0c06d65..eede6ae 100644 --- a/playground/src/App.vue +++ b/playground/src/App.vue @@ -8,7 +8,7 @@ - + diff --git a/playground/vite.config.ts b/playground/vite.config.ts index 010f3af..3c83967 100644 --- a/playground/vite.config.ts +++ b/playground/vite.config.ts @@ -18,28 +18,5 @@ export default defineConfig({ port: 8451, }, // publicDir: 'base/public', - plugins: [ - vue(), - imagemin({ - compress: { - jpg: { - quality: 10, - }, - jpeg: { - quality: 10, - }, - png: { - quality: 10, - }, - webp: { - quality: 0, - }, - }, - conversion: [ - { from: 'jpeg', to: 'webp' }, - { from: 'jpg', to: 'webp' }, - { from: 'png', to: 'webp' }, - ], - }), - ], + plugins: [vue(), imagemin()], }); diff --git a/src/core/context.ts b/src/core/context.ts index 45f998f..6401d23 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -1,10 +1,11 @@ +/* eslint-disable no-return-await */ /* eslint-disable no-await-in-loop */ import fs from 'node:fs/promises'; import { Buffer } from 'node:buffer'; import { cpus } from 'node:os'; import { performance } from 'node:perf_hooks'; -import { basename, extname, isAbsolute, join, relative, resolve } from 'pathe'; +import { basename, extname, join, resolve } from 'pathe'; import { createFilter } from '@rollup/pluginutils'; import { optimize } from 'svgo'; import chalk from 'chalk'; @@ -14,11 +15,13 @@ import { encodeMap, encodeMapBack } from './encodeMap'; import { exists, filterFile, - generateImageID, + getBundleImageSrc, hasImageFiles, + isSvgFile, isTurnImageType, parseId, transformFileName, + updateCssReferences, } from './utils'; import { defaultOptions } from './compressOptions'; import devalue from './devalue'; @@ -156,17 +159,16 @@ export default class Context { continue; } - // 4. 创建处理任务 const task = async () => { if (!isSvgFile(path)) { return { originFileName: asset.fileName, - result: await this.generateSquooshBundle(imagePool, path), + result: await this.processRasterImage(imagePool, path), }; } return { originFileName: asset.fileName, - result: await this.generateSvgBundle(path), + result: await this.processSvg(path), }; }; @@ -249,7 +251,7 @@ export default class Context { } // squoosh - async generateSquooshBundle(imagePool, item) { + async processRasterImage(imagePool, item) { const start = performance.now(); const size = await fs.lstat(item); const oldSize = size.size; @@ -279,6 +281,8 @@ export default class Context { [type!]: defaultSquooshOptions[type!], }; + console.log(currentType); + try { await image.encode(currentType); } catch (error) { @@ -341,7 +345,8 @@ export default class Context { spinner.stop(); } - async generateSvgBundle(item) { + async processSvg(item) { + const { assetsDir, outDir,base: configBase } = this.config; const svgCode = await fs.readFile(item, 'utf8'); const result = optimize(svgCode, { @@ -352,7 +357,6 @@ export default class Context { const generateSrc = getBundleImageSrc(item, this.config.options); const base = basename(item, extname(item)); - const { assetsDir, outDir } = this.config; const imageName = `${base}-${generateSrc}`; const start = performance.now(); const size = await fs.lstat(item); @@ -368,7 +372,7 @@ export default class Context { }; compressSuccess( - join(this.config.base, outDir, svgResult.fileName), + join(configBase, outDir, svgResult.fileName), newSize, oldSize, start, @@ -377,18 +381,6 @@ export default class Context { } } -function getBundleImageSrc(filename: string, options: any) { - const currentType = - options.conversion.find( - (item) => item.from === extname(filename).slice(1), - ) ?? extname(filename).slice(1); - const id = generateImageID( - filename, - currentType.to ?? extname(filename).slice(1), - ); - return id; -} - export function resolveOptions( options: any, configOption: any, @@ -448,24 +440,4 @@ export async function transformCode( ); await fs.writeFile(finallyPath, item[sourceCode]); }); -} - -export function isSvgFile(filename) { - return extname(filename) === '.svg'; -} - -function updateCssReferences( - cssContent: string, - fileNameMap: Map, -): string { - try { - return Array.from(fileNameMap).reduce( - (updatedCssContent, [oldFileName, newFileName]) => - updatedCssContent.replace(new RegExp(oldFileName, 'g'), newFileName), - cssContent, - ); - } catch (error) { - console.error('[unplugin-imagemin] Error processing CSS:', error); - return cssContent; - } -} +} \ No newline at end of file diff --git a/src/core/fileHandler.ts b/src/core/fileHandler.ts new file mode 100644 index 0000000..32a3789 --- /dev/null +++ b/src/core/fileHandler.ts @@ -0,0 +1,2 @@ +export default class FileHandler { +} \ No newline at end of file diff --git a/src/core/imageProcess.ts b/src/core/imageProcess.ts new file mode 100644 index 0000000..acf4915 --- /dev/null +++ b/src/core/imageProcess.ts @@ -0,0 +1,155 @@ +// ImageProcessor.ts +import fs from 'node:fs/promises'; +import { cpus } from 'node:os'; +import { extname, basename, join } from 'pathe'; +import { ImagePool } from 'squoosh'; +import { optimize } from 'svgo'; +import { performance } from 'node:perf_hooks'; +import type { ResolvedOptions } from './types'; +import { isSvgFile, getBundleImageSrc } from './Utils'; + +export default class ImageProcessor { + private imagePool: ImagePool; + private options: ResolvedOptions; + private cache: Cache; + + constructor(options: ResolvedOptions, cache: Cache) { + this.options = options; + this.cache = cache; + this.imagePool = new ImagePool(cpus().length); + } + + // /** + // * 处理图像文件,支持常规图像和 SVG + // * @param filePath 图像文件的路径 + // * @returns 处理后的图像信息 + // */ + async processImage(filePath: string): Promise<{ fileName: string; source: Buffer | string }> { + if (isSvgFile(filePath)) { + return this.processSvg(filePath); + } else { + return this.processRasterImage(filePath); + } + } + + /** + * 处理常规光栅图像(如 PNG、JPEG、WEBP 等) + * @param filePath 图像文件的路径 + * @returns 处理后的图像信息 + */ + private async processRasterImage(filePath: string): Promise<{ fileName: string; source: Buffer }> { + const startTime = performance.now(); + const { ext } = this.getFileInfo(filePath); + + const userConversion = this.options.conversion.find( + (item) => item.from === ext + ); + + const targetType = userConversion?.to ?? ext; + const imageId = getBundleImageSrc(filePath, this.options); + const imageName = `${basename(filePath, extname(filePath))}-${imageId}.${targetType}`; + const outputFilePath = join(this.options.assetsDir, imageName); + + // const fileStat = await fs.stat(filePath); + // const cacheKey = `${filePath}-${targetType}`; + // const isCached = await this.cache.hasCachedAsset(cacheKey, fileStat.mtimeMs); + + // if (isCached) { + // Logger.info(`Using cached image for ${filePath}`); + // const cachedData = await this.cache.getCachedAsset(cacheKey); + // return { fileName: outputFilePath, source: cachedData }; + // } + + const imageBuffer = await fs.readFile(filePath); + const image = this.imagePool.ingestImage(imageBuffer); + + const encodeOptions = this.getEncodeOptions(targetType); + if (!encodeOptions) { + throw new Error(`Unsupported target image type: ${targetType}`); + } + + try { + await image.encode({ [targetType]: encodeOptions }); + } catch (error) { + Logger.error(`Error encoding image ${filePath}`, error as Error); + throw error; + } + + const encodedImage = await image.encodedWith[targetType]; + const newSize = encodedImage.size; + const oldSize = imageBuffer.length; + + // 保存缓存 + await this.cache.setCachedAsset(cacheKey, encodedImage.binary); + + const timeSpent = (performance.now() - startTime).toFixed(2); + // Logger.success( + // `Compressed ${filePath} [${(oldSize / 1024).toFixed(2)} KB -> ${(newSize / 1024).toFixed(2)} KB] in ${timeSpent} ms` + // ); + + return { + fileName: outputFilePath, + source: encodedImage.binary, + }; + } + + private async processSvg(filePath: string): Promise<{ fileName: string; source: string }> { + const startTime = performance.now(); + + const svgContent = await fs.readFile(filePath, 'utf8'); + const result = optimize(svgContent, { + multipass: true, + path: filePath, + }); + + if ('data' in result) { + const optimizedSvg = result.data; + const oldSize = Buffer.byteLength(svgContent, 'utf8'); + const newSize = Buffer.byteLength(optimizedSvg, 'utf8'); + + // await this.cache.setCachedAsset(filePath, optimizedSvg); + + const imageId = getBundleImageSrc(filePath, this.options); + const imageName = `${basename(filePath, '.svg')}-${imageId}.svg`; + const outputFilePath = join(this.options.assetsDir, imageName); + + const timeSpent = (performance.now() - startTime).toFixed(2); + Logger.success( + `Optimized SVG ${filePath} [${(oldSize / 1024).toFixed(2)} KB -> ${(newSize / 1024).toFixed(2)} KB] in ${timeSpent} ms` + ); + + return { + fileName: outputFilePath, + source: optimizedSvg, + }; + } else { + // log(`Error optimizing SVG ${filePath}`, result.error as Error); + throw new Error(`Failed to optimize SVG: ${result.error}`); + } + } + + private getFileInfo(filePath: string): { base: string; ext: string } { + const base = basename(filePath); + const ext = extname(filePath).slice(1); // 去掉扩展名前的点 + return { base, ext }; + } + + private getEncodeOptions(targetType: string): Record | null { + switch (targetType) { + case 'avif': + return this.options.compress.avif; + case 'webp': + return this.options.compress.webp; + case 'mozjpeg': + return this.options.compress.mozjpeg; + case 'oxipng': + return this.options.compress.oxipng; + default: + return null; + } + } + + async close() { + await this.imagePool.close(); + } +} \ No newline at end of file diff --git a/src/core/utils.ts b/src/core/utils.ts index 001e66d..b59b887 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-await-in-loop */ import { partial } from 'filesize'; import { createHash } from 'node:crypto'; import fs, { constants, promises as fsPromise } from 'fs'; @@ -83,7 +84,6 @@ export function isTurnImageType(options) { return Boolean(hasConversion && hasType && isReallyType); } -// 最后一个符号后的内容 export function lastSymbol(string: string, symbol: string) { const arr = string.split(symbol); return arr[arr.length - 1]; @@ -114,7 +114,7 @@ export function generateImageID(filename: string, format: string = 'jpeg') { export function transformFileName(file) { return file.substring(0, file.lastIndexOf('.') + 1); } -// 判断后缀名 + export function filterExtension(name: string, ext: string): boolean { const reg = new RegExp(`.${ext}`); return Boolean(name.match(reg)); @@ -146,17 +146,9 @@ export function filterImageModule(filePath: string) { } // filter public dir path with excludeDir -export function filterDirPath(path, dir, excludeDir?) { - // let regex; - - // if (excludeDir) { - // regex = new RegExp(`${dir}/(?!${excludeDir}/)[\\w.-]+`); - // } else { - // regex = new RegExp(`${dir}/[\\w.-]+`); - // } - +export function filterDirPath(pathe: string, dir: any) { // return regex.test(path); - if (path.startsWith(dir)) { + if (pathe.startsWith(dir)) { return true; } return false; @@ -196,7 +188,7 @@ export async function readImageFiles(dir) { const images = []; try { - const files: any = await fs.promises.readdir(dir); + const files: string[] = await fs.promises.readdir(dir); for (let file of files) { const path2 = `${dir}/${file}`; @@ -213,3 +205,35 @@ export async function readImageFiles(dir) { return images; } + +export function isSvgFile(filename) { + return extname(filename) === '.svg'; +} + +export function getBundleImageSrc(filename: string, options: any) { + const currentType = + options.conversion.find( + (item) => item.from === extname(filename).slice(1), + ) ?? extname(filename).slice(1); + const id = generateImageID( + filename, + currentType.to ?? extname(filename).slice(1), + ); + return id; +} + +export function updateCssReferences( + cssContent: string, + fileNameMap: Map, +): string { + try { + return Array.from(fileNameMap).reduce( + (updatedCssContent, [oldFileName, newFileName]) => + updatedCssContent.replace(new RegExp(oldFileName, 'g'), newFileName), + cssContent, + ); + } catch (error) { + console.error('[unplugin-imagemin] Error processing CSS:', error); + return cssContent; + } +}