From 91ccf43fd06e48e72d90fcc5c07f1ef821b43b09 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 23 Oct 2024 12:17:34 -0400 Subject: [PATCH 1/4] feat: add experiment for sharp cpu flags --- packages/next/src/server/image-optimizer.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/next/src/server/image-optimizer.ts b/packages/next/src/server/image-optimizer.ts index cf8a888cff10a..c9b40e69ffc50 100644 --- a/packages/next/src/server/image-optimizer.ts +++ b/packages/next/src/server/image-optimizer.ts @@ -520,7 +520,17 @@ export async function optimizeImage({ height?: number }): Promise { const sharp = getSharp() - const transformer = sharp(buffer).timeout({ seconds: 7 }).rotate() + const transformer = sharp(buffer, { + limitInputPixels: process.env.__NEXT_EXPERIMENTAL_IMAGE_PIXELS + ? Number(process.env.__NEXT_EXPERIMENTAL_IMAGE_PIXELS) + : 67_108_864, + }) + .timeout({ + seconds: process.env.__NEXT_EXPERIMENTAL_IMAGE_TIMEOUT + ? Number(process.env.__NEXT_EXPERIMENTAL_IMAGE_TIMEOUT) + : 7, + }) + .rotate() if (height) { transformer.resize(width, height) From e3fa0375c9d17c64ae012cefb376cca4b5a60332 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 23 Oct 2024 14:06:11 -0400 Subject: [PATCH 2/4] don't change the default --- packages/next/src/server/image-optimizer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/src/server/image-optimizer.ts b/packages/next/src/server/image-optimizer.ts index c9b40e69ffc50..c7450763f533a 100644 --- a/packages/next/src/server/image-optimizer.ts +++ b/packages/next/src/server/image-optimizer.ts @@ -523,7 +523,7 @@ export async function optimizeImage({ const transformer = sharp(buffer, { limitInputPixels: process.env.__NEXT_EXPERIMENTAL_IMAGE_PIXELS ? Number(process.env.__NEXT_EXPERIMENTAL_IMAGE_PIXELS) - : 67_108_864, + : undefined, }) .timeout({ seconds: process.env.__NEXT_EXPERIMENTAL_IMAGE_TIMEOUT From 242faf08fbc3cffdb9b9d42605a5e4e6f3410077 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 23 Oct 2024 15:45:02 -0400 Subject: [PATCH 3/4] switch from env to config, and add tests --- packages/next/src/server/config-schema.ts | 2 + packages/next/src/server/config-shared.ts | 2 + packages/next/src/server/image-optimizer.ts | 18 +++++--- .../image-optimizer/test/index.test.ts | 45 +++++++++++++++++++ 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 8cc21968441dc..473c76ff39b38 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -314,6 +314,8 @@ export const configSchema: zod.ZodType = z.lazy(() => forceSwcTransforms: z.boolean().optional(), fullySpecified: z.boolean().optional(), gzipSize: z.boolean().optional(), + imgOptTimeoutInSeconds: z.number().int().optional(), + imgOptMaxInputPixels: z.number().int().optional(), internal_disableSyncDynamicAPIWarnings: z.boolean().optional(), isrFlushToDisk: z.boolean().optional(), largePageDataBytes: z.number().optional(), diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 13a0daec70e24..30b976b8fedb2 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -286,6 +286,8 @@ export interface ExperimentalConfig { extensionAlias?: Record allowedRevalidateHeaderKeys?: string[] fetchCacheKeyPrefix?: string + imgOptTimeoutInSeconds?: number + imgOptMaxInputPixels?: number optimisticClientCache?: boolean /** * @deprecated use config.expireTime instead diff --git a/packages/next/src/server/image-optimizer.ts b/packages/next/src/server/image-optimizer.ts index c7450763f533a..6ab759bd54e32 100644 --- a/packages/next/src/server/image-optimizer.ts +++ b/packages/next/src/server/image-optimizer.ts @@ -512,23 +512,23 @@ export async function optimizeImage({ quality, width, height, + limitInputPixels, + timeoutInSeconds, }: { buffer: Buffer contentType: string quality: number width: number height?: number + limitInputPixels?: number + timeoutInSeconds?: number }): Promise { const sharp = getSharp() const transformer = sharp(buffer, { - limitInputPixels: process.env.__NEXT_EXPERIMENTAL_IMAGE_PIXELS - ? Number(process.env.__NEXT_EXPERIMENTAL_IMAGE_PIXELS) - : undefined, + limitInputPixels, }) .timeout({ - seconds: process.env.__NEXT_EXPERIMENTAL_IMAGE_TIMEOUT - ? Number(process.env.__NEXT_EXPERIMENTAL_IMAGE_TIMEOUT) - : 7, + seconds: timeoutInSeconds ?? 7, }) .rotate() @@ -641,6 +641,10 @@ export async function imageOptimizer( 'href' | 'width' | 'quality' | 'mimeType' >, nextConfig: { + experimental: Pick< + NextConfigComplete['experimental'], + 'imgOptMaxInputPixels' | 'imgOptTimeoutInSeconds' + > images: Pick< NextConfigComplete['images'], 'dangerouslyAllowSVG' | 'minimumCacheTTL' @@ -745,6 +749,8 @@ export async function imageOptimizer( contentType, quality, width, + limitInputPixels: nextConfig.experimental.imgOptMaxInputPixels, + timeoutInSeconds: nextConfig.experimental.imgOptTimeoutInSeconds, }) if (optimizedBuffer) { if (isDev && width <= BLUR_IMG_SIZE && quality === BLUR_QUALITY) { diff --git a/test/integration/image-optimizer/test/index.test.ts b/test/integration/image-optimizer/test/index.test.ts index 7e139790686df..f53c807f79949 100644 --- a/test/integration/image-optimizer/test/index.test.ts +++ b/test/integration/image-optimizer/test/index.test.ts @@ -734,6 +734,51 @@ describe('Image Optimizer', () => { }) }) + describe('experimental.imgOptMaxInputPixels in next.config.js', () => { + let app + let appPort + const size = 256 // defaults defined in lib/image-config.ts + const query = { w: size, q: 75, url: '/test.jpg' } + const opts = { headers: { accept: 'image/webp' } } + + afterEach(async () => { + await killApp(app) + nextConfig.restore() + }) + it('should optimize when imgOptMaxInputPixels is larger than source image', async () => { + nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + experimental: { + imgOptMaxInputPixels: 60_000_000, + }, + }) + ) + await cleanImagesDir({ imagesDir }) + appPort = await findPort() + app = await launchApp(appDir, appPort) + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/webp') + }) + it('should fallback to source image when input exceeds imgOptMaxInputPixels', async () => { + nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + experimental: { + imgOptMaxInputPixels: 100, + }, + }) + ) + await cleanImagesDir({ imagesDir }) + appPort = await findPort() + app = await launchApp(appDir, appPort) + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/jpeg') + }) + }) + describe('External rewrite support with for serving static content in images', () => { ;(process.env.TURBOPACK_DEV ? describe.skip : describe)( 'production mode', From 76864f1030bda6c1defcc1f04bf1ca7dc0e5e574 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 23 Oct 2024 17:55:20 -0400 Subject: [PATCH 4/4] fix test --- packages/next/src/server/config-schema.ts | 1 + packages/next/src/server/config-shared.ts | 4 +++ packages/next/src/server/image-optimizer.ts | 11 ++++--- .../image-optimizer/test/index.test.ts | 32 ++++++------------- 4 files changed, 21 insertions(+), 27 deletions(-) diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 473c76ff39b38..376ef08bbbf7e 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -314,6 +314,7 @@ export const configSchema: zod.ZodType = z.lazy(() => forceSwcTransforms: z.boolean().optional(), fullySpecified: z.boolean().optional(), gzipSize: z.boolean().optional(), + imgOptConcurrency: z.number().int().optional().nullable(), imgOptTimeoutInSeconds: z.number().int().optional(), imgOptMaxInputPixels: z.number().int().optional(), internal_disableSyncDynamicAPIWarnings: z.boolean().optional(), diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 30b976b8fedb2..43f3c288098c9 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -286,6 +286,7 @@ export interface ExperimentalConfig { extensionAlias?: Record allowedRevalidateHeaderKeys?: string[] fetchCacheKeyPrefix?: string + imgOptConcurrency?: number | null imgOptTimeoutInSeconds?: number imgOptMaxInputPixels?: number optimisticClientCache?: boolean @@ -1108,6 +1109,9 @@ export const defaultConfig: NextConfig = { (os.cpus() || { length: 1 }).length) - 1 ), memoryBasedWorkersCount: false, + imgOptConcurrency: null, + imgOptTimeoutInSeconds: 7, + imgOptMaxInputPixels: 268_402_689, // https://sharp.pixelplumbing.com/api-constructor#:~:text=%5Boptions.limitInputPixels%5D isrFlushToDisk: true, workerThreads: false, proxyTimeout: undefined, diff --git a/packages/next/src/server/image-optimizer.ts b/packages/next/src/server/image-optimizer.ts index 6ab759bd54e32..c31935ffbd244 100644 --- a/packages/next/src/server/image-optimizer.ts +++ b/packages/next/src/server/image-optimizer.ts @@ -47,7 +47,7 @@ const BLUR_QUALITY = 70 // should match `next-image-loader` let _sharp: typeof import('sharp') -function getSharp() { +function getSharp(concurrency: number | null | undefined) { if (_sharp) { return _sharp } @@ -59,7 +59,7 @@ function getSharp() { // https://sharp.pixelplumbing.com/api-utility#concurrency const divisor = process.env.NODE_ENV === 'development' ? 4 : 2 _sharp.concurrency( - Math.floor(Math.max(_sharp.concurrency() / divisor, 1)) + concurrency ?? Math.floor(Math.max(_sharp.concurrency() / divisor, 1)) ) } } catch (e: unknown) { @@ -512,6 +512,7 @@ export async function optimizeImage({ quality, width, height, + concurrency, limitInputPixels, timeoutInSeconds, }: { @@ -520,10 +521,11 @@ export async function optimizeImage({ quality: number width: number height?: number + concurrency?: number | null limitInputPixels?: number timeoutInSeconds?: number }): Promise { - const sharp = getSharp() + const sharp = getSharp(concurrency) const transformer = sharp(buffer, { limitInputPixels, }) @@ -643,7 +645,7 @@ export async function imageOptimizer( nextConfig: { experimental: Pick< NextConfigComplete['experimental'], - 'imgOptMaxInputPixels' | 'imgOptTimeoutInSeconds' + 'imgOptConcurrency' | 'imgOptMaxInputPixels' | 'imgOptTimeoutInSeconds' > images: Pick< NextConfigComplete['images'], @@ -749,6 +751,7 @@ export async function imageOptimizer( contentType, quality, width, + concurrency: nextConfig.experimental.imgOptConcurrency, limitInputPixels: nextConfig.experimental.imgOptMaxInputPixels, timeoutInSeconds: nextConfig.experimental.imgOptTimeoutInSeconds, }) diff --git a/test/integration/image-optimizer/test/index.test.ts b/test/integration/image-optimizer/test/index.test.ts index f53c807f79949..e9aab89e9380d 100644 --- a/test/integration/image-optimizer/test/index.test.ts +++ b/test/integration/image-optimizer/test/index.test.ts @@ -737,42 +737,28 @@ describe('Image Optimizer', () => { describe('experimental.imgOptMaxInputPixels in next.config.js', () => { let app let appPort - const size = 256 // defaults defined in lib/image-config.ts - const query = { w: size, q: 75, url: '/test.jpg' } - const opts = { headers: { accept: 'image/webp' } } - afterEach(async () => { - await killApp(app) - nextConfig.restore() - }) - it('should optimize when imgOptMaxInputPixels is larger than source image', async () => { + beforeAll(async () => { nextConfig.replace( '{ /* replaceme */ }', JSON.stringify({ experimental: { - imgOptMaxInputPixels: 60_000_000, + imgOptMaxInputPixels: 100, }, }) ) await cleanImagesDir({ imagesDir }) appPort = await findPort() app = await launchApp(appDir, appPort) - const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) - expect(res.status).toBe(200) - expect(res.headers.get('Content-Type')).toBe('image/webp') + }) + afterAll(async () => { + await killApp(app) + nextConfig.restore() }) it('should fallback to source image when input exceeds imgOptMaxInputPixels', async () => { - nextConfig.replace( - '{ /* replaceme */ }', - JSON.stringify({ - experimental: { - imgOptMaxInputPixels: 100, - }, - }) - ) - await cleanImagesDir({ imagesDir }) - appPort = await findPort() - app = await launchApp(appDir, appPort) + const size = 256 // defaults defined in lib/image-config.ts + const query = { w: size, q: 75, url: '/test.jpg' } + const opts = { headers: { accept: 'image/webp' } } const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('image/jpeg')