diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 8cc21968441dc..376ef08bbbf7e 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -314,6 +314,9 @@ 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(), 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..43f3c288098c9 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -286,6 +286,9 @@ export interface ExperimentalConfig { extensionAlias?: Record allowedRevalidateHeaderKeys?: string[] fetchCacheKeyPrefix?: string + imgOptConcurrency?: number | null + imgOptTimeoutInSeconds?: number + imgOptMaxInputPixels?: number optimisticClientCache?: boolean /** * @deprecated use config.expireTime instead @@ -1106,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 cf8a888cff10a..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,15 +512,27 @@ export async function optimizeImage({ quality, width, height, + concurrency, + limitInputPixels, + timeoutInSeconds, }: { buffer: Buffer contentType: string quality: number width: number height?: number + concurrency?: number | null + limitInputPixels?: number + timeoutInSeconds?: number }): Promise { - const sharp = getSharp() - const transformer = sharp(buffer).timeout({ seconds: 7 }).rotate() + const sharp = getSharp(concurrency) + const transformer = sharp(buffer, { + limitInputPixels, + }) + .timeout({ + seconds: timeoutInSeconds ?? 7, + }) + .rotate() if (height) { transformer.resize(width, height) @@ -631,6 +643,10 @@ export async function imageOptimizer( 'href' | 'width' | 'quality' | 'mimeType' >, nextConfig: { + experimental: Pick< + NextConfigComplete['experimental'], + 'imgOptConcurrency' | 'imgOptMaxInputPixels' | 'imgOptTimeoutInSeconds' + > images: Pick< NextConfigComplete['images'], 'dangerouslyAllowSVG' | 'minimumCacheTTL' @@ -735,6 +751,9 @@ export async function imageOptimizer( contentType, quality, width, + concurrency: nextConfig.experimental.imgOptConcurrency, + 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..e9aab89e9380d 100644 --- a/test/integration/image-optimizer/test/index.test.ts +++ b/test/integration/image-optimizer/test/index.test.ts @@ -734,6 +734,37 @@ describe('Image Optimizer', () => { }) }) + describe('experimental.imgOptMaxInputPixels in next.config.js', () => { + let app + let appPort + + beforeAll(async () => { + nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + experimental: { + imgOptMaxInputPixels: 100, + }, + }) + ) + await cleanImagesDir({ imagesDir }) + appPort = await findPort() + app = await launchApp(appDir, appPort) + }) + afterAll(async () => { + await killApp(app) + nextConfig.restore() + }) + it('should fallback to source image when input exceeds imgOptMaxInputPixels', async () => { + 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') + }) + }) + describe('External rewrite support with for serving static content in images', () => { ;(process.env.TURBOPACK_DEV ? describe.skip : describe)( 'production mode',