From 954add3066518e639ff1965ce5c1a23ad4f3ff72 Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 26 Aug 2022 19:56:28 -0400 Subject: [PATCH 1/6] Change `next/future/image` svg blur placeholder during `next dev` --- packages/next/client/future/image.tsx | 34 ++++++++++++---------- packages/next/server/image-optimizer.ts | 25 +++++++++++++++- packages/next/server/lib/squoosh/main.ts | 8 +++++ packages/next/server/next-server.ts | 1 + packages/next/shared/lib/image-blur-svg.ts | 31 ++++++++++++++++++++ 5 files changed, 83 insertions(+), 16 deletions(-) create mode 100644 packages/next/shared/lib/image-blur-svg.ts diff --git a/packages/next/client/future/image.tsx b/packages/next/client/future/image.tsx index 60f36c27a074..354757642423 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 (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..87df0c1ec582 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: | (( @@ -385,6 +392,7 @@ export async function imageOptimizer( _res: ServerResponse, paramsResult: ImageParamsResult, nextConfig: NextConfigComplete, + isDev: boolean | undefined, handleRequest: ( newReq: IncomingMessage, newRes: ServerResponse, @@ -602,6 +610,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..847d4a55d967 --- /dev/null +++ b/packages/next/shared/lib/image-blur-svg.ts @@ -0,0 +1,31 @@ +/** + * 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 + + if (!svgWidth || !svgHeight) { + throw new Error( + 'Invariant: Expected image with placeholder="blur" to have width and height' + ) + } + + 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` +} From f1320b3c14e3475bb2678e4571cdd2c101f2d989 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 29 Aug 2022 10:08:19 -0400 Subject: [PATCH 2/6] Fix tests --- .../webpack/loaders/next-image-loader.js | 26 +++++++++---------- packages/next/client/future/image.tsx | 2 +- .../base-path/test/static.test.js | 4 +-- .../image-future/default/test/static.test.ts | 6 ++--- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/next/build/webpack/loaders/next-image-loader.js b/packages/next/build/webpack/loaders/next-image-loader.js index af1fb8d0be60..73284744bfd5 100644 --- a/packages/next/build/webpack/loaders/next-image-loader.js +++ b/packages/next/build/webpack/loaders/next-image-loader.js @@ -32,6 +32,19 @@ 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) { const prefix = 'http://localhost' const url = new URL(`${basePath || ''}/_next/image`, prefix) @@ -40,19 +53,6 @@ function nextImageLoader(content) { 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 354757642423..199083c4304c 100644 --- a/packages/next/client/future/image.tsx +++ b/packages/next/client/future/image.tsx @@ -815,7 +815,7 @@ export default function Image({ : {} if (process.env.NODE_ENV === 'development') { - if (blurDataURL?.startsWith('/')) { + 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. 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( From bed377da39128f9202e635f74f485671c8c901f7 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 29 Aug 2022 10:13:35 -0400 Subject: [PATCH 3/6] Update comments --- packages/next/build/webpack/loaders/next-image-loader.js | 3 +++ packages/next/shared/lib/image-blur-svg.ts | 9 +-------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/next/build/webpack/loaders/next-image-loader.js b/packages/next/build/webpack/loaders/next-image-loader.js index 73284744bfd5..202c3933a45c 100644 --- a/packages/next/build/webpack/loaders/next-image-loader.js +++ b/packages/next/build/webpack/loaders/next-image-loader.js @@ -46,6 +46,9 @@ function nextImageLoader(content) { } 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) diff --git a/packages/next/shared/lib/image-blur-svg.ts b/packages/next/shared/lib/image-blur-svg.ts index 847d4a55d967..ce2a502f29f5 100644 --- a/packages/next/shared/lib/image-blur-svg.ts +++ b/packages/next/shared/lib/image-blur-svg.ts @@ -1,5 +1,5 @@ /** - * A shared function. used on both client and server. to generate a SVG blur placeholder. + * A shared function, used on both client and server, to generate a SVG blur placeholder. */ export function getImageBlurSvg({ widthInt, @@ -17,13 +17,6 @@ export function getImageBlurSvg({ const std = blurWidth && blurHeight ? '1' : '20' const svgWidth = blurWidth || widthInt const svgHeight = blurHeight || heightInt - - if (!svgWidth || !svgHeight) { - throw new Error( - 'Invariant: Expected image with placeholder="blur" to have width and height' - ) - } - const feComponentTransfer = blurDataURL.startsWith('data:image/jpeg') ? `%3CfeComponentTransfer%3E%3CfeFuncA type='discrete' tableValues='1 1'/%3E%3C/feComponentTransfer%3E%` : '' From fe74054d48ce7e904b0c9a38e289cf1733fb6448 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 29 Aug 2022 15:24:27 -0400 Subject: [PATCH 4/6] Fix blur width/height --- .../webpack/loaders/next-image-loader.js | 2 +- packages/next/server/image-optimizer.ts | 7 ++-- test/integration/image-optimizer/test/util.ts | 32 +++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/next/build/webpack/loaders/next-image-loader.js b/packages/next/build/webpack/loaders/next-image-loader.js index 202c3933a45c..6bc17aa92ebf 100644 --- a/packages/next/build/webpack/loaders/next-image-loader.js +++ b/packages/next/build/webpack/loaders/next-image-loader.js @@ -52,7 +52,7 @@ function nextImageLoader(content) { 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 { diff --git a/packages/next/server/image-optimizer.ts b/packages/next/server/image-optimizer.ts index 87df0c1ec582..68ca350f9d3b 100644 --- a/packages/next/server/image-optimizer.ts +++ b/packages/next/server/image-optimizer.ts @@ -222,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`, } @@ -610,7 +613,7 @@ export async function imageOptimizer( // End Squoosh transformation logic } if (optimizedBuffer) { - if (isDev && width === BLUR_IMG_SIZE && quality === BLUR_QUALITY) { + 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. diff --git a/test/integration/image-optimizer/test/util.ts b/test/integration/image-optimizer/test/util.ts index b79a061ce86f..bdb3e079c54d 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()).toBe( + `` + ) + } else { + expect(res.status).toBe(400) + expect(await res.text()).toBe(`"w" parameter (width) of 8 is not allowed`) + } + }) + + it('should emit blur svg when width is less than 8 in dev but not prod', async () => { + 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()).toBe( + `` + ) + } else { + expect(res.status).toBe(400) + expect(await res.text()).toBe(`"w" parameter (width) of 3 is not allowed`) + } + }) + it('should resize relative url and webp Firefox accept header', async () => { const query = { url: '/test.png', w: ctx.w, q: 80 } const opts = { headers: { accept: 'image/webp,*/*' } } From d488d2768709bc20a0d742cd8dcef7a79e3a73be Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 29 Aug 2022 16:07:13 -0400 Subject: [PATCH 5/6] Simplify svg comparison --- test/integration/image-optimizer/test/util.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/image-optimizer/test/util.ts b/test/integration/image-optimizer/test/util.ts index bdb3e079c54d..3092c3984f56 100644 --- a/test/integration/image-optimizer/test/util.ts +++ b/test/integration/image-optimizer/test/util.ts @@ -457,7 +457,7 @@ export function runTests(ctx) { expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('image/svg+xml') expect(await res.text()).toBe( - `` + `` + `