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
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user