Skip to content

Commit deed868

Browse files
authored
feat(next/image): Add experimental imgOptMozjpeg config (#98571)
In a previous PR, `mozjpeg` was set to true which might be the reason output jpeg spikes cpu. - #65846 I made a benchmark and found that disabling MozJPEG saved 72% CPU, with average outputs 31% larger. - lovell/sharp#4603 So this PR adds a new experimental flag to enable (or rather disable) mozjpeg and try with real workloads. This is useful becaues in many cases, cpu is more costly than bandwidth since most CDNs provide [near unlimited bandwidth](https://vercel.com/blog/introducing-flat-rate-cdn).
1 parent d155ba9 commit deed868

6 files changed

Lines changed: 61 additions & 4 deletions

File tree

‎packages/next/src/server/config-schema.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ export const experimentalSchema = {
273273
imgOptTimeoutInSeconds: z.number().int().optional(),
274274
imgOptMaxInputPixels: z.number().int().optional(),
275275
imgOptSequentialRead: z.boolean().optional().nullable(),
276+
imgOptMozjpeg: z.boolean().optional(),
276277
isrFlushToDisk: z.boolean().optional(),
277278
largePageDataBytes: z.number().optional(),
278279
linkNoTouchStart: z.boolean().optional(),

‎packages/next/src/server/config-shared.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,7 @@ export interface ExperimentalConfig {
606606
imgOptTimeoutInSeconds?: number
607607
imgOptMaxInputPixels?: number
608608
imgOptSequentialRead?: boolean | null
609+
imgOptMozjpeg?: boolean
609610
optimisticClientCache?: boolean
610611
/**
611612
* @deprecated use config.expireTime instead
@@ -2333,6 +2334,7 @@ export const defaultConfig = Object.freeze({
23332334
imgOptTimeoutInSeconds: 7,
23342335
imgOptMaxInputPixels: 268_402_689, // https://sharp.pixelplumbing.com/api-constructor#:~:text=%5Boptions.limitInputPixels%5D
23352336
imgOptSequentialRead: null,
2337+
imgOptMozjpeg: true,
23362338
isrFlushToDisk: true,
23372339
workerThreads: false,
23382340
proxyTimeout: undefined,
@@ -2502,6 +2504,7 @@ export interface NextConfigRuntime {
25022504
| 'imgOptMaxInputPixels'
25032505
| 'imgOptSequentialRead'
25042506
| 'imgOptTimeoutInSeconds'
2507+
| 'imgOptMozjpeg'
25052508
| 'proxyClientMaxBodySize'
25062509
| 'proxyTimeout'
25072510
| 'testProxy'
@@ -2573,6 +2576,7 @@ export function getNextConfigRuntime(
25732576
imgOptMaxInputPixels: ex.imgOptMaxInputPixels,
25742577
imgOptSequentialRead: ex.imgOptSequentialRead,
25752578
imgOptTimeoutInSeconds: ex.imgOptTimeoutInSeconds,
2579+
imgOptMozjpeg: ex.imgOptMozjpeg,
25762580
proxyClientMaxBodySize: ex.proxyClientMaxBodySize,
25772581
proxyTimeout: ex.proxyTimeout,
25782582
testProxy: ex.testProxy,

‎packages/next/src/server/image-optimizer.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,7 @@ export async function imageOptimizer(
752752
| 'imgOptMaxInputPixels'
753753
| 'imgOptSequentialRead'
754754
| 'imgOptTimeoutInSeconds'
755+
| 'imgOptMozjpeg'
755756
>
756757
images: Pick<
757758
NextConfigComplete['images'],

‎packages/next/src/server/image-optimizer/transform.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export type ImageOptimizerTransformConfig = {
4949
| 'imgOptMaxInputPixels'
5050
| 'imgOptSequentialRead'
5151
| 'imgOptTimeoutInSeconds'
52+
| 'imgOptMozjpeg'
5253
>
5354
images: Pick<
5455
NextConfigComplete['images'],
@@ -139,6 +140,7 @@ export async function optimizeImage({
139140
limitInputPixels,
140141
sequentialRead,
141142
timeoutInSeconds,
143+
mozjpeg = true,
142144
}: {
143145
buffer: Buffer
144146
contentType: string
@@ -150,6 +152,7 @@ export async function optimizeImage({
150152
limitInputPixels?: number
151153
sequentialRead?: boolean | null
152154
timeoutInSeconds?: number
155+
mozjpeg?: boolean
153156
}): Promise<Buffer> {
154157
const sharp = getSharp(concurrency, operationCache)
155158
const transformer = sharp(buffer, {
@@ -182,7 +185,7 @@ export async function optimizeImage({
182185
} else if (contentType === PNG) {
183186
transformer.png({ quality })
184187
} else if (contentType === JPEG) {
185-
transformer.jpeg({ quality, mozjpeg: true })
188+
transformer.jpeg({ quality, mozjpeg })
186189
}
187190

188191
const optimizedBuffer = await transformer.toBuffer()
@@ -283,6 +286,7 @@ export async function imageOptimizerTransform(
283286
limitInputPixels: nextConfig.experimental.imgOptMaxInputPixels,
284287
sequentialRead: nextConfig.experimental.imgOptSequentialRead,
285288
timeoutInSeconds: nextConfig.experimental.imgOptTimeoutInSeconds,
289+
mozjpeg: nextConfig.experimental.imgOptMozjpeg,
286290
})
287291
if (opts.handleDevOutput) {
288292
const output = await opts.handleDevOutput(optimizedBuffer, contentType)

‎test/unit/image-optimizer/transform.test.ts‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
/* eslint-env jest */
22
import { readFile } from 'fs-extra'
33
import { join } from 'path'
4-
import { imageOptimizerTransform } from 'next/dist/server/image-optimizer/transform'
4+
import {
5+
getSharp,
6+
imageOptimizerTransform,
7+
type ImageOptimizerTransformConfig,
8+
} from 'next/dist/server/image-optimizer/transform'
59
import type {
610
CachedRouteKind,
711
IncrementalResponseCacheEntry,
@@ -24,7 +28,11 @@ const config = {
2428
},
2529
}
2630

27-
async function transform(filename: string, mimeType = 'image/webp') {
31+
async function transform(
32+
filename: string,
33+
mimeType = 'image/webp',
34+
nextConfig: ImageOptimizerTransformConfig = config
35+
) {
2836
const buffer = await getImage(filename)
2937
return imageOptimizerTransform(
3038
{
@@ -34,7 +42,7 @@ async function transform(filename: string, mimeType = 'image/webp') {
3442
etag: 'source-etag',
3543
},
3644
{ href: `/${filename}`, width: 64, quality: 75, mimeType },
37-
config
45+
nextConfig
3846
)
3947
}
4048

@@ -88,6 +96,24 @@ describe('imageOptimizerTransform', () => {
8896
expect(result.maxAge).toBe(120)
8997
})
9098

99+
it.each([undefined, true, false])(
100+
'encodes JPEGs with imgOptMozjpeg=%s',
101+
async (imgOptMozjpeg) => {
102+
const result = await transform('test.jpg', 'image/jpeg', {
103+
...config,
104+
experimental: { ...config.experimental, imgOptMozjpeg },
105+
})
106+
expect(result.error).toBeUndefined()
107+
expect(result.contentType).toBe('image/jpeg')
108+
const sharp = getSharp(1, false)
109+
expect(await sharp(result.buffer).metadata()).toMatchObject({
110+
format: 'jpeg',
111+
width: 64,
112+
isProgressive: imgOptMozjpeg ?? true,
113+
})
114+
}
115+
)
116+
91117
it('preserves the source format when no output format is requested', async () => {
92118
const result = await transform('test.png', '')
93119
expect(result.contentType).toBe('image/png')

‎test/unit/isolated/config.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,27 @@ describe('config', () => {
108108
expect(disabledConfig.experimental.devMemoryThresholdRestart).toBe(false)
109109
})
110110

111+
it.each([undefined, true, false])(
112+
'Should preserve imgOptMozjpeg=%s in the runtime config',
113+
async (imgOptMozjpeg) => {
114+
const config = await loadConfig(PHASE_DEVELOPMENT_SERVER, '<rootDir>', {
115+
customConfig: {
116+
experimental: {
117+
imgOptMozjpeg,
118+
runtimeServerDeploymentId: true,
119+
},
120+
},
121+
})
122+
const { getNextConfigRuntime } = await import(
123+
'next/dist/server/config-shared'
124+
)
125+
expect(config.experimental.imgOptMozjpeg).toBe(imgOptMozjpeg ?? true)
126+
expect(getNextConfigRuntime(config).experimental.imgOptMozjpeg).toBe(
127+
imgOptMozjpeg ?? true
128+
)
129+
}
130+
)
131+
111132
it('Should allow bundler-specific options during the test phase', async () => {
112133
const config = await loadConfig(PHASE_TEST, '<rootDir>-test-phase', {
113134
customConfig: {

0 commit comments

Comments
 (0)