SVG thumbhashes
This commit is contained in:
parent
e95748330d
commit
ae3c753d71
5 changed files with 333 additions and 0 deletions
2
packages/vite-plugin-thumbhash-svg/.gitignore
vendored
Normal file
2
packages/vite-plugin-thumbhash-svg/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
dist
|
||||
node_modules
|
||||
37
packages/vite-plugin-thumbhash-svg/package.json
Normal file
37
packages/vite-plugin-thumbhash-svg/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
20
packages/vite-plugin-thumbhash-svg/rollup.config.js
Normal file
20
packages/vite-plugin-thumbhash-svg/rollup.config.js
Normal file
|
|
@ -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()],
|
||||
}
|
||||
];
|
||||
260
packages/vite-plugin-thumbhash-svg/src/index.ts
Normal file
260
packages/vite-plugin-thumbhash-svg/src/index.ts
Normal file
|
|
@ -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> | string | RegExp
|
||||
exclude?: Array<string | RegExp> | 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<OutputExtension, MimeType> = {
|
||||
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<string, string>()
|
||||
|
||||
const buildCache = new Map<string, string>()
|
||||
|
||||
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 }
|
||||
14
packages/vite-plugin-thumbhash-svg/tsconfig.json
Normal file
14
packages/vite-plugin-thumbhash-svg/tsconfig.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue