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

feat: add picture-lqip format with low quality base64 image #662

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eight-chicken-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'imagetools-core': patch
---

feat: add picture-lqip format with low quality base64 image
21 changes: 21 additions & 0 deletions docs/directives.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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)
Expand Down Expand Up @@ -485,6 +486,26 @@ for (const [format, images] of Object.entries(picture.sources)) {
html += `<img src={picture.img.src} /></picture>`
```

### Picture with low quality inplace image

• **Keyword**: `picture-lqip`<br> • **Type**: _boolean_<br>

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 = '<picture>';
for (const [format, images] of Object.entries(picture.sources)) {
html += `<source srcset={images.map((i) => `${i.src}`).join(', ')} type={'image/' + format} />`;
}
html += `<img src={picture.lqip} /></picture>`
```

### Source

• **Keyword**: `source`<br> • **Type**: _boolean_<br>
Expand Down
11 changes: 11 additions & 0 deletions docs/interfaces/core_src.Picture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
35 changes: 35 additions & 0 deletions docs/modules/core_src.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 32 additions & 1 deletion packages/core/src/__tests__/output-formats.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/output-formats.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readFile } from 'fs/promises'
import type { ImageMetadata, Img, OutputFormat, Picture } from './types.js'

export const urlFormat: OutputFormat = () => (metadatas) => {
Expand Down Expand Up @@ -109,11 +110,44 @@
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?.imagePath) {
const data = (await readFile(smallestFallback.imagePath as string)).toString('base64')
result.lqip = `data:image/${smallestFallback.format};base64,${data}`

Check warning on line 136 in packages/core/src/output-formats.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/output-formats.ts#L135-L136

Added lines #L135 - L136 were not covered by tests
} else if (smallestFallback?.image) {
const data = (await smallestFallback.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
}
3 changes: 2 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -100,4 +100,5 @@ export interface Picture {
w: number
h: number
}
lqip?: string
}