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)
This commit is contained in:
parent
13f68debfe
commit
cc1f3b8a38
30 changed files with 302 additions and 43 deletions
|
@ -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.<br/>`placeholder` prop added.<br/>`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:
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -180,7 +180,8 @@ module.exports = {
|
|||
|
||||
/* Handle image imports
|
||||
https://jestjs.io/docs/webpack#handling-static-assets */
|
||||
'^.+\\.(jpg|jpeg|png|gif|webp|svg)$': '<rootDir>/__mocks__/fileMock.js',
|
||||
'^.+\\.(jpg|jpeg|png|gif|webp|avif|svg)$':
|
||||
'<rootDir>/__mocks__/fileMock.js',
|
||||
},
|
||||
testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/.next/'],
|
||||
testEnvironment: 'jsdom',
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
24
errors/sharp-version-avif.md
Normal file
24
errors/sharp-version-avif.md
Normal file
|
@ -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)
|
|
@ -14,7 +14,7 @@ module.exports = {
|
|||
|
||||
// Handle image imports
|
||||
// https://jestjs.io/docs/webpack#handling-static-assets
|
||||
'^.+\\.(jpg|jpeg|png|gif|webp|svg)$': `<rootDir>/__mocks__/fileMock.js`,
|
||||
'^.+\\.(jpg|jpeg|png|gif|webp|avif|svg)$': `<rootDir>/__mocks__/fileMock.js`,
|
||||
|
||||
// Handle module aliases
|
||||
'^@/components/(.*)$': '<rootDir>/components/$1',
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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.
|
||||
|
|
6
packages/next/image-types/global.d.ts
vendored
6
packages/next/image-types/global.d.ts
vendored
|
@ -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
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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<ImageConfigComplete>
|
||||
|
@ -28,4 +31,5 @@ export const imageConfigDefault: ImageConfigComplete = {
|
|||
domains: [],
|
||||
disableStaticImages: false,
|
||||
minimumCacheTTL: 60,
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
}
|
||||
|
|
|
@ -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<Buffer> {
|
||||
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 }
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = () => {
|
|||
<Image id="blur-png" src={testPNG} placeholder="blur" />
|
||||
<Image id="blur-jpg" src={testJPG} placeholder="blur" />
|
||||
<Image id="blur-webp" src={testWEBP} placeholder="blur" />
|
||||
<Image id="blur-avif" src={testAVIF} placeholder="blur" />
|
||||
<Image id="static-svg" src={testSVG} />
|
||||
<Image id="static-gif" src={testGIF} />
|
||||
<Image id="static-bmp" src={testBMP} />
|
||||
|
|
BIN
test/integration/image-component/base-path/public/test.avif
Normal file
BIN
test/integration/image-component/base-path/public/test.avif
Normal file
Binary file not shown.
|
@ -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()
|
||||
|
|
|
@ -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 = () => {
|
|||
<Image id="blur-png" src={testPNG} placeholder="blur" />
|
||||
<Image id="blur-jpg" src={testJPG} placeholder="blur" />
|
||||
<Image id="blur-webp" src={testWEBP} placeholder="blur" />
|
||||
<Image id="blur-avif" src={testAVIF} placeholder="blur" />
|
||||
<Image id="static-svg" src={testSVG} />
|
||||
<Image id="static-gif" src={testGIF} />
|
||||
<Image id="static-bmp" src={testBMP} />
|
||||
|
|
BIN
test/integration/image-component/default/public/test.avif
Normal file
BIN
test/integration/image-component/default/public/test.avif
Normal file
Binary file not shown.
|
@ -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()
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
<Image id="object-src-with-svg" src={svg} />
|
||||
<Image id="object-src-with-avif" src={avif} />
|
||||
<Image
|
||||
id="fill-with-unused-width-height"
|
||||
src="https://via.placeholder.com/200"
|
||||
|
|
BIN
test/integration/image-component/typescript/public/test.avif
Normal file
BIN
test/integration/image-component/typescript/public/test.avif
Normal file
Binary file not shown.
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
BIN
test/unit/image-optimizer/images/test.avif
Normal file
BIN
test/unit/image-optimizer/images/test.avif
Normal file
Binary file not shown.
Loading…
Reference in a new issue