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

Refactor image optimization util #43868

Merged
merged 5 commits into from Dec 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
@@ -1,14 +1,23 @@
import isAnimated from 'next/dist/compiled/is-animated'
import loaderUtils from 'next/dist/compiled/loader-utils3'
import { resizeImage, getImageSize } from '../../../server/image-optimizer'
import { optimizeImage, getImageSize } from '../../../server/image-optimizer'

const BLUR_IMG_SIZE = 8
const BLUR_QUALITY = 70
const VALID_BLUR_EXT = ['jpeg', 'png', 'webp', 'avif'] // should match next/client/image.tsx

function nextImageLoader(content) {
interface Options {
isServer: boolean
isDev: boolean
assetPrefix: string
basePath: string
}

function nextImageLoader(this: any, content: Buffer) {
const imageLoaderSpan = this.currentTraceSpan.traceChild('next-image-loader')
return imageLoaderSpan.traceAsyncFn(async () => {
const { isServer, isDev, assetPrefix, basePath } = this.getOptions()
const options: Options = this.getOptions()
const { isServer, isDev, assetPrefix, basePath } = options
const context = this.rootContext
const opts = { context, content }
const interpolatedName = loaderUtils.interpolateName(
Expand All @@ -33,9 +42,9 @@ function nextImageLoader(content) {
throw err
}

let blurDataURL
let blurWidth
let blurHeight
let blurDataURL: string
let blurWidth: number
let blurHeight: number

if (VALID_BLUR_EXT.includes(extension)) {
// Shrink the image's largest dimension
Expand All @@ -60,14 +69,23 @@ function nextImageLoader(content) {
const prefix = 'http://localhost'
const url = new URL(`${basePath || ''}/_next/image`, prefix)
url.searchParams.set('url', outputPath)
url.searchParams.set('w', blurWidth)
url.searchParams.set('q', BLUR_QUALITY)
url.searchParams.set('w', String(blurWidth))
url.searchParams.set('q', String(BLUR_QUALITY))
blurDataURL = url.href.slice(prefix.length)
} else {
const resizeImageSpan = imageLoaderSpan.traceChild('image-resize')
const resizedImage = await resizeImageSpan.traceAsyncFn(() =>
resizeImage(content, blurWidth, blurHeight, extension, BLUR_QUALITY)
)
const resizedImage = await resizeImageSpan.traceAsyncFn(() => {
if (isAnimated(content)) {
return content
}
return optimizeImage({
buffer: content,
width: blurWidth,
height: blurHeight,
contentType: `image/${extension}`,
quality: BLUR_QUALITY,
})
})
const blurDataURLSpan = imageLoaderSpan.traceChild(
'image-base64-tostring'
)
Expand Down
274 changes: 120 additions & 154 deletions packages/next/server/image-optimizer.ts
Expand Up @@ -390,6 +390,119 @@ export function getMaxAge(str: string | null): number {
return 0
}

export async function optimizeImage({
buffer,
contentType,
quality,
width,
height,
nextConfigOutput,
}: {
buffer: Buffer
contentType: string
quality: number
width: number
height?: number
nextConfigOutput?: 'standalone'
}): Promise<Buffer> {
let optimizedBuffer = buffer
if (sharp) {
// Begin sharp transformation logic
const transformer = sharp(buffer)

transformer.rotate()

if (height) {
transformer.resize(width, height)
} else {
const { width: metaWidth } = await transformer.metadata()

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

if (contentType === AVIF) {
if (transformer.avif) {
const avifQuality = quality - 15
transformer.avif({
quality: Math.max(avifQuality, 0),
chromaSubsampling: '4:2:0', // same as webp
})
} 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 })
} else if (contentType === JPEG) {
transformer.jpeg({ quality })
}

optimizedBuffer = await transformer.toBuffer()
// End sharp transformation logic
} else {
if (showSharpMissingWarning && nextConfigOutput) {
// TODO: should we ensure squoosh also works even though we don't
// recommend it be used in production and this is a production feature
console.error(
`Error: 'sharp' is required to be installed in standalone mode for the image optimization to function correctly. Read more at: https://nextjs.org/docs/messages/sharp-missing-in-production`
)
throw new ImageError(500, 'internal server error')
}
// Show sharp warning in production once
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'
)
showSharpMissingWarning = false
}

// Begin Squoosh transformation logic
const orientation = await getOrientation(buffer)

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
}

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

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

return optimizedBuffer
}

export async function imageOptimizer(
_req: IncomingMessage,
_res: ServerResponse,
Expand Down Expand Up @@ -504,114 +617,13 @@ export async function imageOptimizer(
contentType = JPEG
}
try {
let optimizedBuffer: Buffer | undefined
if (sharp) {
// Begin sharp transformation logic
const transformer = sharp(upstreamBuffer)

transformer.rotate()

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

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

if (contentType === AVIF) {
if (transformer.avif) {
const avifQuality = quality - 15
transformer.avif({
quality: Math.max(avifQuality, 0),
chromaSubsampling: '4:2:0', // same as webp
})
} 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 })
} else if (contentType === JPEG) {
transformer.jpeg({ quality })
}

optimizedBuffer = await transformer.toBuffer()
// End sharp transformation logic
} else {
if (showSharpMissingWarning && nextConfig.output === 'standalone') {
// TODO: should we ensure squoosh also works even though we don't
// recommend it be used in production and this is a production feature
console.error(
`Error: 'sharp' is required to be installed in standalone mode for the image optimization to function correctly. Read more at: https://nextjs.org/docs/messages/sharp-missing-in-production`
)
throw new ImageError(500, 'internal server error')
}
// Show sharp warning in production once
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'
)
showSharpMissingWarning = false
}

// Begin Squoosh transformation logic
const orientation = await getOrientation(upstreamBuffer)

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) {
optimizedBuffer = await processBuffer(
upstreamBuffer,
operations,
'avif',
quality
)
} 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
}
let optimizedBuffer = await optimizeImage({
buffer: upstreamBuffer,
contentType,
quality,
width,
nextConfigOutput: nextConfig.output,
})
if (optimizedBuffer) {
if (isDev && width <= BLUR_IMG_SIZE && quality === BLUR_QUALITY) {
// During `next dev`, we don't want to generate blur placeholders with webpack
Expand Down Expand Up @@ -743,52 +755,6 @@ export function sendResponse(
}
}

export async function resizeImage(
content: Buffer,
width: number,
height: number,
// Should match VALID_BLUR_EXT
extension: 'avif' | 'webp' | 'png' | 'jpeg',
quality: number
): Promise<Buffer> {
if (isAnimated(content)) {
return content
} else if (sharp) {
const transformer = sharp(content)

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 })
} else if (extension === 'jpeg') {
transformer.jpeg({ quality })
}
transformer.resize(width, height)
const buf = await transformer.toBuffer()
return buf
} else {
const resizeOperationOpts: Operation = { type: 'resize', width, height }
const buf = await processBuffer(
content,
[resizeOperationOpts],
extension,
quality
)
return buf
}
}

export async function getImageSize(
buffer: Buffer,
// Should match VALID_BLUR_EXT
Expand Down