feat(variant): image preview on variant creation with a real sample image

This commit is contained in:
Florian Sylvain committed 2026-10-13 17:29:56 +02:00
1 parent a1cdfce529
commit 04dc151ed7
9 files changed
+314 -40

No files matched your search

@@ -8,7 +8,7 @@ import {
} from '../variantEstimate'
import { outputSize } from '../variantOutput'
const SAMPLE = { width: 4032, height: 3024 }
const SAMPLE = { width: 1500, height: 1000 }
function request(overrides: Partial<EstimateRequest>): EstimateRequest {
return {
@@ -28,11 +28,11 @@ function labelOf(overrides: Partial<EstimateRequest>) {
describe('Output size, never enlarged', () => {
it.each([
[1200, 630, 'cover', '1200×630'],
[800, null, 'cover', '800×600'],
[null, 2400, 'inside', '3200×2400'],
[800, null, 'cover', '800×533'],
[null, 2400, 'inside', '1500×1000'],
[600, 600, 'contain', '600×600'],
[1000, 1000, 'inside', '1000×750'],
[8000, 8000, 'cover', '4032×3024'],
[1000, 1000, 'inside', '1000×667'],
[8000, 8000, 'cover', '1500×1000'],
] as const)('width %s height %s fit %s gives %s', (width, height, fit, expected) => {
const size = outputSize(SAMPLE, { width, height, fit })
@@ -61,10 +61,10 @@ describe('Output size, never enlarged', () => {
describe('Size estimate', () => {
it.each([
['hero', 1200, 630, 'cover', 'webp', 82, '1200×630 · about 96 KB'],
['hero', 1200, 630, 'cover', 'webp', 70, '1200×630 · about 71 KB'],
['hero', 1200, 630, 'cover', 'png', 82, '1200×630 · about 310 KB'],
['thumb', 400, 300, 'cover', 'webp', 80, '400×300 · about 18 KB'],
['hero', 1200, 630, 'cover', 'webp', 82, '1200×630 · about 30 KB'],
['hero', 1200, 630, 'cover', 'webp', 70, '1200×630 · about 22 KB'],
['hero', 1200, 630, 'cover', 'png', 82, '1200×630 · about 84 KB'],
['thumb', 400, 300, 'cover', 'webp', 80, '400×300 · about 6 KB'],
] as const)(
'%s %s×%s %s %s q%s reads %s',
(_name, width, height, fit, format, quality, expected) => {
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { canRender, drawPlan, flattensTransparency, mimeFor } from '../variantRender'
const SOURCE = { width: 1500, height: 1000 }
describe('Variant render', () => {
it.each([
['png', 'image/png'],
['webp', 'image/webp'],
['jpeg', 'image/jpeg'],
['original', 'image/jpeg'],
] as const)('encodes %s as %s', (format, mime) => {
expect(mimeFor(format)).toBe(mime)
})
it('crops the centre of the source to fill a wider box', () => {
const plan = drawPlan(SOURCE, { width: 1200, height: 400 }, 'cover')
expect(plan).toEqual({
sx: 0,
sy: 250,
sw: 1500,
sh: 500,
dx: 0,
dy: 0,
dw: 1200,
dh: 400,
})
})
it('keeps the whole source when the ratio already matches', () => {
const plan = drawPlan(SOURCE, { width: 750, height: 500 }, 'inside')
expect([plan.sx, plan.sy, plan.sw, plan.sh]).toEqual([0, 0, 1500, 1000])
expect([plan.dx, plan.dy, plan.dw, plan.dh]).toEqual([0, 0, 750, 500])
})
it('letterboxes the whole source inside a contain box', () => {
const plan = drawPlan(SOURCE, { width: 600, height: 600 }, 'contain')
expect(plan).toEqual({
sx: 0,
sy: 0,
sw: 1500,
sh: 1000,
dx: 0,
dy: 100,
dw: 600,
dh: 400,
})
})
it('never enlarges the source inside a contain box', () => {
const plan = drawPlan(SOURCE, { width: 3000, height: 3000 }, 'contain')
expect([plan.dx, plan.dy, plan.dw, plan.dh]).toEqual([750, 1000, 1500, 1000])
})
it.each([
['jpeg', true],
['original', true],
['png', false],
['webp', false],
] as const)('flattens transparency for %s: %s', (format, flattens) => {
expect(flattensTransparency(format)).toBe(flattens)
})
it('refuses to render huge outputs', () => {
expect(canRender({ width: 4000, height: 4000 })).toBe(true)
expect(canRender({ width: 8000, height: 8000 })).toBe(false)
})
})
+14 -12
View File
@@ -5,23 +5,23 @@ import { outputSize, type OutputRequest, type PixelSize } from '@/lib/variants/v
export type { PixelSize } from '@/lib/variants/variantOutput'
const BYTES_PER_MB = 1024 * 1024
const FULL_QUALITY = 100
const REFERENCE_QUALITY = 82
const PNG_BYTES_PER_PIXEL = 0.42
const SIZE_EXPONENT = 0.885
const QUALITY_EXPONENT = 1.9
const LOSSY_BASE: Record<Exclude<VariantFormat, 'png'>, number> = {
webp: 0.9,
jpeg: 1.4,
original: 1.4,
const FORMAT_WEIGHT: Record<Exclude<VariantFormat, 'png'>, number> = {
webp: 0.65,
jpeg: 1,
original: 1,
}
export const SAMPLE_SOURCE = {
filename: 'north-wall.jpg',
width: 4032,
height: 3024,
bytes: Math.round(3.9 * BYTES_PER_MB),
filename: 'variant-sample.jpg',
width: 1500,
height: 1000,
bytes: 86_382,
}
export interface EstimateRequest extends OutputRequest {
@@ -38,10 +38,12 @@ function rawBytes(pixels: number, request: EstimateRequest) {
if (request.format === 'png') {
return pixels * PNG_BYTES_PER_PIXEL
}
const quality = Math.min(FULL_QUALITY, Math.max(1, request.quality)) / FULL_QUALITY
const quality = Math.min(FULL_QUALITY, Math.max(1, request.quality)) / REFERENCE_QUALITY
const area = pixels / (SAMPLE_SOURCE.width * SAMPLE_SOURCE.height)
return (
LOSSY_BASE[request.format] *
Math.pow(pixels, SIZE_EXPONENT) *
SAMPLE_SOURCE.bytes *
FORMAT_WEIGHT[request.format] *
Math.pow(area, SIZE_EXPONENT) *
Math.pow(quality, QUALITY_EXPONENT)
)
}
+66
View File
@@ -0,0 +1,66 @@
import type { VariantFit, VariantFormat } from '@/composables/useImageVariants'
import type { PixelSize } from '@/lib/variants/variantOutput'
export const MAX_RENDER_PIXELS = 16_000_000
export interface DrawPlan {
sx: number
sy: number
sw: number
sh: number
dx: number
dy: number
dw: number
dh: number
}
export function mimeFor(format: VariantFormat) {
if (format === 'png') {
return 'image/png'
}
if (format === 'webp') {
return 'image/webp'
}
return 'image/jpeg'
}
export function flattensTransparency(format: VariantFormat) {
return mimeFor(format) === 'image/jpeg'
}
export function canRender(size: PixelSize) {
return size.width * size.height <= MAX_RENDER_PIXELS
}
export function drawPlan(source: PixelSize, target: PixelSize, fit: VariantFit): DrawPlan {
const widthScale = target.width / source.width
const heightScale = target.height / source.height
if (fit === 'contain') {
const scale = Math.min(1, widthScale, heightScale)
const dw = source.width * scale
const dh = source.height * scale
return {
sx: 0,
sy: 0,
sw: source.width,
sh: source.height,
dx: (target.width - dw) / 2,
dy: (target.height - dh) / 2,
dw,
dh,
}
}
const scale = Math.max(widthScale, heightScale)
const sw = target.width / scale
const sh = target.height / scale
return {
sx: (source.width - sw) / 2,
sy: (source.height - sh) / 2,
sw,
sh,
dx: 0,
dy: 0,
dw: target.width,
dh: target.height,
}
}