From ae3c753d71ee1565c3b4ec70c077035c7b869e94 Mon Sep 17 00:00:00 2001 From: Jade Ellis Date: Fri, 19 Jul 2024 15:32:14 +0100 Subject: [PATCH] SVG thumbhashes --- packages/vite-plugin-thumbhash-svg/.gitignore | 2 + .../vite-plugin-thumbhash-svg/package.json | 37 +++ .../rollup.config.js | 20 ++ .../vite-plugin-thumbhash-svg/src/index.ts | 260 ++++++++++++++++++ .../vite-plugin-thumbhash-svg/tsconfig.json | 14 + 5 files changed, 333 insertions(+) create mode 100644 packages/vite-plugin-thumbhash-svg/.gitignore create mode 100644 packages/vite-plugin-thumbhash-svg/package.json create mode 100644 packages/vite-plugin-thumbhash-svg/rollup.config.js create mode 100644 packages/vite-plugin-thumbhash-svg/src/index.ts create mode 100644 packages/vite-plugin-thumbhash-svg/tsconfig.json diff --git a/packages/vite-plugin-thumbhash-svg/.gitignore b/packages/vite-plugin-thumbhash-svg/.gitignore new file mode 100644 index 00000000..db4c6d9b --- /dev/null +++ b/packages/vite-plugin-thumbhash-svg/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules \ No newline at end of file diff --git a/packages/vite-plugin-thumbhash-svg/package.json b/packages/vite-plugin-thumbhash-svg/package.json new file mode 100644 index 00000000..aa977bde --- /dev/null +++ b/packages/vite-plugin-thumbhash-svg/package.json @@ -0,0 +1,37 @@ +{ + "name": "vite-plugin-thumbhash-svg", + "version": "1.0.0", + "description": "", + "type": "module", + "main": "dist/index.esm.js", + "exports": { + "import": "./dist/index.esm.js", + "require": "./dist/index.cjs.js", + "types": "./dist/index.d.ts" + }, + "types": "dist/index.d.ts", + "scripts": { + "build": "rollup -c", + "dev": "rollup -c -w", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@rollup/plugin-typescript": "^11.1.6", + "@types/node": "^20.14.2", + "rollup": "^4.18.0", + "rollup-plugin-dts": "^6.1.1", + "vite": "^5.3.1" + }, + "files": [ + "dist" + ], + "dependencies": { + "@napi-rs/canvas": "^0.1.53", + "@resvg/resvg-js": "^2.6.2", + "@rollup/pluginutils": "^5.1.0", + "thumbhash-node": "^0.1.3" + } +} diff --git a/packages/vite-plugin-thumbhash-svg/rollup.config.js b/packages/vite-plugin-thumbhash-svg/rollup.config.js new file mode 100644 index 00000000..e4fda77c --- /dev/null +++ b/packages/vite-plugin-thumbhash-svg/rollup.config.js @@ -0,0 +1,20 @@ +// rollup.config.js +import typescript from '@rollup/plugin-typescript'; +import { dts } from "rollup-plugin-dts"; +import pkg from './package.json' with { type: "json" }; + +export default [ + { + input: 'src/index.ts', + output: [ + { file: pkg.exports.require, format: 'cjs' }, + { file: pkg.exports.import, format: 'es' } + ], + plugins: [typescript()] + }, + { + input: 'src/index.ts', + output: [{ file: pkg.exports.types, format: 'es' }], + plugins: [dts()], + } +]; \ No newline at end of file diff --git a/packages/vite-plugin-thumbhash-svg/src/index.ts b/packages/vite-plugin-thumbhash-svg/src/index.ts new file mode 100644 index 00000000..881158de --- /dev/null +++ b/packages/vite-plugin-thumbhash-svg/src/index.ts @@ -0,0 +1,260 @@ +import { createFilter } from '@rollup/pluginutils' +import { basename } from 'node:path' +import { relative } from 'node:path/posix' +import { readFile, access } from 'node:fs/promises' +import { constants } from 'node:fs' +import { extname } from 'node:path'; + +import { loadImage, createCanvas, ImageData } from '@napi-rs/canvas' +import { Resvg } from '@resvg/resvg-js' +import { rgbaToThumbHash, thumbHashToRGBA } from 'thumbhash-node' +import type { Plugin, ResolvedConfig } from 'vite' + +export type OutputExtension = 'png' | 'jpg' | 'webp' | 'avif' + +export type Options = + | { + include?: Array | string | RegExp + exclude?: Array | string | RegExp + outputExtension?: OutputExtension + } + | undefined + +interface LoaderParams { + thumbSrc: string + thumbWidth: number + thumbHeight: number + originalSrc: string + originalWidth: number + originalHeight: number +} + +const loader = (params: LoaderParams) => { + return `export default ${JSON.stringify(params)}` +} + +async function loadImageAndConvertToRgba(path: string) { + const maxSize = 100 + const imgPath = path + let image; + // console.log(path, extname(path)) + if (extname(path) === ".svg") { + + if (await exists(imgPath)) { + const svg = await readFile(imgPath); + const resvg = new Resvg(svg) + const render = resvg.render() + image = await loadImage(render.asPng()) + } else { + throw new TypeError("Could not resolve image source!") + // Try URI decode path? + // console.log(decodeURIComponent(imgPath)) + // const svg = await readFile(decodeURIComponent(imgPath)); + // const resvg = new Resvg(svg) + // const render = resvg.render() + // image = await loadImage(render.asPng()) + + } + } else { + // canvas handles all file loading for us + image = await loadImage(imgPath) + + } + const width = image.width + const height = image.height + + const scale = maxSize / Math.max(width, height) + const resizedWidth = Math.round(width * scale) + const resizedHeight = Math.round(height * scale) + + const canvas = createCanvas(resizedWidth, resizedHeight) + const ctx = canvas.getContext('2d') + ctx.drawImage(image, 0, 0, resizedWidth, resizedHeight) + + const imageData = ctx.getImageData(0, 0, resizedWidth, resizedHeight) + const rgba = new Uint8Array(imageData.data) + + return { + originalWidth: width, + originalHeight: height, + height: imageData.height, + width: imageData.width, + rgba, + } +} + +const fromRGBAToImageBuffer = ( + rgba: Uint8Array, + mimeType: MimeType, + width: number, + height: number +) => { + const thumb = rgbaToThumbHash(width, height, rgba) + const transformedRgba = thumbHashToRGBA(thumb) + const imageData = new ImageData( + new Uint8ClampedArray(transformedRgba.rgba), + transformedRgba.width, + transformedRgba.height + ) + + const canvas = createCanvas(transformedRgba.width, transformedRgba.height) + const context = canvas.getContext('2d') + //@ts-ignore + context.putImageData(imageData, 0, 0) + //@ts-ignore + const buffer = canvas.toBuffer(mimeType) + + return buffer +} + +type MimeType = 'image/webp' | 'image/jpeg' | 'image/avif' | 'image/png' + +const extToMimeTypeMap: Record = { + avif: 'image/avif', + jpg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', +} + +const isThumbHash = (id: string) => { + return id.endsWith('?th') || id.endsWith('?thumb') +} + +const cleanId = (id: string) => decodeURIComponent(id.replace('?thumb', '').replace('?th', '')) + +const buildViteAsset = (referenceId: string) => `__VITE_ASSET__${referenceId}__` + +const buildDataURL = (buf: Buffer, mimeType: MimeType) => { + const dataPrefix = `data:${mimeType};base64,` + + const dataURL = `${dataPrefix}${buf.toString('base64')}` + + return dataURL +} + +async function exists(path: string) { + try { + await access(path, constants.F_OK) + return true + } catch { + return false + } +} + +const thumbHash = (options: Options = {}): Plugin => { + const { include, exclude, outputExtension = 'png' } = options + + const bufferMimeType = extToMimeTypeMap[outputExtension] + + const filter = createFilter(include, exclude) + + let config: ResolvedConfig + + const devCache = new Map() + + const buildCache = new Map() + + return { + name: 'vite-plugin-thumbhash', + enforce: 'pre', + + configResolved(cfg) { + config = cfg + }, + + + async load(id) { + if (!filter(id)) { + return null + } + + if (id.includes("Design")) { + console.log('raw', id) + } + + if (isThumbHash(id)) { + const cleanedId = cleanId(id) + console.log('clean', cleanedId) + + if (config.command === 'serve') { + if (devCache.has(id)) { + return devCache.get(id) + } + + const { rgba, width, height, originalHeight, originalWidth } = + await loadImageAndConvertToRgba(cleanedId) + + const buffer = fromRGBAToImageBuffer( + rgba, + bufferMimeType, + width, + height + ) + + const dataURL = buildDataURL(buffer, bufferMimeType) + + const loadedSource = loader({ + thumbSrc: dataURL, + thumbWidth: width, + thumbHeight: height, + originalSrc: relative(config.root, cleanedId), + originalWidth: originalWidth, + originalHeight: originalHeight, + }) + + devCache.set(id, loadedSource) + + return loadedSource + } + + if (buildCache.has(id)) { + return buildCache.get(id) + } + + const { rgba, width, height, originalHeight, originalWidth } = + await loadImageAndConvertToRgba(cleanedId) + + const buffer = fromRGBAToImageBuffer( + rgba, + bufferMimeType, + width, + height + ) + + const referenceId = this.emitFile({ + type: 'asset', + name: basename(cleanedId).replace( + /\.(jpg)|(jpeg)|(png)|(webp)|(avif)/g, + `.${outputExtension}` + ), + source: buffer, + }) + + const originalRefId = this.emitFile({ + type: 'asset', + name: basename(cleanedId), + source: await readFile(cleanedId), + }) + + // import.meta.ROLLUP_FILE_URL_ + + const loadedSource = loader({ + thumbSrc: buildViteAsset(referenceId), + thumbWidth: width, + thumbHeight: height, + originalSrc: buildViteAsset(originalRefId), + originalWidth: originalWidth, + originalHeight: originalHeight, + }) + + buildCache.set(id, loadedSource) + + return loadedSource + } + + return null + }, + } +} + +export { thumbHash } \ No newline at end of file diff --git a/packages/vite-plugin-thumbhash-svg/tsconfig.json b/packages/vite-plugin-thumbhash-svg/tsconfig.json new file mode 100644 index 00000000..f6a4a048 --- /dev/null +++ b/packages/vite-plugin-thumbhash-svg/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "esnext", + "strict": true, + "esModuleInterop": true, + "moduleResolution": "node", + "skipLibCheck": true, + "noImplicitAny": true, + "allowJs": true, + "noEmit": true, + "resolveJsonModule": true + } +} \ No newline at end of file