Skip to content

Commit

Permalink
Fix next/future/image blur-up placeholder (#39785)
Browse files Browse the repository at this point in the history
This PR is a follow up to PR #39190 so that we can dynamically set the `feComponentTransfer` when we know the image doesn't have transparency (at this time its just jpeg).

We also set the stdDeviation to 1 and the viewbox to the placeholder's width/height to avoid any rounding issues.

Finally, we also fix the conversion from `objectPosition` to `backgroundPosition` because they have different default values according to the spec.
  • Loading branch information
styfle committed Aug 23, 2022
1 parent 17244b8 commit 73bffd6
Show file tree
Hide file tree
Showing 9 changed files with 207 additions and 95 deletions.
25 changes: 16 additions & 9 deletions packages/next/build/webpack/loaders/next-image-loader.js
Expand Up @@ -28,6 +28,8 @@ function nextImageLoader(content) {
getImageSize(content, extension)
)
let blurDataURL
let blurWidth
let blurHeight

if (VALID_BLUR_EXT.includes(extension)) {
if (isDev) {
Expand All @@ -39,18 +41,21 @@ function nextImageLoader(content) {
blurDataURL = url.href.slice(prefix.length)
} else {
// Shrink the image's largest dimension
const dimension =
imageSize.width >= imageSize.height ? 'width' : 'height'
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,
dimension,
BLUR_IMG_SIZE,
extension,
BLUR_QUALITY
)
resizeImage(content, blurWidth, blurHeight, extension, BLUR_QUALITY)
)
const blurDataURLSpan = imageLoaderSpan.traceChild(
'image-base64-tostring'
Expand All @@ -70,6 +75,8 @@ function nextImageLoader(content) {
height: imageSize.height,
width: imageSize.width,
blurDataURL,
blurWidth,
blurHeight,
})
)

Expand Down
24 changes: 19 additions & 5 deletions packages/next/client/future/image.tsx
Expand Up @@ -67,6 +67,8 @@ export interface StaticImageData {
height: number
width: number
blurDataURL?: string
blurWidth?: number
blurHeight?: number
}

interface StaticRequire {
Expand Down Expand Up @@ -578,6 +580,8 @@ export default function Image({
}

let staticSrc = ''
let blurWidth: number | undefined
let blurHeight: number | undefined
if (isStaticImport(src)) {
const staticImageData = isStaticRequire(src) ? src.default : src

Expand All @@ -588,6 +592,8 @@ export default function Image({
)}`
)
}
blurWidth = staticImageData.blurWidth
blurHeight = staticImageData.blurHeight
blurDataURL = blurDataURL || staticImageData.blurDataURL
staticSrc = staticImageData.src

Expand Down Expand Up @@ -785,16 +791,24 @@ export default function Image({
bottom: 0,
}
: {},
showAltText || placeholder === 'blur' ? {} : { color: 'transparent' },
showAltText ? {} : { color: 'transparent' },
style
)
const svgBlurPlaceholder = `url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http%3A//www.w3.org/2000/svg' viewBox='0 0 ${widthInt} ${heightInt}'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='50'/%3E%3C/filter%3E%3Cimage filter='url(%23b)' x='0' y='0' height='100%25' width='100%25' href='${blurDataURL}'/%3E%3C/svg%3E")`
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' && !blurComplete
placeholder === 'blur' && blurDataURL && !blurComplete
? {
backgroundSize: imgStyle.objectFit || 'cover',
backgroundPosition: imgStyle.objectPosition || '0% 0%',
...(blurDataURL?.startsWith('data:image')
backgroundPosition: imgStyle.objectPosition || '50% 50%',
backgroundRepeat: 'no-repeat',
...(blurDataURL.startsWith('data:image') && svgWidth && svgHeight
? {
backgroundImage: svgBlurPlaceholder,
}
Expand Down
15 changes: 4 additions & 11 deletions packages/next/server/image-optimizer.ts
Expand Up @@ -719,8 +719,8 @@ export function sendResponse(

export async function resizeImage(
content: Buffer,
dimension: 'width' | 'height',
size: number,
width: number,
height: number,
// Should match VALID_BLUR_EXT
extension: 'avif' | 'webp' | 'png' | 'jpeg',
quality: number
Expand Down Expand Up @@ -748,18 +748,11 @@ export async function resizeImage(
} else if (extension === 'jpeg') {
transformer.jpeg({ quality })
}
if (dimension === 'width') {
transformer.resize(size)
} else {
transformer.resize(null, size)
}
transformer.resize(width, height)
const buf = await transformer.toBuffer()
return buf
} else {
const resizeOperationOpts: Operation =
dimension === 'width'
? { type: 'resize', width: size }
: { type: 'resize', height: size }
const resizeOperationOpts: Operation = { type: 'resize', width, height }
const buf = await processBuffer(
content,
[resizeOperationOpts],
Expand Down
1 change: 1 addition & 0 deletions packages/next/server/lib/squoosh/impl.ts
Expand Up @@ -62,6 +62,7 @@ export async function rotate(
type ResizeOpts = { image: ImageData } & (
| { width: number; height?: never }
| { height: number; width?: never }
| { height: number; width: number }
)

export async function resize({ image, width, height }: ResizeOpts) {
Expand Down
24 changes: 14 additions & 10 deletions packages/next/server/lib/squoosh/main.ts
Expand Up @@ -9,7 +9,11 @@ type RotateOperation = {
}
type ResizeOperation = {
type: 'resize'
} & ({ width: number; height?: never } | { height: number; width?: never })
} & (
| { width: number; height?: never }
| { height: number; width?: never }
| { width: number; height: number }
)
export type Operation = RotateOperation | ResizeOperation
export type Encoding = 'jpeg' | 'png' | 'webp' | 'avif'

Expand Down Expand Up @@ -37,24 +41,24 @@ export async function processBuffer(
if (operation.type === 'rotate') {
imageData = await worker.rotate(imageData, operation.numRotations)
} else if (operation.type === 'resize') {
const opt = { image: imageData, width: 0, height: 0 }
if (
operation.width &&
imageData.width &&
imageData.width > operation.width
) {
imageData = await worker.resize({
image: imageData,
width: operation.width,
})
} else if (
opt.width = operation.width
}
if (
operation.height &&
imageData.height &&
imageData.height > operation.height
) {
imageData = await worker.resize({
image: imageData,
height: operation.height,
})
opt.height = operation.height
}

if (opt.width > 0 || opt.height > 0) {
imageData = await worker.resize(opt)
}
}
}
Expand Down
133 changes: 91 additions & 42 deletions test/integration/image-future/base-path/test/static.test.js
Expand Up @@ -5,20 +5,23 @@ import {
nextStart,
renderViaHTTP,
File,
launchApp,
waitFor,
} from 'next-test-utils'
import webdriver from 'next-webdriver'
import cheerio from 'cheerio'
import { join } from 'path'

const appDir = join(__dirname, '../')
let appPort
let app
let browser
let html
let $

const indexPage = new File(join(appDir, 'pages/static-img.js'))

const runTests = () => {
const runTests = (isDev) => {
it('Should allow an image with a static src to omit height and width', async () => {
expect(await browser.elementById('basic-static')).toBeTruthy()
expect(await browser.elementById('blur-png')).toBeTruthy()
Expand All @@ -31,42 +34,71 @@ const runTests = () => {
expect(await browser.elementById('static-ico')).toBeTruthy()
expect(await browser.elementById('static-unoptimized')).toBeTruthy()
})
it('Should use immutable cache-control header for static import', async () => {
await browser.eval(
`document.getElementById("basic-static").scrollIntoView()`
)
await waitFor(1000)
const url = await browser.eval(
`document.getElementById("basic-static").src`
)
const res = await fetch(url)
expect(res.headers.get('cache-control')).toBe(
'public, max-age=315360000, immutable'
)
})
it('Should use immutable cache-control header even when unoptimized', async () => {
await browser.eval(
`document.getElementById("static-unoptimized").scrollIntoView()`
)
await waitFor(1000)
const url = await browser.eval(
`document.getElementById("static-unoptimized").src`
)
const res = await fetch(url)
expect(res.headers.get('cache-control')).toBe(
'public, max-age=31536000, immutable'
)
})
if (!isDev) {
// cache-control is set to "0, no-store" in dev mode
it('Should use immutable cache-control header for static import', async () => {
await browser.eval(
`document.getElementById("basic-static").scrollIntoView()`
)
await waitFor(1000)
const url = await browser.eval(
`document.getElementById("basic-static").src`
)
const res = await fetch(url)
expect(res.headers.get('cache-control')).toBe(
'public, max-age=315360000, immutable'
)
})

it('Should use immutable cache-control header even when unoptimized', async () => {
await browser.eval(
`document.getElementById("static-unoptimized").scrollIntoView()`
)
await waitFor(1000)
const url = await browser.eval(
`document.getElementById("static-unoptimized").src`
)
const res = await fetch(url)
expect(res.headers.get('cache-control')).toBe(
'public, max-age=31536000, immutable'
)
})
}
it('Should automatically provide an image height and width', async () => {
expect(html).toContain('width="400" height="300"')
const img = $('#basic-non-static')
expect(img.attr('width')).toBe('400')
expect(img.attr('height')).toBe('300')
})
it('Should allow provided width and height to override intrinsic', async () => {
expect(html).toContain('width="150" height="150"')
const img = $('#defined-size-static')
expect(img.attr('width')).toBe('150')
expect(img.attr('height')).toBe('150')
})
it('Should add a blur to a statically imported image', async () => {
expect(html).toContain(
`style="background-size:cover;background-position:0% 0%;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http%3A//www.w3.org/2000/svg' viewBox='0 0 400 300'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='50'/%3E%3C/filter%3E%3Cimage filter='url(%23b)' x='0' y='0' height='100%25' width='100%25' href='data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAoKCgoKCgsMDAsPEA4QDxYUExMUFiIYGhgaGCIzICUgICUgMy03LCksNy1RQDg4QFFeT0pPXnFlZXGPiI+7u/sBCgoKCgoKCwwMCw8QDhAPFhQTExQWIhgaGBoYIjMgJSAgJSAzLTcsKSw3LVFAODhAUV5PSk9ecWVlcY+Ij7u7+//CABEIAAYACAMBIgACEQEDEQH/xAAnAAEBAAAAAAAAAAAAAAAAAAAABwEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEAMQAAAAmgP/xAAcEAACAQUBAAAAAAAAAAAAAAASFBMAAQMFERX/2gAIAQEAAT8AZ1HjrKZX55JysIc4Ff/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQIBAT8Af//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQMBAT8Af//Z'/%3E%3C/svg%3E")`
)

it('Should add a blur placeholder a statically imported jpg', async () => {
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")`
)
} else {
expect(style).toBe(
`color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http%3A//www.w3.org/2000/svg' viewBox='0 0 8 6'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='1'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='discrete' tableValues='1 1'/%3E%3C/feComponentTransfer%3E%%3C/filter%3E%3Cimage filter='url(%23b)' x='0' y='0' height='100%25' width='100%25' href='data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAoKCgoKCgsMDAsPEA4QDxYUExMUFiIYGhgaGCIzICUgICUgMy03LCksNy1RQDg4QFFeT0pPXnFlZXGPiI+7u/sBCgoKCgoKCwwMCw8QDhAPFhQTExQWIhgaGBoYIjMgJSAgJSAzLTcsKSw3LVFAODhAUV5PSk9ecWVlcY+Ij7u7+//CABEIAAYACAMBIgACEQEDEQH/xAAnAAEBAAAAAAAAAAAAAAAAAAAABwEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEAMQAAAAmgP/xAAcEAACAQUBAAAAAAAAAAAAAAASFBMAAQMFERX/2gAIAQEAAT8AZ1HjrKZX55JysIc4Ff/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQIBAT8Af//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQMBAT8Af//Z'/%3E%3C/svg%3E")`
)
}
})

it('Should add a blur placeholder a statically imported png', async () => {
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")`
)
} else {
expect(style).toBe(
`color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http%3A//www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='1'/%3E%3C/filter%3E%3Cimage filter='url(%23b)' x='0' y='0' height='100%25' width='100%25' href='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAAAAADhZOFXAAAAOklEQVR42iWGsQkAIBDE0iuIdiLOJjiGIzjiL/Meb4okiNYIlLjK3hJMzCQG1/0qmXXOUkjAV+m9wAMe3QiV6Ne8VgAAAABJRU5ErkJggg=='/%3E%3C/svg%3E")`
)
}
})
}

Expand All @@ -90,15 +122,32 @@ describe('Build Error Tests', () => {
})
})
describe('Future Static Image Component Tests for basePath', () => {
beforeAll(async () => {
await nextBuild(appDir)
appPort = await findPort()
app = await nextStart(appDir, appPort)
html = await renderViaHTTP(appPort, '/docs/static-img')
browser = await webdriver(appPort, '/docs/static-img')
describe('production mode', () => {
beforeAll(async () => {
await nextBuild(appDir)
appPort = await findPort()
app = await nextStart(appDir, appPort)
html = await renderViaHTTP(appPort, '/docs/static-img')
$ = cheerio.load(html)
browser = await webdriver(appPort, '/docs/static-img')
})
afterAll(() => {
killApp(app)
})
runTests(false)
})
afterAll(() => {
killApp(app)

describe('dev mode', () => {
beforeAll(async () => {
appPort = await findPort()
app = await launchApp(appDir, appPort)
html = await renderViaHTTP(appPort, '/docs/static-img')
$ = cheerio.load(html)
browser = await webdriver(appPort, '/docs/static-img')
})
afterAll(() => {
killApp(app)
})
runTests(true)
})
runTests()
})
7 changes: 7 additions & 0 deletions test/integration/image-future/default/pages/static-img.js
Expand Up @@ -32,15 +32,22 @@ const Page = () => {
<Image id="blur-jpg" src={testJPG} placeholder="blur" />
<Image id="blur-webp" src={testWEBP} placeholder="blur" />
<Image id="blur-avif" src={testAVIF} placeholder="blur" />
<br />
<Image id="static-svg" src={testSVG} />
<Image id="static-gif" src={testGIF} />
<Image id="static-bmp" src={testBMP} />
<Image id="static-ico" src={testICO} />
<br />
<Image id="static-svg-fill" src={testSVG} fill />
<Image id="static-gif-fill" src={testGIF} fill />
<Image id="static-bmp-fill" src={testBMP} fill />
<Image id="static-ico-fill" src={testICO} fill />
<br />
<Image id="blur-png-fill" src={testPNG} placeholder="blur" fill />
<Image id="blur-jpg-fill" src={testJPG} placeholder="blur" fill />
<Image id="blur-webp-fill" src={testWEBP} placeholder="blur" fill />
<Image id="blur-avif-fill" src={testAVIF} placeholder="blur" fill />
<br />
<Image id="static-unoptimized" src={testJPG} unoptimized />
</div>
)
Expand Down

0 comments on commit 73bffd6

Please sign in to comment.