diff --git a/packages/next/build/webpack/loaders/next-image-loader.js b/packages/next/build/webpack/loaders/next-image-loader.js index af1fb8d0be60..6bc17aa92ebf 100644 --- a/packages/next/build/webpack/loaders/next-image-loader.js +++ b/packages/next/build/webpack/loaders/next-image-loader.js @@ -32,27 +32,30 @@ function nextImageLoader(content) { let blurHeight if (VALID_BLUR_EXT.includes(extension)) { + // Shrink the image's largest dimension + if (imageSize.width >= imageSize.height) { + blurWidth = BLUR_IMG_SIZE + blurHeight = Math.round( + (imageSize.height / imageSize.width) * BLUR_IMG_SIZE + ) + } else { + blurWidth = Math.round( + (imageSize.width / imageSize.height) * BLUR_IMG_SIZE + ) + blurHeight = BLUR_IMG_SIZE + } + if (isDev) { + // During `next dev`, we don't want to generate blur placeholders with webpack + // because it can delay starting the dev server. Instead, we inline a + // special url to lazily generate the blur placeholder at request time. const prefix = 'http://localhost' const url = new URL(`${basePath || ''}/_next/image`, prefix) url.searchParams.set('url', outputPath) - url.searchParams.set('w', BLUR_IMG_SIZE) + url.searchParams.set('w', blurWidth) url.searchParams.set('q', BLUR_QUALITY) blurDataURL = url.href.slice(prefix.length) } else { - // Shrink the image's largest dimension - if (imageSize.width >= imageSize.height) { - blurWidth = BLUR_IMG_SIZE - blurHeight = Math.round( - (imageSize.height / imageSize.width) * BLUR_IMG_SIZE - ) - } else { - blurWidth = Math.round( - (imageSize.width / imageSize.height) * BLUR_IMG_SIZE - ) - blurHeight = BLUR_IMG_SIZE - } - const resizeImageSpan = imageLoaderSpan.traceChild('image-resize') const resizedImage = await resizeImageSpan.traceAsyncFn(() => resizeImage(content, blurWidth, blurHeight, extension, BLUR_QUALITY) diff --git a/packages/next/client/future/image.tsx b/packages/next/client/future/image.tsx index 60f36c27a074..199083c4304c 100644 --- a/packages/next/client/future/image.tsx +++ b/packages/next/client/future/image.tsx @@ -7,6 +7,7 @@ import React, { useState, } from 'react' import Head from '../../shared/lib/head' +import { getImageBlurSvg } from '../../shared/lib/image-blur-svg' import { ImageConfigComplete, imageConfigDefault, @@ -794,13 +795,6 @@ export default function Image({ showAltText ? {} : { color: 'transparent' }, style ) - const std = blurWidth && blurHeight ? '1' : '20' - const svgWidth = blurWidth || widthInt - const svgHeight = blurHeight || heightInt - const feComponentTransfer = blurDataURL?.startsWith('data:image/jpeg') - ? `%3CfeComponentTransfer%3E%3CfeFuncA type='discrete' tableValues='1 1'/%3E%3C/feComponentTransfer%3E%` - : '' - const svgBlurPlaceholder = `url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http%3A//www.w3.org/2000/svg' viewBox='0 0 ${svgWidth} ${svgHeight}'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='${std}'/%3E${feComponentTransfer}%3C/filter%3E%3Cimage filter='url(%23b)' x='0' y='0' height='100%25' width='100%25' href='${blurDataURL}'/%3E%3C/svg%3E")` const blurStyle = placeholder === 'blur' && blurDataURL && !blurComplete @@ -808,17 +802,27 @@ export default function Image({ backgroundSize: imgStyle.objectFit || 'cover', backgroundPosition: imgStyle.objectPosition || '50% 50%', backgroundRepeat: 'no-repeat', - ...(blurDataURL.startsWith('data:image') && svgWidth && svgHeight - ? { - backgroundImage: svgBlurPlaceholder, - } - : { - filter: 'blur(20px)', - backgroundImage: `url("${blurDataURL}")`, - }), + backgroundImage: `url("data:image/svg+xml;charset=utf-8,${getImageBlurSvg( + { + widthInt, + heightInt, + blurWidth, + blurHeight, + blurDataURL, + } + )}")`, } : {} + if (process.env.NODE_ENV === 'development') { + if (blurStyle.backgroundImage && blurDataURL?.startsWith('/')) { + // During `next dev`, we don't want to generate blur placeholders with webpack + // because it can delay starting the dev server. Instead, `next-image-loader.js` + // will inline a special url to lazily generate the blur placeholder at request time. + blurStyle.backgroundImage = `url("${blurDataURL}")` + } + } + const imgAttributes = generateImgAttrs({ config, src, diff --git a/packages/next/server/image-optimizer.ts b/packages/next/server/image-optimizer.ts index 36d8921f8bbf..68ca350f9d3b 100644 --- a/packages/next/server/image-optimizer.ts +++ b/packages/next/server/image-optimizer.ts @@ -9,7 +9,12 @@ import contentDisposition from 'next/dist/compiled/content-disposition' import { join } from 'path' import nodeUrl, { UrlWithParsedQuery } from 'url' import { NextConfigComplete } from './config-shared' -import { processBuffer, decodeBuffer, Operation } from './lib/squoosh/main' +import { + processBuffer, + decodeBuffer, + Operation, + getMetadata, +} from './lib/squoosh/main' import { sendEtagResponse } from './send-payload' import { getContentType, getExtension } from './serve-static' import chalk from 'next/dist/compiled/chalk' @@ -17,6 +22,7 @@ import { NextUrlWithParsedQuery } from './request-meta' import { IncrementalCacheEntry, IncrementalCacheValue } from './response-cache' import { mockRequest } from './lib/mock-request' import { hasMatch } from '../shared/lib/match-remote-pattern' +import { getImageBlurSvg } from '../shared/lib/image-blur-svg' type XCacheHeader = 'MISS' | 'HIT' | 'STALE' @@ -30,6 +36,7 @@ const CACHE_VERSION = 3 const ANIMATABLE_TYPES = [WEBP, PNG, GIF] const VECTOR_TYPES = [SVG] const BLUR_IMG_SIZE = 8 // should match `next-image-loader` +const BLUR_QUALITY = 70 // should match `next-image-loader` let sharp: | (( @@ -215,7 +222,10 @@ export class ImageOptimizerCache { sizes.push(BLUR_IMG_SIZE) } - if (!sizes.includes(width)) { + const isValidSize = + sizes.includes(width) || (isDev && width <= BLUR_IMG_SIZE) + + if (!isValidSize) { return { errorMessage: `"w" parameter (width) of ${width} is not allowed`, } @@ -385,6 +395,7 @@ export async function imageOptimizer( _res: ServerResponse, paramsResult: ImageParamsResult, nextConfig: NextConfigComplete, + isDev: boolean | undefined, handleRequest: ( newReq: IncomingMessage, newRes: ServerResponse, @@ -602,6 +613,21 @@ export async function imageOptimizer( // End Squoosh transformation logic } if (optimizedBuffer) { + if (isDev && width <= BLUR_IMG_SIZE && quality === BLUR_QUALITY) { + // During `next dev`, we don't want to generate blur placeholders with webpack + // because it can delay starting the dev server. Instead, `next-image-loader.js` + // will inline a special url to lazily generate the blur placeholder at request time. + const meta = await getMetadata(optimizedBuffer) + const opts = { + blurWidth: meta.width, + blurHeight: meta.height, + blurDataURL: `data:${contentType};base64,${optimizedBuffer.toString( + 'base64' + )}`, + } + optimizedBuffer = Buffer.from(unescape(getImageBlurSvg(opts))) + contentType = 'image/svg+xml' + } return { buffer: optimizedBuffer, contentType, diff --git a/packages/next/server/lib/squoosh/main.ts b/packages/next/server/lib/squoosh/main.ts index 2d326cbf1cc2..4bac4ac4553e 100644 --- a/packages/next/server/lib/squoosh/main.ts +++ b/packages/next/server/lib/squoosh/main.ts @@ -28,6 +28,14 @@ const getWorker = execOnce( }) ) +export async function getMetadata( + buffer: Buffer +): Promise<{ width: number; height: number }> { + const worker: typeof import('./impl') = getWorker() as any + const { width, height } = await worker.decodeBuffer(buffer) + return { width, height } +} + export async function processBuffer( buffer: Buffer, operations: Operation[], diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index 2d8e3aac0a33..18d75626b9a8 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -787,6 +787,7 @@ export default class NextNodeServer extends BaseServer { res.originalResponse, paramsResult, this.nextConfig, + this.renderOpts.dev, (newReq, newRes, newParsedUrl) => this.getRequestHandler()( new NodeNextRequest(newReq), diff --git a/packages/next/shared/lib/image-blur-svg.ts b/packages/next/shared/lib/image-blur-svg.ts new file mode 100644 index 000000000000..ce2a502f29f5 --- /dev/null +++ b/packages/next/shared/lib/image-blur-svg.ts @@ -0,0 +1,24 @@ +/** + * A shared function, used on both client and server, to generate a SVG blur placeholder. + */ +export function getImageBlurSvg({ + widthInt, + heightInt, + blurWidth, + blurHeight, + blurDataURL, +}: { + widthInt?: number + heightInt?: number + blurWidth?: number + blurHeight?: number + blurDataURL: string +}): string { + const std = blurWidth && blurHeight ? '1' : '20' + const svgWidth = blurWidth || widthInt + const svgHeight = blurHeight || heightInt + const feComponentTransfer = blurDataURL.startsWith('data:image/jpeg') + ? `%3CfeComponentTransfer%3E%3CfeFuncA type='discrete' tableValues='1 1'/%3E%3C/feComponentTransfer%3E%` + : '' + return `%3Csvg xmlns='http%3A//www.w3.org/2000/svg' viewBox='0 0 ${svgWidth} ${svgHeight}'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='${std}'/%3E${feComponentTransfer}%3C/filter%3E%3Cimage filter='url(%23b)' x='0' y='0' height='100%25' width='100%25' href='${blurDataURL}'/%3E%3C/svg%3E` +} diff --git a/test/integration/image-future/base-path/test/static.test.js b/test/integration/image-future/base-path/test/static.test.js index fa262880c619..833f7fe2ee2d 100644 --- a/test/integration/image-future/base-path/test/static.test.js +++ b/test/integration/image-future/base-path/test/static.test.js @@ -79,7 +79,7 @@ const runTests = (isDev) => { const style = $('#basic-static').attr('style') if (isDev) { expect(style).toBe( - `color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;filter:blur(20px);background-image:url("/docs/_next/image?url=%2Fdocs%2F_next%2Fstatic%2Fmedia%2Ftest-rect.f323a148.jpg&w=8&q=70")` + `color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;background-image:url("/docs/_next/image?url=%2Fdocs%2F_next%2Fstatic%2Fmedia%2Ftest-rect.f323a148.jpg&w=8&q=70")` ) } else { expect(style).toBe( @@ -92,7 +92,7 @@ const runTests = (isDev) => { const style = $('#blur-png').attr('style') if (isDev) { expect(style).toBe( - `color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;filter:blur(20px);background-image:url("/docs/_next/image?url=%2Fdocs%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=8&q=70")` + `color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;background-image:url("/docs/_next/image?url=%2Fdocs%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=8&q=70")` ) } else { expect(style).toBe( diff --git a/test/integration/image-future/default/test/static.test.ts b/test/integration/image-future/default/test/static.test.ts index 2bd883d8976c..2c9c58e57eb0 100644 --- a/test/integration/image-future/default/test/static.test.ts +++ b/test/integration/image-future/default/test/static.test.ts @@ -84,7 +84,7 @@ const runTests = (isDev) => { const style = $('#basic-static').attr('style') if (isDev) { expect(style).toBe( - `color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;filter:blur(20px);background-image:url("/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest-rect.f323a148.jpg&w=8&q=70")` + `color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;background-image:url("/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest-rect.f323a148.jpg&w=8&q=70")` ) } else { expect(style).toBe( @@ -97,7 +97,7 @@ const runTests = (isDev) => { const style = $('#blur-png').attr('style') if (isDev) { expect(style).toBe( - `color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;filter:blur(20px);background-image:url("/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=8&q=70")` + `color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;background-image:url("/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=8&q=70")` ) } else { expect(style).toBe( @@ -110,7 +110,7 @@ const runTests = (isDev) => { const style = $('#blur-png-fill').attr('style') if (isDev) { expect(style).toBe( - `position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;filter:blur(20px);background-image:url("/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=8&q=70")` + `position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;background-image:url("/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=8&q=70")` ) } else { expect(style).toBe( diff --git a/test/integration/image-optimizer/test/util.ts b/test/integration/image-optimizer/test/util.ts index b79a061ce86f..aa37a5aef7c7 100644 --- a/test/integration/image-optimizer/test/util.ts +++ b/test/integration/image-optimizer/test/util.ts @@ -449,6 +449,38 @@ export function runTests(ctx) { ) }) + it('should emit blur svg when width is 8 in dev but not prod', async () => { + const query = { url: '/test.png', w: 8, q: 70 } + const opts = { headers: { accept: 'image/webp' } } + const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts) + if (isDev) { + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/svg+xml') + expect(await res.text()).toMatch( + ` { + const query = { url: '/test.png', w: 3, q: 70 } + const opts = { headers: { accept: 'image/webp' } } + const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts) + if (isDev) { + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/svg+xml') + expect(await res.text()).toMatch( + ` { const query = { url: '/test.png', w: ctx.w, q: 80 } const opts = { headers: { accept: 'image/webp,*/*' } }