imagetools
imagetools copied to clipboard
Add a low-quality-placeholder directive
This has been a requested feature and one that I'd argue is a wothwhile addition to the library. I'm not quite sure on the specific syntax though and would like to get some feedback!
My proposal would be:
-
Keyword:
lqip
- Type: boolean
Where lqip invokes the following steps:
- resize to a width of 640px
- set quality to 50
The question is wether these values should be customizeable on if so how? An integer that scales the values accordingly? I don't want this directive to do too much since stuff like blurring can and should be added by the user to their liking.
Originally posted by @rchrdnsh in https://github.com/JonasKruckenberg/imagetools/discussions/69
I agree on blurring, that should be handled by the user to fit their desired aesthetic.
I do think both the size and quality should be customizable to an extent (I like the idea of using an integer that scales the values accordingly), but I don't think there's really a need for fine grained controls given lqip would be, ultimately, for the sake of convenience.
If a user needs the ability to highly customize the placeholder image, they should import the placeholder using the provided directives as that's going to give the most granular control in terms of the resulting image.
E.g. I currently import placeholder images, which is passed into a lazy loading responsive image component (along w the srcset imported separately), by doing the following:
import logo from './logo.jpg?w=300&blur=100&quality=30';
Edit: Great work on this library by the way; love using it in with SvelteKit.
+1 I would love this feature.
Great library btw it works great!
E.g. I currently import placeholder images, which is passed into a lazy loading responsive image component (along w the srcset imported separately), by doing the following:
Hey @cryptodeal do you have an example of that component?
There is another style of placeholder which I really like "traced placeholder". It is included with the gatsby-plugin-image
and svelte-image
components.
I created a custom directive using the svelte-image
implementation as a guide:
import { imagetools } from "vite-imagetools";
import { setMetadata } from "imagetools-core";
import potrace from "potrace";
import { promisify } from "util";
import { optimize } from "svgo";
const trace = promisify(potrace.trace);
function svgPlaceholderTransform(config) {
if (!("svgPlaceholder" in config)) return;
return async function (image) {
const svg = await trace(await image.toBuffer(), {
// background: "#fff", // Default is transparent
color: "#002fa7",
threshold: 120,
});
const { data } = optimize(svg, {
multipass: true,
floatPrecision: 0,
datauri: "base64",
});
setMetadata(image, "svgPlaceholder", data);
return image;
};
}
const imagetoolsPlugin = imagetools({
extendTransforms: (builtins) => [svgPlaceholderTransform, ...builtins],
});
export { imagetoolsPlugin };
Some Svelte template
<script lang="ts">
import srcsetWebp from "././photo-1531315630201-bb15abeb1653.jpeg?w=500;700;900;1200&webp&srcset";
import { svgPlaceholder } from "./photo-1531315630201-bb15abeb1653.jpeg?meta&svgPlaceholder";
</script>
<div>
<img src={svgPlaceholder} alt="Testing" />
<picture>
<source srcset={srcsetWebp} type="image/webp" />
<img alt="Thing" />
</picture>
</div>
<style>
img {
width: 300px;
}
div {
display: flex;
}
</style>
Heres the result:
There is additional work to show and hide depending on the loading but you get the idea.
Also I added this to my global.d.ts
for TS support as I plan to use it a lot:
declare module "*?meta&svgPlaceholder" {
const svgPlaceholder: string;
export { svgPlaceholder };
export default { svgPlaceholder };
}
Is this an official feature now? It looks like it was added as a directive by @JonasKruckenberg. Not mentioned in the Docs however.
Would love to see lqip
as an option for the picture
directive's fallback
This is what I've been using for now but would like some help integrating it into the imagetools. It's a combination of @benmccann 's implementation for SvelteKit and svimg's lqip implementation.
The result can be seen here(may require throttling)
svelte.config.js
for the inline src imports
import adapter from '@sveltejs/adapter-cloudflare';
import { vitePreprocess } from '@sveltejs/kit/vite';
import { importAssets } from 'svelte-preprocess-import-assets';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: [
importAssets({
sources: (defaultSources) => {
return [
...defaultSources,
{
tag: 'Image',
srcAttributes: ['src']
}
];
}
}),
vitePreprocess()
],
kit: {
adapter: adapter(),
}
};
export default config;
vite.config.ts
custom transforms for lqip
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
import Icons from 'unplugin-icons/vite';
import { imagetools } from 'vite-imagetools';
import {
setMetadata,
type OutputFormat,
type TransformFactory,
type Picture
} from 'imagetools-core';
import { createPlaceholder } from './placeholder';
const placeholderTransform: TransformFactory = (config) => {
return async function (image) {
if (!('lqip' in config)) return image;
/** @ts-ignore it's a string */
const href = await createPlaceholder(image.options.input.file);
setMetadata(image, 'lqip', href);
return image;
};
};
const pictureProxy = (a: OutputFormat): OutputFormat => {
return function (metadatas) {
const pictureFormat = a(metadatas);
return async function (imageConfig) {
// console.log(imageConfig);
const picture = pictureFormat(imageConfig) as Picture;
return { ...picture, lqip: imageConfig[0].lqip };
};
};
};
export default defineConfig({
plugins: [
imagetools({
extendOutputFormats: (builtins) => {
return { ...builtins, picture: pictureProxy(builtins.picture) };
},
extendTransforms: (builtins) => {
return [placeholderTransform, ...builtins];
},
defaultDirectives: (url) => {
if (url.searchParams.has('optimize')) {
/** @ts-ignore we can pass in booleans */
return new URLSearchParams({
w: '1920;1366;780;414',
format: 'avif;webp;jpg',
picture: true,
lqip: true
});
}
return new URLSearchParams();
}
}),
sveltekit()
],
});
placeholder.js
the actual logic to retrieve the base64 lqip copied and adapted from svimg
// Copied from: https://github.com/xiphux/svimg
import sharp from 'sharp';
const PLACEHOLDER_WIDTH = 16;
/**
* @param {string} inputFile
* @param {{ width: number; height?: number; quality?: number }} options
*/
async function resizeImage(inputFile, options) {
if (!inputFile) {
throw new Error('Input file is required');
}
let sharpInstance = sharp(inputFile).toFormat('webp').blur(3);
sharpInstance = sharpInstance.resize(options.width, options.height);
return sharpInstance.toBuffer();
}
/**
* @param {string} inputFile
*/
export async function getImageMetadata(inputFile) {
if (!inputFile) {
throw new Error('Input file is required');
}
return sharp(inputFile).metadata();
}
// sharp only supports a very specific list of image formats,
// no point depending on a complete mime type database
/**
* @param {string | undefined} format
*/
export function getMimeType(format) {
switch (format) {
case 'jpeg':
case 'png':
case 'webp':
case 'avif':
case 'tiff':
case 'gif':
return `image/${format}`;
case 'svg':
return 'image/svg+xml';
}
return '';
}
/**
* @param {string} inputFile
*/
export async function createPlaceholder(inputFile) {
if (!inputFile) {
throw new Error('Input file is required');
}
const [{ format }, blurData] = await Promise.all([
getImageMetadata(inputFile),
resizeImage(inputFile, { width: PLACEHOLDER_WIDTH })
]);
const blur64 = blurData.toString('base64');
const mime = getMimeType(format);
const href = `data:${mime};base64,${blur64}`;
return href;
}
Image.svelte
inlines the base64 lqip then fades in the image when it has loaded. Also maintains the image aspect ratio to avoid CLS.
<script lang="ts">
import { onMount } from 'svelte';
import type { Picture } from 'vite-imagetools';
interface PictureWithLQIP extends Picture {
lqip: string;
}
export let src: string | PictureWithLQIP;
export let alt: string;
export let decoding: 'async' | 'sync' | 'auto' = 'auto';
export let style = '';
let className = '';
export { className as class };
export let dominantColor = '#F8F8F8';
export let loading: 'eager' | 'lazy' = 'lazy';
// fade-in the image after it has loaded
let image: HTMLImageElement;
let hidden: boolean | undefined = undefined;
onMount(() => {
if (image.complete) return;
image.onload = () => (hidden = false);
if (hidden === undefined) hidden = true;
});
</script>
<div
style="{typeof src !== 'string' && src.lqip
? `background-image: url(${src.lqip})`
: `background-color: ${dominantColor}`}; {style}"
class="img__placeholder {className}"
>
{#if typeof src === 'string'}
<img
bind:this={image}
{style}
class={className}
class:hidden
{src}
{alt}
{loading}
{decoding}
/>
{:else}
<picture>
{#each Object.entries(src.sources) as [format, images]}
<source
srcset={images.map((i) => `${i.src} ${i.w}w`).join(', ')}
type={'image/' + format}
/>
{/each}
<img
bind:this={image}
{style}
class={className}
class:hidden
src={src.fallback.src}
width={src.fallback.w}
height={src.fallback.h}
{alt}
{loading}
{decoding}
/>
</picture>
{/if}
</div>
<style>
.img__placeholder,
img {
width: 100%;
height: auto;
}
.img__placeholder {
height: min-content;
background-size: cover;
background-repeat: no-repeat;
overflow: hidden;
}
img {
display: block;
transition: opacity 0.25s ease-out;
object-fit: cover;
/* hide alt text while image is loading */
color: transparent;
}
.hidden {
opacity: 0;
}
</style>
You can also use a gradient https://csswizardry.com/2016/10/improving-perceived-performance-with-multiple-background-images/ https://github.com/ben-eb/postcss-resemble-image
Or through canvas as in a Medium https://jmperezperez.com/blog/medium-image-progressive-loading-placeholder/
I needed this feature in a Vite plugin, and ran across this thread and saw it wasn’t supported (or is it? and undocumented?), so I made vite-plugin-lqip that handles LQIP and nothing else (can be used with vite-imagetools as this doesn’t optimize anything).
I didn’t see lqip-modern mentioned in this thread, but was pretty impressed with the results it gave both in quality and tiny filesize, so that’s the approach I took.
I’m still testing it / playing around with it, but open to feedback if anyone has any needed improvements. Might be easier for LQIP to be its own thing rather than putting that burden on vite-imagetools. But if @JonasKruckenberg wants to roll this into vite-imagetools I’d be more than happy to oblige 🙂
It seems to me that you might want to use different placeholders for different use cases. E.g. if the image is mostly a design element that's not displaying content then background color, gradient, or blur approaches could make sense. If the image is displaying necessary content like text then something like the traced placeholder might be best. astro-imagetools
offers multiple placeholders.
I also think we might want to be pretty selective about encouraging the use of low quality placeholders as lazy loading can be annoying.
My ideal solution for the problem being solved by placeholders would be an improved prioritization of image fetching by the browser potentially with user hints. I filed an issue for this with the WHATWG: https://github.com/whatwg/html/issues/10056