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

Update next/future/image to use svg blur placeholder during next dev #39992

Merged
merged 8 commits into from Aug 29, 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
31 changes: 17 additions & 14 deletions packages/next/build/webpack/loaders/next-image-loader.js
Expand Up @@ -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)
Expand Down
34 changes: 19 additions & 15 deletions packages/next/client/future/image.tsx
Expand Up @@ -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,
Expand Down Expand Up @@ -794,31 +795,34 @@ 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
? {
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,
Expand Down
30 changes: 28 additions & 2 deletions packages/next/server/image-optimizer.ts
Expand Up @@ -9,14 +9,20 @@ 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'
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'

Expand All @@ -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:
| ((
Expand Down Expand Up @@ -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`,
}
Expand Down Expand Up @@ -385,6 +395,7 @@ export async function imageOptimizer(
_res: ServerResponse,
paramsResult: ImageParamsResult,
nextConfig: NextConfigComplete,
isDev: boolean | undefined,
handleRequest: (
newReq: IncomingMessage,
newRes: ServerResponse,
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions packages/next/server/lib/squoosh/main.ts
Expand Up @@ -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[],
Expand Down
1 change: 1 addition & 0 deletions packages/next/server/next-server.ts
Expand Up @@ -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),
Expand Down
24 changes: 24 additions & 0 deletions 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`
}
4 changes: 2 additions & 2 deletions test/integration/image-future/base-path/test/static.test.js
Expand Up @@ -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(
Expand All @@ -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(
Expand Down
6 changes: 3 additions & 3 deletions test/integration/image-future/default/test/static.test.ts
Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand Down
32 changes: 32 additions & 0 deletions test/integration/image-optimizer/test/util.ts
Expand Up @@ -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(
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'><filter id='b' color-interpolation-filters='sRGB'><feGaussianBlur stdDeviation='1'/></filter><image filter='url(#b)' x='0' y='0' height='100%' width='100%' href='data:image/webp;base64`
)
} 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()).toMatch(
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 3 3'><filter id='b' color-interpolation-filters='sRGB'><feGaussianBlur stdDeviation='1'/></filter><image filter='url(#b)' x='0' y='0' height='100%' width='100%' href='data:image/webp;base64`
)
} 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,*/*' } }
Expand Down