From e8352562aff08d62cdbc99aa4b9fac21fa20a29e Mon Sep 17 00:00:00 2001 From: Philipp Zerelles Date: Wed, 29 Nov 2023 16:56:09 +0100 Subject: [PATCH] feat: add picture-lqip format with low quality base64 image --- .changeset/eight-chicken-worry.md | 5 +++ docs/directives.md | 21 +++++++++++ docs/interfaces/core_src.Picture.md | 11 ++++++ docs/modules/core_src.md | 35 +++++++++++++++++++ .../__fixtures__/with-metadata-lqip.png | 3 ++ .../core/src/__tests__/output-formats.test.ts | 33 ++++++++++++++++- packages/core/src/output-formats.ts | 34 +++++++++++++++++- packages/core/src/types.ts | 3 +- 8 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 .changeset/eight-chicken-worry.md create mode 100644 packages/core/src/__tests__/__fixtures__/with-metadata-lqip.png diff --git a/.changeset/eight-chicken-worry.md b/.changeset/eight-chicken-worry.md new file mode 100644 index 00000000..34995b9d --- /dev/null +++ b/.changeset/eight-chicken-worry.md @@ -0,0 +1,5 @@ +--- +'imagetools-core': patch +--- + +feat: add picture-lqip format with low quality base64 image diff --git a/docs/directives.md b/docs/directives.md index f96b0fb4..6777b8c8 100644 --- a/docs/directives.md +++ b/docs/directives.md @@ -30,6 +30,7 @@ - [Tint](#tint) - [Metadata](#metadata) - [Picture](#picture) + - [Picture with low quality inplace image](#picture-with-low-quality-inplace-image) - [Source](#source) - [Srcset](#srcset) - [URL](#url) @@ -456,6 +457,26 @@ for (const [format, images] of Object.entries(picture.sources)) { html += `` ``` +### Picture with low quality inplace image + +• **Keyword**: `picture-lqip`
• **Type**: _boolean_
+ +Returns information about the image necessary to render a `picture` tag as a JavaScript object. +Includes a base64 encoded inplace representation of the image using the smallest requested size +and the fallback format. The smallest requested size will be excluded from the sources. + +• **Example**: + +```js +import picture from 'example.jpg?w=50;500;900;1200&format=avif;webp;jpg&as=picture-lqip' + +let html = ''; +for (const [format, images] of Object.entries(picture.sources)) { + html += ` `${i.src}`).join(', ')} type={'image/' + format} />`; +} +html += `` +``` + ### Source • **Keyword**: `url`
• **Type**: _boolean_
diff --git a/docs/interfaces/core_src.Picture.md b/docs/interfaces/core_src.Picture.md index a6a7901c..a4ce4c23 100644 --- a/docs/interfaces/core_src.Picture.md +++ b/docs/interfaces/core_src.Picture.md @@ -12,6 +12,7 @@ The picture output format. - [img](core_src.Picture.md#img) - [sources](core_src.Picture.md#sources) +- [lqip](core_src.Picture.md#lqip) ## Properties @@ -42,3 +43,13 @@ Key is format. Value is srcset. #### Defined in [packages/core/src/types.ts:97](https://github.com/JonasKruckenberg/imagetools/blob/4ebc88f/packages/core/src/types.ts#L97) + +### lqip + +• `Optional` **lqip**: `string` + +Low quality inplace image, base64 encoded, prepared for use with `src` attribute. + +#### Defined in + +[packages/core/src/types.ts:103](https://github.com/JonasKruckenberg/imagetools/blob/4ebc88f/packages/core/src/types.ts#L103) diff --git a/docs/modules/core_src.md b/docs/modules/core_src.md index 12baab7c..1fa1142a 100644 --- a/docs/modules/core_src.md +++ b/docs/modules/core_src.md @@ -80,6 +80,7 @@ - [imgFormat](core_src.md#imgformat) - [invert](core_src.md#invert) - [loadImage](core_src.md#loadimage) +- [lqipPictureFormat](core_src.md#lqipPictureformat) - [median](core_src.md#median) - [metadataFormat](core_src.md#metadataformat) - [normalize](core_src.md#normalize) @@ -832,6 +833,40 @@ ___ ___ +### lqipPictureFormat + +▸ **lqipPictureFormat**(`args?`): (`metadata`: [`ProcessedImageMetadata`](../interfaces/core_src.ProcessedImageMetadata.md)[]) => `unknown` + +fallback format should be specified last + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `args?` | `string`[] | + +#### Returns + +`fn` + +▸ (`metadata`): `unknown` + +##### Parameters + +| Name | Type | +| :------ | :------ | +| `metadata` | [`ProcessedImageMetadata`](../interfaces/core_src.ProcessedImageMetadata.md)[] | + +##### Returns + +`unknown` + +#### Defined in + +[packages/core/src/types.ts:71](https://github.com/JonasKruckenberg/imagetools/blob/4ebc88f/packages/core/src/types.ts#L71) + +___ + ### median ▸ **median**(`metadata`, `ctx`): `undefined` \| [`ImageTransformation`](core_src.md#imagetransformation) diff --git a/packages/core/src/__tests__/__fixtures__/with-metadata-lqip.png b/packages/core/src/__tests__/__fixtures__/with-metadata-lqip.png new file mode 100644 index 00000000..e5178bb3 --- /dev/null +++ b/packages/core/src/__tests__/__fixtures__/with-metadata-lqip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:30fc9dde17ee71a35d16a767507f4ebb08e6a1df3c23de15b0671da9cdedb9f3 +size 3894 diff --git a/packages/core/src/__tests__/output-formats.test.ts b/packages/core/src/__tests__/output-formats.test.ts index 2468e294..9079aed2 100644 --- a/packages/core/src/__tests__/output-formats.test.ts +++ b/packages/core/src/__tests__/output-formats.test.ts @@ -1,5 +1,7 @@ -import { urlFormat, metadataFormat, imgFormat, pictureFormat, srcsetFormat } from '../output-formats' +import { urlFormat, metadataFormat, imgFormat, pictureFormat, lqipPictureFormat, srcsetFormat } from '../output-formats' import { describe, test, expect } from 'vitest' +import sharp from 'sharp' +import { join } from 'path' describe('url format', () => { test('single image', () => { @@ -117,6 +119,35 @@ describe('picture format', () => { } }) }) + + test('multiple image formats and sizes with low quality inplace picture', async () => { + const image = sharp(join(__dirname, './__fixtures__/with-metadata-lqip.png')) + const output = await lqipPictureFormat()([ + { src: '/foo-100.avif', format: 'avif', width: 100, height: 50 }, + { src: '/foo-100.webp', format: 'webp', width: 100, height: 50 }, + { src: '/foo-100.jpg', format: 'jpg', width: 100, height: 50 }, + { src: '/foo-50.avif', format: 'avif', width: 50, height: 25 }, + { src: '/foo-50.webp', format: 'webp', width: 50, height: 25 }, + { src: '/foo-50.jpg', format: 'jpg', width: 50, height: 25 }, + { src: '/foo-10.avif', format: 'avif', width: 10, height: 5, image }, + { src: '/foo-10.webp', format: 'webp', width: 10, height: 5, image }, + { src: '/foo-10.jpg', format: 'jpg', width: 10, height: 5, image } + ]) + + expect(output).toStrictEqual({ + sources: { + avif: '/foo-100.avif 100w, /foo-50.avif 50w', + webp: '/foo-100.webp 100w, /foo-50.webp 50w', + jpeg: '/foo-100.jpg 100w, /foo-50.jpg 50w' + }, + img: { + src: '/foo-100.jpg', + w: 100, + h: 50 + }, + lqip: 'data:image/jpg;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAIAAAACUFjqAAAACXBIWXMAABuvAAAbrwFeGpEcAAABHklEQVR4nAXBjU6CQAAA4NuaZoGHnAcccN0hBx4HIalgYZr9TavNbKvWnDWz93+Ivg9Y0qBjyxLQ6DR9R5sUrC6VFCgJYa4wcHNiS+QTSD1NCay4xd22CszpuK+ECZys653joAeFZ+R9GhM4kl6V9xZ1niUuwMGxy/TA1gOzkTGzEDTldn0RzcrkaihB127F3KJY+9uu1o9VWajDfvd0P5mP+tPUAwTr1NYpgS/L+U0lI25XRVwkbDqMSuUD0tVCisx2I2I45paDtOsqPey/n5d3kmGAO60kdMsiUcL9fN9sXtfb3e/yYREyJ+YIQOMoi/231ezMw7ezSVlWq/Xu4+unvhxkYw5Ir0nJST3gHb0JTxsY6cRGXBA5ZG5q/gN38SygSTScDwAAAABJRU5ErkJggg==' + }) + }) }) describe('srcset format', () => { diff --git a/packages/core/src/output-formats.ts b/packages/core/src/output-formats.ts index 55d924b6..899f527e 100644 --- a/packages/core/src/output-formats.ts +++ b/packages/core/src/output-formats.ts @@ -1,4 +1,5 @@ -import type { ImageMetadata, Img, OutputFormat, Picture } from './types.js' +import { readFile } from 'fs/promises' +import type { ImageMetadata, Img, OutputFormat, Picture, TransformResult } from './types.js' export const urlFormat: OutputFormat = () => (metadatas) => { const urls: string[] = metadatas.map((metadata) => metadata.src as string) @@ -109,11 +110,42 @@ export const pictureFormat: OutputFormat = () => (metadatas) => { return result } +export const lqipPictureFormat: OutputFormat = () => async (metadatas) => { + const fallbackFormat = [...new Set(metadatas.map((m) => getFormat(m)))].pop() + + let smallestFallback + let smallestFallbackSize = 0 + for (const m of metadatas) { + if (m.format?.replace('jpg', 'jpeg') === fallbackFormat) { + if (m.width && (!smallestFallbackSize || m.width < smallestFallbackSize)) { + smallestFallback = m + smallestFallbackSize = m.width + } + } + } + + const filteredMetadatas = metadatas.filter((m) => m.width && m.width > smallestFallbackSize) + if (filteredMetadatas.length > 0) { + metadatas = filteredMetadatas + } + + const result = pictureFormat()(metadatas) as Picture + + if (smallestFallback) { + const image = smallestFallback.image as string | TransformResult['image'] + const data = (await (typeof image === 'string' ? readFile(image) : image.toBuffer())).toString('base64') + result.lqip = `data:image/${smallestFallback.format};base64,${data}` + } + + return result +} + export const builtinOutputFormats = { url: urlFormat, srcset: srcsetFormat, img: imgFormat, picture: pictureFormat, + 'picture-lqip': lqipPictureFormat, metadata: metadataFormat, meta: metadataFormat } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 07149759..d913910e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,4 +1,4 @@ -import { Metadata, Sharp } from 'sharp' +import type { Metadata, Sharp } from 'sharp' import { kernelValues } from './transforms/kernel.js' import { positionValues } from './transforms/position.js' @@ -100,4 +100,5 @@ export interface Picture { w: number h: number } + lqip?: string }