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

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

+17 -2
View File
@@ -17,6 +17,8 @@ const CHIP =
const props = defineProps<{
name: string
comparable: boolean
originalSrc?: string | null
variantSrc?: string | null
}>()
const position = defineModel<number>({ default: 50 })
@@ -64,7 +66,13 @@ function onKeydown(event: KeyboardEvent) {
class="absolute inset-0 flex items-center justify-center bg-(--ui-color-secondary-200) text-muted dark:bg-(--ui-color-secondary-300)"
:class="PATTERN"
>
<UIcon name="i-lucide-image" class="size-8 opacity-55" />
<img
v-if="originalSrc"
:src="originalSrc"
alt=""
class="absolute inset-0 size-full object-cover"
/>
<UIcon v-else name="i-lucide-image" class="size-8 opacity-55" />
</div>
<template v-if="comparable">
<div
@@ -72,7 +80,14 @@ function onKeydown(event: KeyboardEvent) {
class="absolute inset-0 bg-accented"
:class="VARIANT_PATTERN"
:style="{ clipPath: `inset(0 0 0 ${position}%)` }"
/>
>
<img
v-if="variantSrc"
:src="variantSrc"
alt=""
class="absolute inset-0 size-full object-contain"
/>
</div>
<span :class="[CHIP, 'left-2']">{{ t('Original') }}</span>
<span :class="[CHIP, 'right-2 font-mono']">{{ shownName }}</span>
<input
+29 -8
View File
@@ -4,7 +4,13 @@ import { t } from '@/i18n'
import { computed, ref } from 'vue'
import ImageCompare from '@/components/variants/ImageCompare.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { SAMPLE_SOURCE, estimateLabel, estimateVariant } from '@/lib/variants/variantEstimate'
import { useVariantRender } from '@/composables/useVariantRender'
import {
SAMPLE_SOURCE,
estimateLabel,
estimateVariant,
type EstimateRequest,
} from '@/lib/variants/variantEstimate'
import { parseDimension, sliderValue, type VariantFormValues } from '@/lib/variants/variantForm'
import { formatVariantSize } from '@/lib/variants/variantFormat'
@@ -25,22 +31,31 @@ const dimensionsAreValid = computed(
(props.values.height.trim() === '' || parseDimension(props.values.height) !== null),
)
const estimate = computed(() =>
const request = computed<EstimateRequest | null>(() =>
dimensionsAreValid.value
? estimateVariant({
? {
width: parseDimension(props.values.width),
height: parseDimension(props.values.height),
fit: props.values.fit,
format: props.values.format,
quality: sliderValue(props.values.quality),
})
}
: null,
)
const estimate = computed(() => (request.value ? estimateVariant(request.value) : null))
const { original, variant } = useVariantRender(request)
const shownName = computed(() => props.values.name || t('name'))
const outputLine = computed(() =>
estimate.value ? estimateLabel(estimate.value) : t('No output yet'),
)
const outputLine = computed(() => {
if (!estimate.value) {
return t('No output yet')
}
const bytes = variant.value
? Math.min(SAMPLE_SOURCE.bytes, variant.value.bytes)
: estimate.value.bytes
return estimateLabel({ size: estimate.value.size, bytes })
})
const originalLine = computed(
() =>
`${SAMPLE_SOURCE.width} × ${SAMPLE_SOURCE.height} · ${formatVariantSize(SAMPLE_SOURCE.bytes)}`,
@@ -67,7 +82,13 @@ async function onCopy() {
t('Sample: {0}', [SAMPLE_SOURCE.filename])
}}</span>
</div>
<ImageCompare v-model="position" :name="values.name" :comparable="estimate !== null" />
<ImageCompare
v-model="position"
:name="values.name"
:comparable="estimate !== null"
:original-src="original"
:variant-src="variant?.url ?? null"
/>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col">
<span class="font-medium text-highlighted">{{ t('Original') }}</span>
+98
View File
@@ -0,0 +1,98 @@
import { onScopeDispose, ref, watch, type Ref } from 'vue'
import sampleUrl from '@/assets/variant-sample.jpg'
import { SAMPLE_SOURCE, type EstimateRequest } from '@/lib/variants/variantEstimate'
import { outputSize } from '@/lib/variants/variantOutput'
import { canRender, drawPlan, flattensTransparency, mimeFor } from '@/lib/variants/variantRender'
const RENDER_DELAY_MS = 150
const PERCENT = 100
export interface RenderedVariant {
url: string
bytes: number
}
let sampleImage: Promise<HTMLImageElement | null> | null = null
function loadSample() {
sampleImage ??= new Promise((resolve) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => resolve(null)
image.src = sampleUrl
})
return sampleImage
}
function encode(canvas: HTMLCanvasElement, request: EstimateRequest) {
const mime = mimeFor(request.format)
const quality = mime === 'image/png' ? undefined : request.quality / PERCENT
return new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, mime, quality))
}
async function renderVariant(image: HTMLImageElement, request: EstimateRequest) {
const size = outputSize(SAMPLE_SOURCE, request)
if (size === null || !canRender(size)) {
return null
}
const canvas = document.createElement('canvas')
canvas.width = size.width
canvas.height = size.height
const context = canvas.getContext('2d')
if (context === null) {
return null
}
if (flattensTransparency(request.format)) {
context.fillStyle = 'white'
context.fillRect(0, 0, size.width, size.height)
}
const plan = drawPlan(SAMPLE_SOURCE, size, request.fit)
context.drawImage(image, plan.sx, plan.sy, plan.sw, plan.sh, plan.dx, plan.dy, plan.dw, plan.dh)
return encode(canvas, request)
}
export function useVariantRender(request: Ref<EstimateRequest | null>) {
const original = ref<string | null>(null)
const variant = ref<RenderedVariant | null>(null)
let timer: ReturnType<typeof setTimeout> | undefined
let token = 0
function release() {
if (variant.value) {
URL.revokeObjectURL(variant.value.url)
variant.value = null
}
}
async function run(current: EstimateRequest | null, ticket: number) {
const image = await loadSample()
if (image === null || ticket !== token) {
return
}
original.value = sampleUrl
const blob = current === null ? null : await renderVariant(image, current)
if (ticket !== token) {
return
}
release()
if (blob !== null) {
variant.value = { url: URL.createObjectURL(blob), bytes: blob.size }
}
}
function schedule(current: EstimateRequest | null) {
clearTimeout(timer)
const ticket = ++token
timer = setTimeout(() => void run(current, ticket), RENDER_DELAY_MS)
}
watch(request, schedule, { immediate: true, deep: true })
onScopeDispose(() => {
clearTimeout(timer)
token++
release()
})
return { original, variant }
}
@@ -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,
}
}
+9 -9
View File
@@ -59,7 +59,7 @@ const QUALITY_ERROR = 'Enter a value from 1 to 100.'
const DIMENSION_ERROR = 'Set a width, a height, or both.'
const DIMENSION_HELP = 'Set a width, a height, or both. 1–8000 px. Images are never enlarged.'
const SAVE_ERROR_TITLE = "Couldn't save the variant"
const URL_PREFIX = '/static/uploadedImages/north-wall.jpg?v='
const URL_PREFIX = '/static/uploadedImages/variant-sample.jpg?v='
const WARNING_ALERT =
'Changing dimensions, fit, format or quality purges 244 generated files; they will be regenerated on next request.'
@@ -438,7 +438,7 @@ describe('Rule: Form states', () => {
'i-lucide-circle-dot',
)
expect(previewText()).toContain('hero')
expect(previewText()).toContain('1200×630 · about 96 KB')
expect(previewText()).toContain('1200×630 · about 30 KB')
})
it.each([
@@ -853,14 +853,14 @@ describe('Rule: Saving an edit', () => {
it('Edit: dirty', async () => {
await mountPage()
await openEditForm('hero')
expect(previewText()).toContain('1200×630 · about 96 KB')
expect(previewText()).toContain('1200×630 · about 30 KB')
await typeInto(qualityInput(), '70')
expect(saveButton('Save changes').disabled).toBe(false)
expect(footerStatus()).toBe('Unsaved changes')
expect(qualityInput()!.value).toBe('70')
expect(previewText()).toContain('1200×630 · about 71 KB')
expect(previewText()).toContain('1200×630 · about 22 KB')
expect(form().querySelector('[data-testid="edit-warning"]')).not.toBeNull()
})
@@ -954,13 +954,13 @@ describe('Rule: Preview and ImageCompare', () => {
const panel = form()
expect(panel.querySelector('h3')?.textContent).toBe('Preview')
expect(panel.textContent).toContain('Sample: north-wall.jpg')
expect(panel.textContent).toContain('Sample: variant-sample.jpg')
const box = panel.querySelector('[data-testid="image-compare"]')!
expect(box.className).toContain('aspect-3/2')
expect(box.className).toContain('border')
expect(box.className).toContain('overflow-hidden')
const labels = box.nextElementSibling!
expect(textOf(labels)).toBe('Original 4032 × 3024 · 3.9 MB hero 1200×630 · about 96 KB')
expect(textOf(labels)).toBe('Original 1500 × 1000 · 84 KB hero 1200×630 · about 30 KB')
expect(panel.textContent).toContain(
'Drag the divider, or focus it and use the arrow keys. The estimate comes from a sample; output is never larger than the source.',
)
@@ -1078,7 +1078,7 @@ describe('Rule: Preview and ImageCompare', () => {
expect(overlay.className).toContain('bottom-3')
expect(overlay.className).toContain('left-3')
expect(textOf(box.nextElementSibling!)).toBe(
'Original 4032 × 3024 · 3.9 MB name No output yet',
'Original 1500 × 1000 · 84 KB name No output yet',
)
})
@@ -1089,7 +1089,7 @@ describe('Rule: Preview and ImageCompare', () => {
await typeInto(field('width'), '800')
expect(compareInput().getAttribute('aria-label')).toBe('Compare original and name')
expect(previewText()).toContain('name 800×600 · about')
expect(previewText()).toContain('name 800×533 · about')
expect(
Array.from(form().querySelectorAll('span.absolute.top-2')).map(
(chip) => chip.textContent,
@@ -1107,7 +1107,7 @@ describe('Rule: Preview and ImageCompare', () => {
expect(labels.map(Number)).toEqual([...labels.map(Number)].sort((a, b) => a - b))
await setFormat('png')
expect(previewText()).toContain('1200×630 · about 310 KB')
expect(previewText()).toContain('1200×630 · about 84 KB')
})
})