Skip to content

Commit

Permalink
Use Sharp if available for Image Optimization (#27346)
Browse files Browse the repository at this point in the history
* Use sharp for image transformations when available

* Refactor resizeImage and add sharp warning

* only show sharp warning once per instance

* Modify sharp error message

* Add documentation for optional sharp dependency

* Update docs/basic-features/image-optimization.md

Co-authored-by: Steven <[email protected]>

* Import types for sharp

* Update lockfile

* Add testing against sharp

* use fs-extra for node 12

* Rename test sharp path variable

* Apply suggestions from code review

Co-authored-by: Steven <[email protected]>

* update squoosh specific test

* Apply suggestions from code review

Co-authored-by: Steven <[email protected]>

* update tests

* Apply suggestions from code review

Co-authored-by: Steven <[email protected]>

Co-authored-by: Steven <[email protected]>
Co-authored-by: JJ Kasper <[email protected]>
  • Loading branch information
3 people authored Jul 22, 2021
1 parent 1651129 commit 1b73342
Show file tree
Hide file tree
Showing 25 changed files with 360 additions and 173 deletions.
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",
"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/sharp-missing-in-production'
)
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

0 comments on commit 1b73342

Please sign in to comment.