From cc1f3b8a38645726c6ec1d9c6c843f0a200adcf0 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 11 Oct 2021 19:17:47 -0400 Subject: [PATCH] Add support for AVIF to `next/image` (#29683) Add support for AVIF to `next/image` - Fixes #27882 - Closes #27432 ## Feature - [x] Implements an existing feature request - [x] Related issues linked - [x] Integration tests added - [x] Documentation added - [x] Update manifest output - [x] Warn when `sharp` is outdated - [x] Errors & Warnings have helpful link attached - [ ] Remove `image-size` in favor of `squoosh`/`sharp` (optional, need to benchmark) --- docs/api-reference/next/image.md | 20 ++- docs/basic-features/image-optimization.md | 2 +- docs/testing.md | 3 +- errors/invalid-images-config.md | 4 + errors/manifest.json | 4 + errors/placeholder-blur-data-url.md | 2 +- errors/sharp-missing-in-production.md | 1 + errors/sharp-version-avif.md | 24 ++++ examples/with-jest/jest.config.js | 2 +- packages/next/build/webpack-config.ts | 4 +- .../webpack/config/blocks/images/index.ts | 2 +- .../webpack/loaders/next-image-loader.js | 9 +- packages/next/client/image.tsx | 2 +- packages/next/image-types/global.d.ts | 6 + packages/next/server/config.ts | 26 ++++ packages/next/server/image-config.ts | 4 + packages/next/server/image-optimizer.ts | 87 +++++++++++-- packages/next/server/lib/squoosh/main.ts | 6 + packages/next/server/serve-static.ts | 8 ++ .../base-path/pages/static-img.js | 2 + .../base-path/public/test.avif | Bin 0 -> 1043 bytes .../base-path/test/static.test.js | 1 + .../default/pages/static-img.js | 2 + .../image-component/default/public/test.avif | Bin 0 -> 1043 bytes .../default/test/static.test.js | 1 + .../typescript/pages/valid.tsx | 2 + .../typescript/public/test.avif | Bin 0 -> 1043 bytes .../image-optimizer/test/index.test.js | 117 +++++++++++++++--- .../detect-content-type.test.ts | 4 + test/unit/image-optimizer/images/test.avif | Bin 0 -> 1043 bytes 30 files changed, 302 insertions(+), 43 deletions(-) create mode 100644 errors/sharp-version-avif.md create mode 100644 test/integration/image-component/base-path/public/test.avif create mode 100644 test/integration/image-component/default/public/test.avif create mode 100644 test/integration/image-component/typescript/public/test.avif create mode 100644 test/unit/image-optimizer/images/test.avif diff --git a/docs/api-reference/next/image.md b/docs/api-reference/next/image.md index 431177760454c..fffaa86980448 100644 --- a/docs/api-reference/next/image.md +++ b/docs/api-reference/next/image.md @@ -16,6 +16,7 @@ description: Enable Image Optimization with the built-in Image component. | Version | Changes | | --------- | ------------------------------------------------------------------------------------------------- | +| `v12.0.0` | `formats` configuration added as well as AVIF support. | | `v11.1.0` | `onLoadingComplete` and `lazyBoundary` props added. | | `v11.0.0` | `src` prop support for static import.
`placeholder` prop added.
`blurDataURL` prop added. | | `v10.0.5` | `loader` prop added. | @@ -141,7 +142,7 @@ Should only be used when the image is visible above the fold. Defaults to `false A placeholder to use while the image is loading. Possible values are `blur` or `empty`. Defaults to `empty`. -When `blur`, the [`blurDataURL`](#blurdataurl) property will be used as the placeholder. If `src` is an object from a [static import](#local-images) and the imported image is `.jpg`, `.png`, or `.webp`, then `blurDataURL` will be automatically populated. +When `blur`, the [`blurDataURL`](#blurdataurl) property will be used as the placeholder. If `src` is an object from a [static import](#local-images) and the imported image is `.jpg`, `.png`, `.webp`, or `.avif`, then `blurDataURL` will be automatically populated. For dynamic images, you must provide the [`blurDataURL`](#blurdataurl) property. Solutions such as [Plaiceholder](https://github.com/joe-bell/plaiceholder) can help with `base64` generation. @@ -322,6 +323,7 @@ The expiration (or rather Max Age) is defined by the upstream server's `Cache-Co - If `s-maxage` is found in `Cache-Control`, it is used. If no `s-maxage` is found, then `max-age` is used. If no `max-age` is found, then [`minimumCacheTTL`](#minimum-cache-ttl) is used. - You can configure [`minimumCacheTTL`](#minimum-cache-ttl) to increase the cache duration when the upstream image does not include `max-age`. - You can also configure [`deviceSizes`](#device-sizes) and [`imageSizes`](#device-sizes) to reduce the total number of possible generated images. +- You can also configure [formats](/docs/basic-features/image-optimization.md#acceptable-formats) to disable multiple formats in favor of a single image format. You can configure the Time to Live (TTL) in seconds for cached optimized images. In many cases, it's better to use a [Static Image Import](/docs/basic-features/image-optimization.md#local-images) which will automatically hash the file contents and cache the image forever with a `Cache-Control` header of `immutable`. @@ -351,6 +353,22 @@ module.exports = { } ``` +### Acceptable Formats + +The default [Image Optimization API](#loader-configuration) will automatically detect the browser's supported image formats via the request's `Accept` header. + +If the `Accept` matches more than one of the configured formats, the first match in the array is used. Therefore, the array order matters. If there is no match, the Image Optimization API will fallback to the original image's format. + +If no configuration is provided, the default below is used. + +```js +module.exports = { + images: { + formats: ['image/avif', 'image/webp'], + }, +} +``` + ## Related For an overview of the Image component features and usage guidelines, see: diff --git a/docs/basic-features/image-optimization.md b/docs/basic-features/image-optimization.md index 74512c02a4fef..150e1382167e2 100644 --- a/docs/basic-features/image-optimization.md +++ b/docs/basic-features/image-optimization.md @@ -38,7 +38,7 @@ To use a local image, `import` your `.jpg`, `.png`, or `.webp` files: import profilePic from '../public/me.png' ``` -Dynamic `await import()` or `require()` are _not_ supported. The `import` must be static. Also note that static image support requires Webpack 5, which is enabled by default in Next.js applications. +Dynamic `await import()` or `require()` are _not_ supported. The `import` must be static so it can be analyzed at build time. Next.js will automatically determine the `width` and `height` of your image based on the imported file. These values are used to prevent [Cumulative Layout Shift](https://nextjs.org/learn/seo/web-performance/cls) while your image is loading. diff --git a/docs/testing.md b/docs/testing.md index dffd78325bfc2..5e9a37ff33ea8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -180,7 +180,8 @@ module.exports = { /* Handle image imports https://jestjs.io/docs/webpack#handling-static-assets */ - '^.+\\.(jpg|jpeg|png|gif|webp|svg)$': '/__mocks__/fileMock.js', + '^.+\\.(jpg|jpeg|png|gif|webp|avif|svg)$': + '/__mocks__/fileMock.js', }, testPathIgnorePatterns: ['/node_modules/', '/.next/'], testEnvironment: 'jsdom', diff --git a/errors/invalid-images-config.md b/errors/invalid-images-config.md index febbc5ee4adad..506288d294e5f 100644 --- a/errors/invalid-images-config.md +++ b/errors/invalid-images-config.md @@ -17,6 +17,7 @@ module.exports = { imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], // limit of 50 domains values domains: [], + // path prefix for Image Optimization API, useful with `loader` path: '/_next/image', // loader can be 'default', 'imgix', 'cloudinary', 'akamai', or 'custom' loader: 'default', @@ -24,6 +25,8 @@ module.exports = { disableStaticImages: false, // minimumCacheTTL is in seconds, must be integer 0 or more minimumCacheTTL: 60, + // ordered list of acceptable optimized image formats (mime types) + formats: ['image/avif', 'image/webp'], }, } ``` @@ -31,3 +34,4 @@ module.exports = { ### Useful Links - [Image Optimization Documentation](https://nextjs.org/docs/basic-features/image-optimization) +- [`next/image` Documentation](https://nextjs.org/docs/api-reference/next/image) diff --git a/errors/manifest.json b/errors/manifest.json index 7af12e799e27e..067a2908b5fb7 100644 --- a/errors/manifest.json +++ b/errors/manifest.json @@ -447,6 +447,10 @@ "title": "sharp-missing-in-production", "path": "/errors/sharp-missing-in-production.md" }, + { + "title": "sharp-version-avif", + "path": "/errors/sharp-version-avif.md" + }, { "title": "script-in-document-page", "path": "/errors/no-script-in-document-page.md" diff --git a/errors/placeholder-blur-data-url.md b/errors/placeholder-blur-data-url.md index 2fc53099c9830..9c9a76819dd78 100644 --- a/errors/placeholder-blur-data-url.md +++ b/errors/placeholder-blur-data-url.md @@ -6,7 +6,7 @@ You are attempting use the `next/image` component with `placeholder=blur` proper The `blurDataURL` might be missing because you're using a string for `src` instead of a static import. -Or `blurDataURL` might be missing because the static import is an unsupported image format. Only jpg, png, and webp are supported at this time. +Or `blurDataURL` might be missing because the static import is an unsupported image format. Only jpg, png, webp, and avif are supported at this time. #### Possible Ways to Fix It diff --git a/errors/sharp-missing-in-production.md b/errors/sharp-missing-in-production.md index 02face5fc054f..96903efad9ca5 100644 --- a/errors/sharp-missing-in-production.md +++ b/errors/sharp-missing-in-production.md @@ -16,3 +16,4 @@ You are seeing this error because Image Optimization in production mode (`next s ### Useful Links - [Image Optimization Documentation](https://nextjs.org/docs/basic-features/image-optimization) +- [`next/image` Documentation](https://nextjs.org/docs/api-reference/next/image) diff --git a/errors/sharp-version-avif.md b/errors/sharp-version-avif.md new file mode 100644 index 0000000000000..dcd90aea36629 --- /dev/null +++ b/errors/sharp-version-avif.md @@ -0,0 +1,24 @@ +# Sharp Version Does Not Support AVIF + +#### Why This Error Occurred + +The `next/image` component's default loader uses [`sharp`](https://www.npmjs.com/package/sharp) if its installed. + +You are seeing this error because you have an outdated version of [`sharp`](https://www.npmjs.com/package/sharp) installed that does not support the AVIF image format. + +AVIF support was added to [`sharp`](https://www.npmjs.com/package/sharp) in version 0.27.0 (December 2020) so your installed version is likely older. + +#### Possible Ways to Fix It + +- Install the latest version of `sharp` by running `yarn add sharp@latest` in your project directory +- If you're using the `NEXT_SHARP_PATH` environment variable, then update the `sharp` install referenced in that path, for example `cd "$NEXT_SHARP_PATH/../" && yarn add sharp@latest` +- If you cannot upgrade `sharp`, you can instead disable AVIF by configuring [`formats`](https://nextjs.org/docs/api-reference/next/image#image-formats) in your `next.config.js` + +After choosing an option above, reboot the server by running either `next dev` or `next start` for development or production respectively. + +> Note: This is not necessary for Vercel deployments, since `sharp` is installed automatically for you. + +### Useful Links + +- [Image Optimization Documentation](https://nextjs.org/docs/basic-features/image-optimization) +- [`next/image` Documentation](https://nextjs.org/docs/api-reference/next/image) diff --git a/examples/with-jest/jest.config.js b/examples/with-jest/jest.config.js index 15c4717b8e122..eacc5d367f563 100644 --- a/examples/with-jest/jest.config.js +++ b/examples/with-jest/jest.config.js @@ -14,7 +14,7 @@ module.exports = { // Handle image imports // https://jestjs.io/docs/webpack#handling-static-assets - '^.+\\.(jpg|jpeg|png|gif|webp|svg)$': `/__mocks__/fileMock.js`, + '^.+\\.(jpg|jpeg|png|gif|webp|avif|svg)$': `/__mocks__/fileMock.js`, // Handle module aliases '^@/components/(.*)$': '/components/$1', diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 007dc34f27374..8f7e032ea4247 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1051,7 +1051,7 @@ export default async function getBaseWebpackConfig( ...(!config.images.disableStaticImages ? [ { - test: /\.(png|jpg|jpeg|gif|webp|ico|bmp|svg)$/i, + test: /\.(png|jpg|jpeg|gif|webp|avif|ico|bmp|svg)$/i, loader: 'next-image-loader', issuer: { not: regexLikeCss }, dependency: { not: ['url'] }, @@ -1512,7 +1512,7 @@ export default async function getBaseWebpackConfig( // Exclude svg if the user already defined it in custom // webpack config such as `@svgr/webpack` plugin or // the `babel-plugin-inline-react-svg` plugin. - nextImageRule.test = /\.(png|jpg|jpeg|gif|webp|ico|bmp)$/i + nextImageRule.test = /\.(png|jpg|jpeg|gif|webp|avif|ico|bmp)$/i } } diff --git a/packages/next/build/webpack/config/blocks/images/index.ts b/packages/next/build/webpack/config/blocks/images/index.ts index 6ea422e5af9d8..8ffdad64333c9 100644 --- a/packages/next/build/webpack/config/blocks/images/index.ts +++ b/packages/next/build/webpack/config/blocks/images/index.ts @@ -12,7 +12,7 @@ export const images = curry(async function images( loader({ oneOf: [ { - test: /\.(png|jpg|jpeg|gif|webp|ico|bmp|svg)$/i, + test: /\.(png|jpg|jpeg|gif|webp|avif|ico|bmp|svg)$/i, use: { loader: 'error-loader', options: { diff --git a/packages/next/build/webpack/loaders/next-image-loader.js b/packages/next/build/webpack/loaders/next-image-loader.js index 44507115a18e3..281d00a6171ae 100644 --- a/packages/next/build/webpack/loaders/next-image-loader.js +++ b/packages/next/build/webpack/loaders/next-image-loader.js @@ -1,10 +1,9 @@ import loaderUtils from 'next/dist/compiled/loader-utils' -import sizeOf from 'image-size' -import { resizeImage } from '../../../server/image-optimizer' +import { resizeImage, getImageSize } from '../../../server/image-optimizer' const BLUR_IMG_SIZE = 8 const BLUR_QUALITY = 70 -const VALID_BLUR_EXT = ['jpeg', 'png', 'webp'] +const VALID_BLUR_EXT = ['jpeg', 'png', 'webp', 'avif'] // should match next/client/image.tsx function nextImageLoader(content) { const imageLoaderSpan = this.currentTraceSpan.traceChild('next-image-loader') @@ -26,7 +25,9 @@ function nextImageLoader(content) { } const imageSizeSpan = imageLoaderSpan.traceChild('image-size-calculation') - const imageSize = imageSizeSpan.traceFn(() => sizeOf(content)) + const imageSize = await imageSizeSpan.traceAsyncFn(() => + getImageSize(content, extension) + ) let blurDataURL if (VALID_BLUR_EXT.includes(extension)) { diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 15fb89f5097c8..cd317c58f21c6 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -416,7 +416,7 @@ export default function Image({ ) } if (!blurDataURL) { - const VALID_BLUR_EXT = ['jpeg', 'png', 'webp'] // should match next-image-loader + const VALID_BLUR_EXT = ['jpeg', 'png', 'webp', 'avif'] // should match next-image-loader throw new Error( `Image with src "${src}" has "placeholder='blur'" property but is missing the "blurDataURL" property. diff --git a/packages/next/image-types/global.d.ts b/packages/next/image-types/global.d.ts index a41a6c6a5a04a..1a1c9642b8b36 100644 --- a/packages/next/image-types/global.d.ts +++ b/packages/next/image-types/global.d.ts @@ -49,6 +49,12 @@ declare module '*.webp' { export default content } +declare module '*.avif' { + const content: StaticImageData + + export default content +} + declare module '*.ico' { const content: StaticImageData diff --git a/packages/next/server/config.ts b/packages/next/server/config.ts index f0ac31ab67f29..8e7224fb7b42d 100644 --- a/packages/next/server/config.ts +++ b/packages/next/server/config.ts @@ -312,6 +312,32 @@ function assignDefaults(userConfig: { [key: string]: any }) { )}), received (${images.minimumCacheTTL}).\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config` ) } + + if (images.formats) { + const { formats } = images + if (!Array.isArray(formats)) { + throw new Error( + `Specified images.formats should be an Array received ${typeof formats}.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config` + ) + } + if (formats.length < 1 || formats.length > 2) { + throw new Error( + `Specified images.formats must be length 1 or 2, received length (${formats.length}), please reduce the length of the array to continue.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config` + ) + } + + const invalid = formats.filter((f) => { + return f !== 'image/avif' && f !== 'image/webp' + }) + + if (invalid.length > 0) { + throw new Error( + `Specified images.formats should be an Array of mime type strings, received invalid values (${invalid.join( + ', ' + )}).\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config` + ) + } + } } if (result.webpack5 === false) { diff --git a/packages/next/server/image-config.ts b/packages/next/server/image-config.ts index 9102986588331..a3de1f259c381 100644 --- a/packages/next/server/image-config.ts +++ b/packages/next/server/image-config.ts @@ -8,6 +8,8 @@ export const VALID_LOADERS = [ export type LoaderValue = typeof VALID_LOADERS[number] +type ImageFormat = 'image/avif' | 'image/webp' + export type ImageConfigComplete = { deviceSizes: number[] imageSizes: number[] @@ -16,6 +18,7 @@ export type ImageConfigComplete = { domains?: string[] disableStaticImages?: boolean minimumCacheTTL?: number + formats?: ImageFormat[] } export type ImageConfig = Partial @@ -28,4 +31,5 @@ export const imageConfigDefault: ImageConfigComplete = { domains: [], disableStaticImages: false, minimumCacheTTL: 60, + formats: ['image/avif', 'image/webp'], } diff --git a/packages/next/server/image-optimizer.ts b/packages/next/server/image-optimizer.ts index 4a4227aeeb1f5..d66c3719d607a 100644 --- a/packages/next/server/image-optimizer.ts +++ b/packages/next/server/image-optimizer.ts @@ -2,6 +2,7 @@ import { mediaType } from '@hapi/accept' import { createHash } from 'crypto' import { createReadStream, promises } from 'fs' import { getOrientation, Orientation } from 'get-orientation' +import imageSizeOf from 'image-size' import { IncomingMessage, ServerResponse } from 'http' // @ts-ignore no types for is-animated import isAnimated from 'next/dist/compiled/is-animated' @@ -11,20 +12,19 @@ import nodeUrl, { UrlWithParsedQuery } from 'url' import { NextConfig } from './config-shared' import { fileExists } from '../lib/file-exists' import { ImageConfig, imageConfigDefault } from './image-config' -import { processBuffer, Operation } from './lib/squoosh/main' +import { processBuffer, decodeBuffer, Operation } from './lib/squoosh/main' import Server from './next-server' import { sendEtagResponse } from './send-payload' import { getContentType, getExtension } from './serve-static' import chalk from 'chalk' -//const AVIF = 'image/avif' +const AVIF = 'image/avif' const WEBP = 'image/webp' const PNG = 'image/png' const JPEG = 'image/jpeg' const GIF = 'image/gif' const SVG = 'image/svg+xml' const CACHE_VERSION = 3 -const MODERN_TYPES = [/* AVIF, */ WEBP] const ANIMATABLE_TYPES = [WEBP, PNG, GIF] const VECTOR_TYPES = [SVG] const BLUR_IMG_SIZE = 8 // should match `next-image-loader` @@ -43,7 +43,7 @@ try { // Sharp not present on the server, Squoosh fallback will be used } -let shouldShowSharpWarning = process.env.NODE_ENV === 'production' +let showSharpMissingWarning = process.env.NODE_ENV === 'production' export async function imageOptimizer( server: Server, @@ -61,6 +61,7 @@ export async function imageOptimizer( domains = [], loader, minimumCacheTTL = 60, + formats = ['image/avif', 'image/webp'], } = imageData if (loader !== 'default') { @@ -70,7 +71,7 @@ export async function imageOptimizer( const { headers } = req const { url, w, q } = parsedUrl.query - const mimeType = getSupportedMimeType(MODERN_TYPES, headers.accept) + const mimeType = getSupportedMimeType(formats, headers.accept) let href: string if (!url) { @@ -359,7 +360,18 @@ export async function imageOptimizer( transformer.resize(width) } - if (contentType === WEBP) { + if (contentType === AVIF) { + if (transformer.avif) { + transformer.avif({ quality }) + } else { + console.warn( + chalk.yellow.bold('Warning: ') + + `Your installed version of the 'sharp' package does not support AVIF images. Run 'yarn add sharp@latest' to upgrade to the latest version.\n` + + 'Read more: https://nextjs.org/docs/messages/sharp-version-avif' + ) + transformer.webp({ quality }) + } + } else if (contentType === WEBP) { transformer.webp({ quality }) } else if (contentType === PNG) { transformer.png({ quality }) @@ -371,13 +383,13 @@ export async function imageOptimizer( // End sharp transformation logic } else { // Show sharp warning in production once - if (shouldShowSharpWarning) { + if (showSharpMissingWarning) { console.warn( chalk.yellow.bold('Warning: ') + `For production Image Optimization with Next.js, the optional 'sharp' package is strongly recommended. Run 'yarn add sharp', and Next.js will use it automatically for Image Optimization.\n` + 'Read more: https://nextjs.org/docs/messages/sharp-missing-in-production' ) - shouldShowSharpWarning = false + showSharpMissingWarning = false } // Begin Squoosh transformation logic @@ -399,9 +411,14 @@ export async function imageOptimizer( operations.push({ type: 'resize', width }) - //if (contentType === AVIF) { - //} else - if (contentType === WEBP) { + if (contentType === AVIF) { + optimizedBuffer = await processBuffer( + upstreamBuffer, + operations, + 'avif', + quality + ) + } else if (contentType === WEBP) { optimizedBuffer = await processBuffer( upstreamBuffer, operations, @@ -620,6 +637,13 @@ export function detectContentType(buffer: Buffer) { if ([0x3c, 0x3f, 0x78, 0x6d, 0x6c].every((b, i) => buffer[i] === b)) { return SVG } + if ( + [0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66].every( + (b, i) => !b || buffer[i] === b + ) + ) { + return AVIF + } return null } @@ -642,13 +666,25 @@ export async function resizeImage( content: Buffer, dimension: 'width' | 'height', size: number, - extension: 'webp' | 'png' | 'jpeg', + // Should match VALID_BLUR_EXT + extension: 'avif' | 'webp' | 'png' | 'jpeg', quality: number ): Promise { if (sharp) { const transformer = sharp(content) - if (extension === 'webp') { + if (extension === 'avif') { + if (transformer.avif) { + transformer.avif({ quality }) + } else { + console.warn( + chalk.yellow.bold('Warning: ') + + `Your installed version of the 'sharp' package does not support AVIF images. Run 'yarn add sharp@latest' to upgrade to the latest version.\n` + + 'Read more: https://nextjs.org/docs/messages/sharp-version-avif' + ) + transformer.webp({ quality }) + } + } else if (extension === 'webp') { transformer.webp({ quality }) } else if (extension === 'png') { transformer.png({ quality }) @@ -676,3 +712,28 @@ export async function resizeImage( return buf } } + +export async function getImageSize( + buffer: Buffer, + // Should match VALID_BLUR_EXT + extension: 'avif' | 'webp' | 'png' | 'jpeg' +): Promise<{ + width?: number + height?: number +}> { + // TODO: upgrade "image-size" package to support AVIF + // See https://github.com/image-size/image-size/issues/348 + if (extension === 'avif') { + if (sharp) { + const transformer = sharp(buffer) + const { width, height } = await transformer.metadata() + return { width, height } + } else { + const { width, height } = await decodeBuffer(buffer) + return { width, height } + } + } + + const { width, height } = imageSizeOf(buffer) + return { width, height } +} diff --git a/packages/next/server/lib/squoosh/main.ts b/packages/next/server/lib/squoosh/main.ts index 519e57245680a..3b9077ea6e298 100644 --- a/packages/next/server/lib/squoosh/main.ts +++ b/packages/next/server/lib/squoosh/main.ts @@ -72,3 +72,9 @@ export async function processBuffer( throw Error(`Unsupported encoding format`) } } + +export async function decodeBuffer(buffer: Buffer) { + const worker: typeof import('./impl') = getWorker() as any + const imageData = await worker.decodeBuffer(buffer) + return imageData +} diff --git a/packages/next/server/serve-static.ts b/packages/next/server/serve-static.ts index 52826b92e5f7e..7c2a441c6b75c 100644 --- a/packages/next/server/serve-static.ts +++ b/packages/next/server/serve-static.ts @@ -21,6 +21,10 @@ export function serveStatic( } export function getContentType(extWithoutDot: string): string | null { + if (extWithoutDot === 'avif') { + // TODO: update "mime" package + return 'image/avif' + } const { mime } = send if ('getType' in mime) { // 2.0 @@ -31,6 +35,10 @@ export function getContentType(extWithoutDot: string): string | null { } export function getExtension(contentType: string): string | null { + if (contentType === 'image/avif') { + // TODO: update "mime" package + return 'avif' + } const { mime } = send if ('getExtension' in mime) { // 2.0 diff --git a/test/integration/image-component/base-path/pages/static-img.js b/test/integration/image-component/base-path/pages/static-img.js index 29912c6defc24..ee07521ec8ac1 100644 --- a/test/integration/image-component/base-path/pages/static-img.js +++ b/test/integration/image-component/base-path/pages/static-img.js @@ -5,6 +5,7 @@ import Image from 'next/image' import testJPG from '../public/test.jpg' import testPNG from '../public/test.png' import testWEBP from '../public/test.webp' +import testAVIF from '../public/test.avif' import testSVG from '../public/test.svg' import testGIF from '../public/test.gif' import testBMP from '../public/test.bmp' @@ -41,6 +42,7 @@ const Page = () => { + diff --git a/test/integration/image-component/base-path/public/test.avif b/test/integration/image-component/base-path/public/test.avif new file mode 100644 index 0000000000000000000000000000000000000000..e2c8170a6833ebff1ae4e11b9abff1bee6f4bdeb GIT binary patch literal 1043 zcmZQzU{FXasVqn=%S>Yc0uY^>nP!-qnV9D5Xy^nK`jnemk_eIm0*#E6oFWL5fuSHX zxdg@r(K(q(Fk|=%GD~v7a*RMyE;A=T8N_p8U|HGYPp_`;RN zQI{$oYx^~h{C(Tl1>lMeCPK)4A9urjDmcF}Sym>Oq zuQvx$8Y^FDRZDZOWigVCZeiEkrgxdQRO`S}W#70Swno%#aR?0O_-fq@A z-td)488LdsvuqsOrJH7$mpA;W{kt{rw6ka2>P6SsmbvX-Aywq>sQL4q8{^?~mjnJg z20obKDzi<|bg6crjj7nL&JLcE+fO_VBRQ8@g@t_nB@%R!>CSEYYUf!`7hLns*tTd> zSWy-Gb7sq_oii%;?o^*IcRoF4R$A*kG5^U`OFsSW3(c9ZQt8M zob`Q+N!|6H`07RK4oh-e^;{*{7S<|t-8djNcUhnIfm{C!4jj3$VX>{8g{4pX*>^6T zT(>g5aGCZtO|AQ=c0|p&tk~r!&%xKXR-Whno43-~uSGn0M(wpNqM?_#CeJPGxvD(z z{6Zl!X%nq=i^}`Lg)}a;PG*i{bJ7WFX4p~rhiM^mP+0QgIn%Gt(YT`Pp7ZwbcCmX8 zWVc7J-??N}_n$*M6TdNLr07%x{9L-mp21sQc-6yijyltyR=BP#OPsFth@+or&m-O` ai*%+vXFpb>?I3lmL0LhJC*)VwLI(iE8=mC= literal 0 HcmV?d00001 diff --git a/test/integration/image-component/base-path/test/static.test.js b/test/integration/image-component/base-path/test/static.test.js index 2940de3984557..42f6d197c034d 100644 --- a/test/integration/image-component/base-path/test/static.test.js +++ b/test/integration/image-component/base-path/test/static.test.js @@ -24,6 +24,7 @@ const runTests = (isDev = false) => { expect(await browser.elementById('basic-static')).toBeTruthy() expect(await browser.elementById('blur-png')).toBeTruthy() expect(await browser.elementById('blur-webp')).toBeTruthy() + expect(await browser.elementById('blur-avif')).toBeTruthy() expect(await browser.elementById('blur-jpg')).toBeTruthy() expect(await browser.elementById('static-svg')).toBeTruthy() expect(await browser.elementById('static-gif')).toBeTruthy() diff --git a/test/integration/image-component/default/pages/static-img.js b/test/integration/image-component/default/pages/static-img.js index 29912c6defc24..ee07521ec8ac1 100644 --- a/test/integration/image-component/default/pages/static-img.js +++ b/test/integration/image-component/default/pages/static-img.js @@ -5,6 +5,7 @@ import Image from 'next/image' import testJPG from '../public/test.jpg' import testPNG from '../public/test.png' import testWEBP from '../public/test.webp' +import testAVIF from '../public/test.avif' import testSVG from '../public/test.svg' import testGIF from '../public/test.gif' import testBMP from '../public/test.bmp' @@ -41,6 +42,7 @@ const Page = () => { + diff --git a/test/integration/image-component/default/public/test.avif b/test/integration/image-component/default/public/test.avif new file mode 100644 index 0000000000000000000000000000000000000000..e2c8170a6833ebff1ae4e11b9abff1bee6f4bdeb GIT binary patch literal 1043 zcmZQzU{FXasVqn=%S>Yc0uY^>nP!-qnV9D5Xy^nK`jnemk_eIm0*#E6oFWL5fuSHX zxdg@r(K(q(Fk|=%GD~v7a*RMyE;A=T8N_p8U|HGYPp_`;RN zQI{$oYx^~h{C(Tl1>lMeCPK)4A9urjDmcF}Sym>Oq zuQvx$8Y^FDRZDZOWigVCZeiEkrgxdQRO`S}W#70Swno%#aR?0O_-fq@A z-td)488LdsvuqsOrJH7$mpA;W{kt{rw6ka2>P6SsmbvX-Aywq>sQL4q8{^?~mjnJg z20obKDzi<|bg6crjj7nL&JLcE+fO_VBRQ8@g@t_nB@%R!>CSEYYUf!`7hLns*tTd> zSWy-Gb7sq_oii%;?o^*IcRoF4R$A*kG5^U`OFsSW3(c9ZQt8M zob`Q+N!|6H`07RK4oh-e^;{*{7S<|t-8djNcUhnIfm{C!4jj3$VX>{8g{4pX*>^6T zT(>g5aGCZtO|AQ=c0|p&tk~r!&%xKXR-Whno43-~uSGn0M(wpNqM?_#CeJPGxvD(z z{6Zl!X%nq=i^}`Lg)}a;PG*i{bJ7WFX4p~rhiM^mP+0QgIn%Gt(YT`Pp7ZwbcCmX8 zWVc7J-??N}_n$*M6TdNLr07%x{9L-mp21sQc-6yijyltyR=BP#OPsFth@+or&m-O` ai*%+vXFpb>?I3lmL0LhJC*)VwLI(iE8=mC= literal 0 HcmV?d00001 diff --git a/test/integration/image-component/default/test/static.test.js b/test/integration/image-component/default/test/static.test.js index 32ef254504401..55c7802d958a6 100644 --- a/test/integration/image-component/default/test/static.test.js +++ b/test/integration/image-component/default/test/static.test.js @@ -23,6 +23,7 @@ const runTests = () => { expect(await browser.elementById('basic-static')).toBeTruthy() expect(await browser.elementById('blur-png')).toBeTruthy() expect(await browser.elementById('blur-webp')).toBeTruthy() + expect(await browser.elementById('blur-avif')).toBeTruthy() expect(await browser.elementById('blur-jpg')).toBeTruthy() expect(await browser.elementById('static-svg')).toBeTruthy() expect(await browser.elementById('static-gif')).toBeTruthy() diff --git a/test/integration/image-component/typescript/pages/valid.tsx b/test/integration/image-component/typescript/pages/valid.tsx index 09f4e287e298b..8747c8e813698 100644 --- a/test/integration/image-component/typescript/pages/valid.tsx +++ b/test/integration/image-component/typescript/pages/valid.tsx @@ -2,6 +2,7 @@ import React from 'react' import Image from 'next/image' import testTall from '../public/tall.png' import svg from '../public/test.svg' +import avif from '../public/test.avif' import { ImageCard } from '../components/image-card' import { DynamicSrcImage } from '../components/image-dynamic-src' @@ -77,6 +78,7 @@ const Page = () => { placeholder="blur" /> + Yc0uY^>nP!-qnV9D5Xy^nK`jnemk_eIm0*#E6oFWL5fuSHX zxdg@r(K(q(Fk|=%GD~v7a*RMyE;A=T8N_p8U|HGYPp_`;RN zQI{$oYx^~h{C(Tl1>lMeCPK)4A9urjDmcF}Sym>Oq zuQvx$8Y^FDRZDZOWigVCZeiEkrgxdQRO`S}W#70Swno%#aR?0O_-fq@A z-td)488LdsvuqsOrJH7$mpA;W{kt{rw6ka2>P6SsmbvX-Aywq>sQL4q8{^?~mjnJg z20obKDzi<|bg6crjj7nL&JLcE+fO_VBRQ8@g@t_nB@%R!>CSEYYUf!`7hLns*tTd> zSWy-Gb7sq_oii%;?o^*IcRoF4R$A*kG5^U`OFsSW3(c9ZQt8M zob`Q+N!|6H`07RK4oh-e^;{*{7S<|t-8djNcUhnIfm{C!4jj3$VX>{8g{4pX*>^6T zT(>g5aGCZtO|AQ=c0|p&tk~r!&%xKXR-Whno43-~uSGn0M(wpNqM?_#CeJPGxvD(z z{6Zl!X%nq=i^}`Lg)}a;PG*i{bJ7WFX4p~rhiM^mP+0QgIn%Gt(YT`Pp7ZwbcCmX8 zWVc7J-??N}_n$*M6TdNLr07%x{9L-mp21sQc-6yijyltyR=BP#OPsFth@+or&m-O` ai*%+vXFpb>?I3lmL0LhJC*)VwLI(iE8=mC= literal 0 HcmV?d00001 diff --git a/test/integration/image-optimizer/test/index.test.js b/test/integration/image-optimizer/test/index.test.js index 14a25173d5cf8..90dbc9203d56f 100644 --- a/test/integration/image-optimizer/test/index.test.js +++ b/test/integration/image-optimizer/test/index.test.js @@ -25,6 +25,7 @@ let appPort let app const sharpMissingText = `For production Image Optimization with Next.js, the optional 'sharp' package is strongly recommended` +const sharpOutdatedText = `Your installed version of the 'sharp' package does not support AVIF images. Run 'yarn add sharp@latest' to upgrade to the latest version` async function fsToJson(dir, output = {}) { const files = await fs.readdir(dir) @@ -47,7 +48,7 @@ async function expectWidth(res, w) { expect(d.width).toBe(w) } -function runTests({ w, isDev, domains = [], ttl, isSharp }) { +function runTests({ w, isDev, domains = [], ttl, isSharp, isOutdatedSharp }) { it('should return home page', async () => { const res = await fetchViaHTTP(appPort, '/', null, {}) expect(await res.text()).toMatch(/Image Optimizer Home/m) @@ -359,10 +360,10 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { // FIXME: await expectWidth(res, w) }) - it('should resize relative url and Chrome accept header as webp', async () => { + it('should resize relative url and old Chrome accept header as webp', async () => { const query = { url: '/test.png', w, q: 80 } const opts = { - headers: { accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8' }, + headers: { accept: 'image/webp,image/apng,image/*,*/*;q=0.8' }, } const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res.status).toBe(200) @@ -378,6 +379,27 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { await expectWidth(res, w) }) + it('should resize relative url and new Chrome accept header as avif', async () => { + const query = { url: '/test.png', w, q: 80 } + const opts = { + headers: { accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8' }, + } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/avif') + expect(res.headers.get('Cache-Control')).toBe( + `public, max-age=0, must-revalidate` + ) + expect(res.headers.get('Vary')).toBe('Accept') + expect(res.headers.get('etag')).toBeTruthy() + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="test.avif"` + ) + // TODO: upgrade "image-size" package to support AVIF + // See https://github.com/image-size/image-size/issues/348 + //await expectWidth(res, w) + }) + if (domains.includes('localhost')) { it('should resize absolute url from localhost', async () => { const url = `http://localhost:${appPort}/test.png` @@ -733,6 +755,16 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { expect(nextOutput).toContain(sharpMissingText) }) } + + if (isSharp && isOutdatedSharp) { + it('should have sharp outdated warning', () => { + expect(nextOutput).toContain(sharpOutdatedText) + }) + } else { + it('should not have sharp outdated warning', () => { + expect(nextOutput).not.toContain(sharpOutdatedText) + }) + } } describe('Image Optimizer', () => { @@ -888,6 +920,31 @@ describe('Image Optimizer', () => { /Error: Image with src "(.+)" is missing "loader" prop/ ) }) + + it('should error when images.formats contains invalid values', async () => { + await nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + images: { + formats: ['image/avif', 'jpeg'], + }, + }) + ) + let stderr = '' + + app = await launchApp(appDir, await findPort(), { + onStderr(msg) { + stderr += msg || '' + }, + }) + await waitFor(1000) + await killApp(app).catch(() => {}) + await nextConfig.restore() + + expect(stderr).toContain( + `Specified images.formats should be an Array of mime type strings, received invalid values (jpeg)` + ) + }) }) // domains for testing @@ -1077,7 +1134,7 @@ describe('Image Optimizer', () => { }) }) - const setupTests = (isSharp = false) => { + const setupTests = ({ isSharp = false, isOutdatedSharp = false }) => { describe('dev support w/o next.config.js', () => { const size = 384 // defaults defined in server/config.ts beforeAll(async () => { @@ -1087,6 +1144,11 @@ describe('Image Optimizer', () => { onStderr(msg) { nextOutput += msg }, + env: { + NEXT_SHARP_PATH: isSharp + ? join(appDir, 'node_modules', 'sharp') + : '', + }, cwd: appDir, }) }) @@ -1095,7 +1157,7 @@ describe('Image Optimizer', () => { await fs.remove(imagesDir) }) - runTests({ w: size, isDev: true, domains: [], isSharp }) + runTests({ w: size, isDev: true, domains: [], isSharp, isOutdatedSharp }) }) describe('dev support with next.config.js', () => { @@ -1115,6 +1177,11 @@ describe('Image Optimizer', () => { onStderr(msg) { nextOutput += msg }, + env: { + NEXT_SHARP_PATH: isSharp + ? join(appDir, 'node_modules', 'sharp') + : '', + }, cwd: appDir, }) }) @@ -1124,7 +1191,7 @@ describe('Image Optimizer', () => { await fs.remove(imagesDir) }) - runTests({ w: size, isDev: true, domains, isSharp }) + runTests({ w: size, isDev: true, domains, isSharp, isOutdatedSharp }) }) describe('Server support w/o next.config.js', () => { @@ -1139,9 +1206,7 @@ describe('Image Optimizer', () => { }, env: { NEXT_SHARP_PATH: isSharp - ? require.resolve('sharp', { - paths: [join(appDir, 'node_modules')], - }) + ? join(appDir, 'node_modules', 'sharp') : '', }, cwd: appDir, @@ -1152,7 +1217,7 @@ describe('Image Optimizer', () => { await fs.remove(imagesDir) }) - runTests({ w: size, isDev: false, domains: [], isSharp }) + runTests({ w: size, isDev: false, domains: [], isSharp, isOutdatedSharp }) }) describe('Server support with next.config.js', () => { @@ -1174,9 +1239,7 @@ describe('Image Optimizer', () => { }, env: { NEXT_SHARP_PATH: isSharp - ? require.resolve('sharp', { - paths: [join(appDir, 'node_modules')], - }) + ? join(appDir, 'node_modules', 'sharp') : '', }, cwd: appDir, @@ -1188,15 +1251,15 @@ describe('Image Optimizer', () => { await fs.remove(imagesDir) }) - runTests({ w: size, isDev: false, domains, isSharp }) + runTests({ w: size, isDev: false, domains, isSharp, isOutdatedSharp }) }) } describe('with squoosh', () => { - setupTests() + setupTests({ isSharp: false, isOutdatedSharp: false }) }) - describe('with sharp', () => { + describe('with latest sharp', () => { beforeAll(async () => { await execa('yarn', ['init', '-y'], { cwd: appDir, @@ -1213,6 +1276,26 @@ describe('Image Optimizer', () => { await fs.remove(join(appDir, 'package.json')) }) - setupTests(true) + setupTests({ isSharp: true, isOutdatedSharp: false }) + }) + + describe('with outdated sharp', () => { + beforeAll(async () => { + await execa('yarn', ['init', '-y'], { + cwd: appDir, + stdio: 'inherit', + }) + await execa('yarn', ['add', 'sharp@0.26.3'], { + cwd: appDir, + stdio: 'inherit', + }) + }) + afterAll(async () => { + await fs.remove(join(appDir, 'node_modules')) + await fs.remove(join(appDir, 'yarn.lock')) + await fs.remove(join(appDir, 'package.json')) + }) + + setupTests({ isSharp: true, isOutdatedSharp: true }) }) }) diff --git a/test/unit/image-optimizer/detect-content-type.test.ts b/test/unit/image-optimizer/detect-content-type.test.ts index b94d613fbbba6..6455a3313b015 100644 --- a/test/unit/image-optimizer/detect-content-type.test.ts +++ b/test/unit/image-optimizer/detect-content-type.test.ts @@ -22,4 +22,8 @@ describe('detectContentType', () => { const buffer = await getImage('./images/test.svg') expect(detectContentType(buffer)).toBe('image/svg+xml') }) + it('should return avif', async () => { + const buffer = await getImage('./images/test.avif') + expect(detectContentType(buffer)).toBe('image/avif') + }) }) diff --git a/test/unit/image-optimizer/images/test.avif b/test/unit/image-optimizer/images/test.avif new file mode 100644 index 0000000000000000000000000000000000000000..e2c8170a6833ebff1ae4e11b9abff1bee6f4bdeb GIT binary patch literal 1043 zcmZQzU{FXasVqn=%S>Yc0uY^>nP!-qnV9D5Xy^nK`jnemk_eIm0*#E6oFWL5fuSHX zxdg@r(K(q(Fk|=%GD~v7a*RMyE;A=T8N_p8U|HGYPp_`;RN zQI{$oYx^~h{C(Tl1>lMeCPK)4A9urjDmcF}Sym>Oq zuQvx$8Y^FDRZDZOWigVCZeiEkrgxdQRO`S}W#70Swno%#aR?0O_-fq@A z-td)488LdsvuqsOrJH7$mpA;W{kt{rw6ka2>P6SsmbvX-Aywq>sQL4q8{^?~mjnJg z20obKDzi<|bg6crjj7nL&JLcE+fO_VBRQ8@g@t_nB@%R!>CSEYYUf!`7hLns*tTd> zSWy-Gb7sq_oii%;?o^*IcRoF4R$A*kG5^U`OFsSW3(c9ZQt8M zob`Q+N!|6H`07RK4oh-e^;{*{7S<|t-8djNcUhnIfm{C!4jj3$VX>{8g{4pX*>^6T zT(>g5aGCZtO|AQ=c0|p&tk~r!&%xKXR-Whno43-~uSGn0M(wpNqM?_#CeJPGxvD(z z{6Zl!X%nq=i^}`Lg)}a;PG*i{bJ7WFX4p~rhiM^mP+0QgIn%Gt(YT`Pp7ZwbcCmX8 zWVc7J-??N}_n$*M6TdNLr07%x{9L-mp21sQc-6yijyltyR=BP#OPsFth@+or&m-O` ai*%+vXFpb>?I3lmL0LhJC*)VwLI(iE8=mC= literal 0 HcmV?d00001