Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a low-quality-placeholder directive #86

Open
JonasKruckenberg opened this issue May 3, 2021 · 11 comments
Open

Add a low-quality-placeholder directive #86

JonasKruckenberg opened this issue May 3, 2021 · 11 comments
Labels
enhancement New feature or request Feedback wanted Further information is requested
Projects

Comments

@JonasKruckenberg
Copy link
Owner

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:

  1. resize to a width of 640px
  2. 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 #69

@JonasKruckenberg JonasKruckenberg added the enhancement New feature or request label May 3, 2021
@JonasKruckenberg JonasKruckenberg added this to To do in Roadmap May 7, 2021
@JonasKruckenberg JonasKruckenberg added the Feedback wanted Further information is requested label May 7, 2021
@cryptodeal
Copy link

cryptodeal commented May 13, 2021

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.

JonasKruckenberg added a commit that referenced this issue May 16, 2021
Adds a low-quality-placeholder directive as discussed in #86
JonasKruckenberg added a commit that referenced this issue May 25, 2021
Adds a low-quality-placeholder directive as discussed in #86
@michaeloliverx
Copy link

+1 I would love this feature.

Great library btw it works great!

@michaeloliverx
Copy link

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?

@michaeloliverx
Copy link

michaeloliverx commented Jul 13, 2021

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:
image

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 };
}

@bdlowery
Copy link

bdlowery commented Apr 29, 2022

Is this an official feature now? It looks like it was added as a directive by @JonasKruckenberg. Not mentioned in the Docs however.

@sawyerclick
Copy link
Contributor

Would love to see lqip as an option for the picture directive's fallback

@eltigerchino
Copy link

eltigerchino commented Apr 27, 2023

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>

@drwpow
Copy link

drwpow commented Aug 4, 2023

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 🙂

@benmccann
Copy link
Collaborator

benmccann commented Oct 16, 2023

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.

@benmccann
Copy link
Collaborator

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: whatwg/html#10056

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request Feedback wanted Further information is requested
Projects
Roadmap
To do
Development

No branches or pull requests

9 participants