Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 254 additions & 0 deletions bench/blur-image-benchmark.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
// Benchmark: getBlurImage() - original format vs webp conversion
//
// Usage: node bench/blur-image-benchmark.js
//
// Compares latency and blurDataURL size when keeping the original image
// format versus converting to webp for the blur placeholder.

const path = require('path')
const fs = require('fs')
const {
optimizeImage,
} = require('../packages/next/dist/server/image-optimizer')

const BLUR_IMG_SIZE = 8
const BLUR_QUALITY = 70

const TEST_IMAGES = [
// PNG images
{
path: 'test/unit/image-optimizer/images/test.png',
ext: 'png',
width: 400,
height: 400,
},
{
path: 'test/integration/image-optimizer/app/public/grayscale.png',
ext: 'png',
width: 36,
height: 36,
},
// JPEG images
{
path: 'test/unit/image-optimizer/images/test.jpg',
ext: 'jpeg',
width: 400,
height: 400,
},
{
path: 'test/integration/image-optimizer/app/public/mountains.jpg',
ext: 'jpeg',
width: 2800,
height: 1900,
},
// WebP image (baseline - already webp)
{
path: 'test/unit/image-optimizer/app/public/test.webp',
ext: 'webp',
width: 400,
height: 400,
},
// Wide PNG images
{
path: 'test/integration/next-image-new/app-dir/public/wide.png',
ext: 'png',
width: 1200,
height: 700,
},
{
path: 'test/integration/next-image-new/app-dir/public/super-wide.png',
ext: 'png',
width: 1920,
height: 25,
},
// Photo JPEG images
{
path: 'examples/image-component/public/cat.jpg',
ext: 'jpeg',
width: 1500,
height: 2000,
},
{
path: 'examples/image-component/public/dog.jpg',
ext: 'jpeg',
width: 1500,
height: 2000,
},
// Logo PNG
{
path: 'examples/image-component/public/vercel.png',
ext: 'png',
width: 1600,
height: 1600,
},
// AVIF images
{
path: 'test/unit/image-optimizer/images/test.avif',
ext: 'avif',
width: 400,
height: 400,
},
]

function computeBlurDimensions(width, height) {
let blurWidth, blurHeight
if (width >= height) {
blurWidth = BLUR_IMG_SIZE
blurHeight = Math.max(Math.round((height / width) * BLUR_IMG_SIZE), 1)
} else {
blurWidth = Math.max(Math.round((width / height) * BLUR_IMG_SIZE), 1)
blurHeight = BLUR_IMG_SIZE
}
return { blurWidth, blurHeight }
}

async function runBlur(buffer, ext, blurWidth, blurHeight) {
const optimized = await optimizeImage({
buffer,
width: blurWidth,
height: blurHeight,
contentType: `image/${ext}`,
quality: BLUR_QUALITY,
})
const dataURL = `data:image/${ext};base64,${optimized.toString('base64')}`
return { optimized, dataURL }
}

async function benchmark(label, fn, iterations = 50) {
// Warmup
for (let i = 0; i < 3; i++) {
await fn()
}

const times = []
for (let i = 0; i < iterations; i++) {
const start = performance.now()
await fn()
times.push(performance.now() - start)
}

times.sort((a, b) => a - b)
return {
label,
median: times[Math.floor(times.length / 2)],
mean: times.reduce((a, b) => a + b, 0) / times.length,
p95: times[Math.floor(times.length * 0.95)],
min: times[0],
max: times[times.length - 1],
}
}

async function main() {
const repoRoot = path.resolve(__dirname, '..')
const iterations = 50

console.log(`Blur Image Benchmark (${iterations} iterations each)`)
console.log('='.repeat(100))
console.log()

const results = []

for (const img of TEST_IMAGES) {
const fullPath = path.join(repoRoot, img.path)
if (!fs.existsSync(fullPath)) {
console.log(`SKIP: ${img.path} (not found)`)
continue
}

const content = fs.readFileSync(fullPath)
const fileSize = content.length
const { blurWidth, blurHeight } = computeBlurDimensions(
img.width,
img.height
)
const basename = path.basename(img.path)

console.log(
`${basename} (${img.ext}, ${fileSize} bytes, ${img.width}x${img.height} → ${blurWidth}x${blurHeight})`
)
console.log('-'.repeat(100))

// Original format
const origResult = await runBlur(content, img.ext, blurWidth, blurHeight)
const origTiming = await benchmark(
` ${img.ext} (original)`,
() => runBlur(content, img.ext, blurWidth, blurHeight),
iterations
)

// WebP format
const webpResult = await runBlur(content, 'webp', blurWidth, blurHeight)
const webpTiming = await benchmark(
` webp (converted)`,
() => runBlur(content, 'webp', blurWidth, blurHeight),
iterations
)

const origDataURLLen = origResult.dataURL.length
const webpDataURLLen = webpResult.dataURL.length
const sizeDiff = (
((webpDataURLLen - origDataURLLen) / origDataURLLen) *
100
).toFixed(1)
const speedDiff = (
((webpTiming.median - origTiming.median) / origTiming.median) *
100
).toFixed(1)

console.log(
` ${'Format'.padEnd(18)} ${'Median (ms)'.padStart(12)} ${'Mean (ms)'.padStart(12)} ${'P95 (ms)'.padStart(12)} ${'DataURL len'.padStart(12)} ${'Buf size'.padStart(10)}`
)
console.log(
` ${(img.ext + ' (original)').padEnd(18)} ${origTiming.median.toFixed(2).padStart(12)} ${origTiming.mean.toFixed(2).padStart(12)} ${origTiming.p95.toFixed(2).padStart(12)} ${String(origDataURLLen).padStart(12)} ${String(origResult.optimized.length).padStart(10)}`
)
console.log(
` ${'webp (converted)'.padEnd(18)} ${webpTiming.median.toFixed(2).padStart(12)} ${webpTiming.mean.toFixed(2).padStart(12)} ${webpTiming.p95.toFixed(2).padStart(12)} ${String(webpDataURLLen).padStart(12)} ${String(webpResult.optimized.length).padStart(10)}`
)
console.log(
` Δ webp vs orig: speed ${speedDiff}% | dataURL size ${sizeDiff}%`
)
console.log()

results.push({
file: basename,
ext: img.ext,
fileSize,
origMedianMs: origTiming.median,
webpMedianMs: webpTiming.median,
origDataURLLen,
webpDataURLLen,
origBufSize: origResult.optimized.length,
webpBufSize: webpResult.optimized.length,
})
}

// Summary table
console.log()
console.log('SUMMARY')
console.log('='.repeat(100))
console.log(
`${'File'.padEnd(25)} ${'Ext'.padEnd(6)} ${'Orig ms'.padStart(9)} ${'WebP ms'.padStart(9)} ${'Δ Speed'.padStart(9)} ${'Orig URL'.padStart(10)} ${'WebP URL'.padStart(10)} ${'Δ Size'.padStart(9)}`
)
console.log('-'.repeat(100))

for (const r of results) {
const speedDiff = (
((r.webpMedianMs - r.origMedianMs) / r.origMedianMs) *
100
).toFixed(1)
const sizeDiff = (
((r.webpDataURLLen - r.origDataURLLen) / r.origDataURLLen) *
100
).toFixed(1)

console.log(
`${r.file.padEnd(25)} ${r.ext.padEnd(6)} ${r.origMedianMs.toFixed(2).padStart(9)} ${r.webpMedianMs.toFixed(2).padStart(9)} ${(speedDiff + '%').padStart(9)} ${String(r.origDataURLLen).padStart(10)} ${String(r.webpDataURLLen).padStart(10)} ${(sizeDiff + '%').padStart(9)}`
)
}
console.log()
}

main().catch((err) => {
console.error(err)
process.exit(1)
})
Original file line number Diff line number Diff line change
Expand Up @@ -65,19 +65,21 @@ export async function getBlurImage(
blurDataURL = url.href.slice(prefix.length)
} else {
const resizeImageSpan = tracing('image-resize')
// Use webp placeholder since its fewer bytes and faster to encode
const blurContentType = 'image/webp'
const resizedImage = await resizeImageSpan.traceAsyncFn(() =>
optimizeImage({
buffer: content,
width: blurWidth,
height: blurHeight,
contentType: `image/${extension}`,
contentType: blurContentType,
quality: BLUR_QUALITY,
})
)
const blurDataURLSpan = tracing('image-base64-tostring')
blurDataURL = blurDataURLSpan.traceFn(
() =>
`data:image/${extension};base64,${resizedImage.toString('base64')}`
`data:${blurContentType};base64,${resizedImage.toString('base64')}`
)
}
}
Expand Down
10 changes: 7 additions & 3 deletions packages/next/src/server/image-optimizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1142,25 +1142,29 @@ export async function imageOptimizer(
}

try {
const isBlur =
opts.isDev && width <= BLUR_IMG_SIZE && quality === BLUR_QUALITY
// Use webp placeholder since its fewer bytes and faster to encode
const blurContentType = isBlur ? WEBP : contentType
let optimizedBuffer = await optimizeImage({
buffer: upstreamBuffer,
contentType,
contentType: blurContentType,
quality,
width,
concurrency: nextConfig.experimental.imgOptConcurrency,
limitInputPixels: nextConfig.experimental.imgOptMaxInputPixels,
sequentialRead: nextConfig.experimental.imgOptSequentialRead,
timeoutInSeconds: nextConfig.experimental.imgOptTimeoutInSeconds,
})
if (opts.isDev && width <= BLUR_IMG_SIZE && quality === BLUR_QUALITY) {
if (isBlur) {
// 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 getImageSize(optimizedBuffer)
const blurOpts = {
blurWidth: meta.width,
blurHeight: meta.height,
blurDataURL: `data:${contentType};base64,${optimizedBuffer.toString(
blurDataURL: `data:${blurContentType};base64,${optimizedBuffer.toString(
'base64'
)}`,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe('Image Component assetPrefix Tests', () => {
`document.getElementById('${id}').style['background-image']`
)
if (process.env.IS_TURBOPACK_TEST) {
expect(bgImage).toContain('data:image/jpeg;')
expect(bgImage).toContain('data:image/webp;')
} else {
expect(bgImage).toMatch(
/\/_next\/image\?url=https%3A%2F%2Fexample.com%2Fpre%2F_next%2Fstatic%2Fmedia%2Ftest(.+).jpg&w=8&q=70/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ const runTests = () => {
)
} else {
expect($('#basic-static')[2].attribs.style).toMatchInlineSnapshot(
`"position:absolute;top:0;left:0;bottom:0;right:0;box-sizing:border-box;padding:0;border:none;margin:auto;display:block;width:0;height:0;min-width:100%;max-width:100%;min-height:100%;max-height:100%;background-size:cover;background-position:0% 0%;filter:blur(20px);background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAICAMAAAALMbVOAAAAGFBMVEUBAQFCQkIHBwcuLi79/f0rKyu1tbWurq7lN1wyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAHElEQVR4nGNggAImRiYGRhZGBjYWdgZmVmaYMAACVQAo1/LzagAAAABJRU5ErkJggg==")"`
`"position:absolute;top:0;left:0;bottom:0;right:0;box-sizing:border-box;padding:0;border:none;margin:auto;display:block;width:0;height:0;min-width:100%;max-width:100%;min-height:100%;max-height:100%;background-size:cover;background-position:0% 0%;filter:blur(20px);background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAICAYAAAA870V8AAAARUlEQVR42l3MoQ0AQQhE0XG7xWwIJSBIKBRJOZRBEXOWnPjimQ8AXC3ce+nuPOcQEcHuppkRVcWZYWYSIkJV5XvvN9j4AFZHJTnjDHb/AAAAAElFTkSuQmCC")"`
)
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe('Image Component assetPrefix Tests', () => {
const bgImage = await browser.eval(
`document.getElementById('${id}').style['background-image']`
)
expect(bgImage).toMatch('data:image/jpeg;base64')
expect(bgImage).toMatch('data:image/webp;base64')
})

// eslint-disable-next-line jest/no-identical-title
Expand Down
8 changes: 7 additions & 1 deletion turbopack/crates/turbopack-image/src/process/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,13 @@ fn compute_blur_data_internal(
let small_image = image.resize(options.size, options.size, FilterType::Triangle);
let width = small_image.width();
let height = small_image.height();
let (data, mime) = encode_image(small_image, format, options.quality)?;
// Prefer WebP for smaller blur placeholders when available
let blur_format = if cfg!(feature = "webp") {
ImageFormat::WebP
} else {
format
};
let (data, mime) = encode_image(small_image, blur_format, options.quality)?;
let data_url = format!(
"data:{mime};base64,{}",
Base64Display::new(&data, &STANDARD)
Expand Down
Loading