diff --git a/.changeset/rare-eels-refuse.md b/.changeset/rare-eels-refuse.md
new file mode 100644
index 00000000..c95a51c7
--- /dev/null
+++ b/.changeset/rare-eels-refuse.md
@@ -0,0 +1,5 @@
+---
+'imagetools-core': minor
+---
+
+feat: add effort directive
diff --git a/docs/directives.md b/docs/directives.md
index 953e9512..908c98ae 100644
--- a/docs/directives.md
+++ b/docs/directives.md
@@ -5,6 +5,7 @@
- [Directives](#directives)
- [Background](#background)
- [Blur](#blur)
+ - [Effort](#effort)
- [Fit](#fit)
- [Flatten](#flatten)
- [Flip](#flip)
@@ -86,6 +87,33 @@ import Image from 'example.jpg?blur=100'
---
+### Effort
+
+• **Keyword**: `effort`
• **Type**: _integer_ | _"max"_ | _"min"_
+
+Adjust the effort to spend encoding the image.
+The effect of effort varies per format, but a lower value leads to faster encoding.
+
+The supported ranges by format:
+- `png`: 1 to 10 (default 7)
+- `webp`: 0 to 6 (default 4)
+- `avif`/`heif`: 0 to 9 (default 4)
+- `jxl`: 3 to 9 (default 7)
+- `gif`: 1 to 10 (default 7)
+
+The keywords `"min"` and `"max"` apply the highest effort value for the given image format.
+
+> Search `options.effort` in [sharp's Output options documentation](https://sharp.pixelplumbing.com/api-output) for details.
+
+• **Example**:
+
+```js
+import highestEffortWebp from 'example.jpg?format=webp&effort=max'
+import quicklyGeneratingAvif from 'example.jpg?format=avif&effort=0'
+```
+
+---
+
### Fit
• **Keyword**: `fit`
• **Type**: _cover_ \| _contain_ \| _fill_ \| _inside_ \| _outside_
@@ -142,7 +170,7 @@ import Image from 'exmaple.jpg?flop=true'
### Format
-• **Keyword**: `format`
• **Type**: _heic_\| _heif_ \| _avif_ \| _jpeg_ \| _jpg_ \| _png_ \| _tiff_ \| _webp_ \|
+• **Keyword**: `format`
• **Type**: _jxl_\| _heif_ \| _avif_ \| _jpeg_ \| _jpg_ \| _png_ \| _tiff_ \| _webp_ \|
_gif_
Convert the image into the given format.
@@ -154,7 +182,7 @@ Convert the image into the given format.
```js
import Image from 'example.jpg?format=webp'
-import Images from 'example.jpg?format=webp;avif;heic'
+import Images from 'example.jpg?format=webp;avif;jxl'
```
---
@@ -233,7 +261,7 @@ Use this directive to set a different interpolation kernel when resizing the ima
Use lossless compression mode.
Formats that support this directive are:
- `avif`, `heif`, `heic`, and `webp`
+ `avif`, `heif`, `jxl`, and `webp`
• **Example**:
@@ -295,7 +323,7 @@ See sharps [resize options](https://sharp.pixelplumbing.com/api-resize#resize) f
All formats (except `gif`) allow the quality to be adjusted by setting this directive.
-The argument must be a number between 0 and 100.
+The argument must be a number between 1 and 100.
> See sharps [Output options](https://sharp.pixelplumbing.com/api-output) for default quality values.
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 148556c2..96edd03a 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,5 +1,6 @@
export * from './transforms/background.js'
export * from './transforms/blur.js'
+export * from './transforms/effort.js'
export * from './transforms/fit.js'
export * from './transforms/flatten.js'
export * from './transforms/flip.js'
diff --git a/packages/core/src/transforms/__tests__/__file_snapshots__/src-transforms-__tests__-format.spec.ts---format---transform---webp-w--effort-1 b/packages/core/src/transforms/__tests__/__file_snapshots__/src-transforms-__tests__-format.spec.ts---format---transform---webp-w--effort-1
new file mode 100644
index 00000000..0ee6545b
--- /dev/null
+++ b/packages/core/src/transforms/__tests__/__file_snapshots__/src-transforms-__tests__-format.spec.ts---format---transform---webp-w--effort-1
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:edf97a52065e500d6d224e36222eb3083d691295643e550484c917e39136e0fb
+size 14920
diff --git a/packages/core/src/transforms/__tests__/__image_snapshots__/format-spec-ts-src-transforms-tests-format-spec-ts-format-transform-png-w-effort-1-snap.png b/packages/core/src/transforms/__tests__/__image_snapshots__/format-spec-ts-src-transforms-tests-format-spec-ts-format-transform-png-w-effort-1-snap.png
new file mode 100644
index 00000000..9fc902ea
--- /dev/null
+++ b/packages/core/src/transforms/__tests__/__image_snapshots__/format-spec-ts-src-transforms-tests-format-spec-ts-format-transform-png-w-effort-1-snap.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1cb50732d3aa1e40ba4745f9cf3156b18655b4550f808bf018459e28efd3cf59
+size 189976
diff --git a/packages/core/src/transforms/__tests__/effort.spec.ts b/packages/core/src/transforms/__tests__/effort.spec.ts
new file mode 100644
index 00000000..c760145e
--- /dev/null
+++ b/packages/core/src/transforms/__tests__/effort.spec.ts
@@ -0,0 +1,71 @@
+import { getEffort } from '../effort'
+import sharp, { Sharp } from 'sharp'
+import { join } from 'path'
+import { describe, beforeEach, expect, test, it } from 'vitest'
+import { METADATA } from '../../lib/metadata'
+
+describe('effort', () => {
+ let img: Sharp
+ beforeEach(() => {
+ img = sharp(join(__dirname, '../../__tests__/__fixtures__/pexels-allec-gomes-5195763.png'))
+ })
+
+ test('keyword "effort"', () => {
+ const res = getEffort({ effort: '3' }, img)
+
+ expect(res).toEqual(3)
+ })
+
+ test('missing', () => {
+ const res = getEffort({}, img)
+
+ expect(res).toBeUndefined()
+ })
+
+ describe('arguments', () => {
+ test('invalid', () => {
+ const res = getEffort({ effort: 'invalid' }, img)
+
+ expect(res).toBeUndefined()
+ })
+
+ test('empty', () => {
+ const res = getEffort({ effort: '' }, img)
+
+ expect(res).toBeUndefined()
+ })
+
+ test('integer', () => {
+ const res = getEffort({ effort: '3' }, img)
+
+ expect(res).toEqual(3)
+ })
+
+ it('rounds float to int', () => {
+ const res = getEffort({ effort: '3.5' }, img)
+
+ expect(res).toEqual(3)
+ })
+
+ it('sets to minimum effort with "min"', async () => {
+ img[METADATA] = { format: 'webp' }
+ const res = getEffort({ effort: 'min' }, img)
+
+ expect(res).toEqual(0)
+ })
+
+ it('sets to maximum effort with "max"', async () => {
+ img[METADATA] = { format: 'webp' }
+ const res = getEffort({ effort: 'max' }, img)
+
+ expect(res).toEqual(6)
+ })
+
+ it('ignores effort when not applicable', async () => {
+ img[METADATA] = { format: 'jpeg' }
+ const res = getEffort({ effort: 'max' }, img)
+
+ expect(res).toBeUndefined()
+ })
+ })
+})
diff --git a/packages/core/src/transforms/__tests__/format.spec.ts b/packages/core/src/transforms/__tests__/format.spec.ts
index 9ba3a1b3..90c53dc3 100644
--- a/packages/core/src/transforms/__tests__/format.spec.ts
+++ b/packages/core/src/transforms/__tests__/format.spec.ts
@@ -162,5 +162,20 @@ describe('format', () => {
expect(await image.toBuffer()).toMatchFile()
})
+
+ test('png w/ effort', async () => {
+ const { image } = await applyTransforms([format({ format: 'png', effort: '1' }, dirCtx)!], img)
+
+ expect(await image.toBuffer()).toMatchImageSnapshot({
+ failureThreshold: 0.05,
+ failureThresholdType: 'percent'
+ })
+ })
+
+ test('webp w/ effort', async () => {
+ const { image } = await applyTransforms([format({ format: 'webp', effort: 'min' }, dirCtx)!], img)
+
+ expect(await image.toBuffer()).toMatchFile()
+ })
})
})
diff --git a/packages/core/src/transforms/effort.ts b/packages/core/src/transforms/effort.ts
new file mode 100644
index 00000000..646e33fe
--- /dev/null
+++ b/packages/core/src/transforms/effort.ts
@@ -0,0 +1,36 @@
+import { TransformOption } from '../types.js'
+import { getMetadata, setMetadata } from '../lib/metadata.js'
+
+export interface EffortOptions {
+ effort: string
+}
+
+const FORMAT_TO_EFFORT_RANGE: Record = {
+ avif: [0, 9],
+ gif: [1, 10],
+ heif: [0, 9],
+ jxl: [3, 9],
+ png: [1, 10],
+ webp: [0, 6]
+}
+
+function parseEffort(effort: string, format: string) {
+ if (effort === 'min') {
+ return FORMAT_TO_EFFORT_RANGE[format]?.[0]
+ } else if (effort === 'max') {
+ return FORMAT_TO_EFFORT_RANGE[format]?.[1]
+ }
+ return parseInt(effort)
+}
+
+export const getEffort: TransformOption = ({ effort: _effort }, image) => {
+ if (!_effort) return
+
+ const format = (getMetadata(image, 'format') ?? '') as string
+ const effort = parseEffort(_effort, format)
+ if (!Number.isInteger(effort)) return
+
+ setMetadata(image, 'effort', effort)
+
+ return effort
+}
diff --git a/packages/core/src/transforms/format.ts b/packages/core/src/transforms/format.ts
index 02443cfe..f2e81a7b 100644
--- a/packages/core/src/transforms/format.ts
+++ b/packages/core/src/transforms/format.ts
@@ -1,5 +1,6 @@
import { TransformFactory } from '../types.js'
import { METADATA } from '../lib/metadata.js'
+import { getEffort } from './effort.js'
import { getQuality } from './quality.js'
import { getProgressive } from './progressive.js'
import { getLossless } from './lossless.js'
@@ -23,9 +24,10 @@ export const format: TransformFactory = (config) => {
return image.toFormat(format, {
compression: format == 'heif' ? 'av1' : undefined,
- quality: getQuality(config, image),
+ effort: getEffort(config, image),
lossless: getLossless(config, image) as boolean,
- progressive: getProgressive(config, image) as boolean
+ progressive: getProgressive(config, image) as boolean,
+ quality: getQuality(config, image)
})
}
}