feat(variant): image preview on variant creation with a real sample image
This commit is contained in:
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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user