Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use Sharp if available for Image Optimization #27346

Merged
merged 21 commits into from
Jul 22, 2021
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/basic-features/image-optimization.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ If you need a different provider, you can use the [`loader`](/docs/api-reference

> The `next/image` component's default loader is not supported when using [`next export`](/docs/advanced-features/static-html-export.md). However, other loader options will work.
> The `next/image` component's default loader uses the ['squoosh'](https://www.npmjs.com/package/@squoosh/lib) library for image resizing and optimization. This library is quick to install and suitable for a dev server environment. For a production environment, it is strongly recommended that you install the optional [`sharp`](https://www.npmjs.com/package/sharp) library by running `yarn add sharp` in your project directory. If sharp is already installed but can't be resolved you can manually pass the path to it via the `NEXT_SHARP_PATH` environment variable e.g. `NEXT_SHARP_PATH=/tmp/node_modules/sharp`
## Caching

The following describes the caching algorithm for the default [loader](#loader). For all other loaders, please refer to your cloud provider's documentation.
Expand Down
4 changes: 4 additions & 0 deletions errors/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,10 @@
{
"title": "page-data-collection-timeout",
"path": "/errors/page-data-collection-timeout.md"
},
{
"titlle": "sharp-missing-in-production",
styfle marked this conversation as resolved.
Show resolved Hide resolved
"path": "/errors/sharp-missing-in-production.md"
}
]
}
Expand Down
13 changes: 13 additions & 0 deletions errors/sharp-missing-in-production.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Sharp Missing In Production

#### Why This Error Occurred

The `next/image` component's default loader uses the ['squoosh'](https://www.npmjs.com/package/@squoosh/lib) library for image resizing and optimization. This library is quick to install and suitable for a dev server environment. For a production environment, it is strongly recommended that you install the optional [`sharp`](https://www.npmjs.com/package/sharp). This package was not detected when leveraging the Image Optimization in production mode (`next start`).

#### Possible Ways to Fix It

Install `sharp` by running `yarn add sharp` in your project directory.

### Useful Links

- [Image Optimization Documentation](https://nextjs.org/docs/basic-features/image-optimization)
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@types/fs-extra": "8.1.0",
"@types/http-proxy": "1.17.3",
"@types/jest": "24.0.13",
"@types/sharp": "0.28.4",
"@types/string-hash": "1.1.1",
"@typescript-eslint/eslint-plugin": "4.22.0",
"@typescript-eslint/parser": "4.22.0",
Expand Down
16 changes: 10 additions & 6 deletions packages/next/build/webpack/loaders/next-image-loader.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import loaderUtils from 'next/dist/compiled/loader-utils'
import sizeOf from 'image-size'
import { processBuffer } from '../../../server/lib/squoosh/main'
import { resizeImage } from '../../../server/image-optimizer'

const BLUR_IMG_SIZE = 8
const BLUR_QUALITY = 70
Expand Down Expand Up @@ -38,14 +38,18 @@ function nextImageLoader(content) {
blurDataURL = url.href.slice(prefix.length)
} else {
// Shrink the image's largest dimension
const resizeOperationOpts =
imageSize.width >= imageSize.height
? { type: 'resize', width: BLUR_IMG_SIZE }
: { type: 'resize', height: BLUR_IMG_SIZE }
const dimension =
imageSize.width >= imageSize.height ? 'width' : 'height'

const resizeImageSpan = imageLoaderSpan.traceChild('image-resize')
const resizedImage = await resizeImageSpan.traceAsyncFn(() =>
processBuffer(content, [resizeOperationOpts], extension, BLUR_QUALITY)
resizeImage(
content,
dimension,
BLUR_IMG_SIZE,
extension,
BLUR_QUALITY
)
)
const blurDataURLSpan = imageLoaderSpan.traceChild(
'image-base64-tostring'
Expand Down
168 changes: 129 additions & 39 deletions packages/next/server/image-optimizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { processBuffer, 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 WEBP = 'image/webp'
Expand All @@ -29,6 +30,21 @@ const VECTOR_TYPES = [SVG]
const BLUR_IMG_SIZE = 8 // should match `next-image-loader`
const inflightRequests = new Map<string, Promise<undefined>>()

let sharp:
| ((
input?: string | Buffer,
options?: import('sharp').SharpOptions
) => import('sharp').Sharp)
| undefined

try {
sharp = require(process.env.NEXT_SHARP_PATH || 'sharp')
} catch (e) {
// Sharp not present on the server, Squoosh fallback will be used
}

let shouldShowSharpWarning = process.env.NODE_ENV === 'production'

export async function imageOptimizer(
server: Server,
req: IncomingMessage,
Expand Down Expand Up @@ -322,52 +338,87 @@ export async function imageOptimizer(
} else {
contentType = JPEG
}

try {
const orientation = await getOrientation(upstreamBuffer)
let optimizedBuffer: Buffer | undefined
if (sharp) {
// Begin sharp transformation logic
const transformer = sharp(upstreamBuffer)

const operations: Operation[] = []
transformer.rotate()

const { width: metaWidth } = await transformer.metadata()

if (metaWidth && metaWidth > width) {
transformer.resize(width)
}

if (contentType === WEBP) {
transformer.webp({ quality })
} else if (contentType === PNG) {
transformer.png({ quality })
} else if (contentType === JPEG) {
transformer.jpeg({ quality })
}

if (orientation === Orientation.RIGHT_TOP) {
operations.push({ type: 'rotate', numRotations: 1 })
} else if (orientation === Orientation.BOTTOM_RIGHT) {
operations.push({ type: 'rotate', numRotations: 2 })
} else if (orientation === Orientation.LEFT_BOTTOM) {
operations.push({ type: 'rotate', numRotations: 3 })
optimizedBuffer = await transformer.toBuffer()
// End sharp transformation logic
} else {
// TODO: support more orientations
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// const _: never = orientation
}
// Show sharp warning in production once
if (shouldShowSharpWarning) {
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/improper-devtool'
ijjk marked this conversation as resolved.
Show resolved Hide resolved
)
shouldShowSharpWarning = false
}

operations.push({ type: 'resize', width })
// Begin Squoosh transformation logic
const orientation = await getOrientation(upstreamBuffer)

let optimizedBuffer: Buffer | undefined
//if (contentType === AVIF) {
//} else
if (contentType === WEBP) {
optimizedBuffer = await processBuffer(
upstreamBuffer,
operations,
'webp',
quality
)
} else if (contentType === PNG) {
optimizedBuffer = await processBuffer(
upstreamBuffer,
operations,
'png',
quality
)
} else if (contentType === JPEG) {
optimizedBuffer = await processBuffer(
upstreamBuffer,
operations,
'jpeg',
quality
)
}
const operations: Operation[] = []

if (orientation === Orientation.RIGHT_TOP) {
operations.push({ type: 'rotate', numRotations: 1 })
} else if (orientation === Orientation.BOTTOM_RIGHT) {
operations.push({ type: 'rotate', numRotations: 2 })
} else if (orientation === Orientation.LEFT_BOTTOM) {
operations.push({ type: 'rotate', numRotations: 3 })
} else {
// TODO: support more orientations
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// const _: never = orientation
}

operations.push({ type: 'resize', width })

//if (contentType === AVIF) {
//} else
if (contentType === WEBP) {
optimizedBuffer = await processBuffer(
upstreamBuffer,
operations,
'webp',
quality
)
} else if (contentType === PNG) {
optimizedBuffer = await processBuffer(
upstreamBuffer,
operations,
'png',
quality
)
} else if (contentType === JPEG) {
optimizedBuffer = await processBuffer(
upstreamBuffer,
operations,
'jpeg',
quality
)
}

// End Squoosh transformation logic
}
if (optimizedBuffer) {
await writeToCacheDir(
hashDir,
Expand Down Expand Up @@ -551,3 +602,42 @@ export function getMaxAge(str: string | null): number {
}
return 0
}

export async function resizeImage(
content: Buffer,
dimension: 'width' | 'height',
size: number,
extension: 'webp' | 'png' | 'jpeg',
quality: number
): Promise<Buffer> {
if (sharp) {
const transformer = sharp(content)

if (extension === 'webp') {
transformer.webp({ quality })
} else if (extension === 'png') {
transformer.png({ quality })
} else if (extension === 'jpeg') {
transformer.jpeg({ quality })
}
if (dimension === 'width') {
transformer.resize(size)
} else {
transformer.resize(null, size)
}
const buf = await transformer.toBuffer()
return buf
} else {
const resizeOperationOpts: Operation =
dimension === 'width'
? { type: 'resize', width: size }
: { type: 'resize', height: size }
const buf = await processBuffer(
content,
[resizeOperationOpts],
extension,
quality
)
return buf
}
}
3 changes: 3 additions & 0 deletions test/integration/image-optimizer/app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
package.json
yarn.lock
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
/* eslint-env jest */
import { detectContentType } from '../../../../packages/next/dist/server/image-optimizer.js'
import { readFile } from 'fs/promises'
import { readFile } from 'fs-extra'
import { join } from 'path'

const getImage = (filepath) => readFile(join(__dirname, filepath))

describe('detectContentType', () => {
it('should return jpg', async () => {
const buffer = await getImage('../public/test.jpg')
const buffer = await getImage('../app/public/test.jpg')
expect(detectContentType(buffer)).toBe('image/jpeg')
})
it('should return png', async () => {
const buffer = await getImage('../public/test.png')
const buffer = await getImage('../app/public/test.png')
expect(detectContentType(buffer)).toBe('image/png')
})
it('should return webp', async () => {
const buffer = await getImage('../public/animated.webp')
const buffer = await getImage('../app/public/animated.webp')
expect(detectContentType(buffer)).toBe('image/webp')
})
it('should return svg', async () => {
const buffer = await getImage('../public/test.svg')
const buffer = await getImage('../app/public/test.svg')
expect(detectContentType(buffer)).toBe('image/svg+xml')
})
})
Loading