feat: image variants settings screens
This commit is contained in:
1 parent
f1d56ef50e
commit
f567866161
42 files changed
+7192
-11
No files matched your search
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
SAMPLE_SOURCE,
|
||||
estimateLabel,
|
||||
estimateVariant,
|
||||
formatPixelSize,
|
||||
type EstimateRequest,
|
||||
} from '../variantEstimate'
|
||||
import { outputSize } from '../variantOutput'
|
||||
|
||||
const SAMPLE = { width: 4032, height: 3024 }
|
||||
|
||||
function request(overrides: Partial<EstimateRequest>): EstimateRequest {
|
||||
return {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fit: 'cover',
|
||||
format: 'webp',
|
||||
quality: 82,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function labelOf(overrides: Partial<EstimateRequest>) {
|
||||
return estimateLabel(estimateVariant(request(overrides))!)
|
||||
}
|
||||
|
||||
describe('Output size, never enlarged', () => {
|
||||
it.each([
|
||||
[1200, 630, 'cover', '1200×630'],
|
||||
[800, null, 'cover', '800×600'],
|
||||
[null, 2400, 'inside', '3200×2400'],
|
||||
[600, 600, 'contain', '600×600'],
|
||||
[1000, 1000, 'inside', '1000×750'],
|
||||
[8000, 8000, 'cover', '4032×3024'],
|
||||
] as const)('width %s height %s fit %s gives %s', (width, height, fit, expected) => {
|
||||
const size = outputSize(SAMPLE, { width, height, fit })
|
||||
|
||||
expect(formatPixelSize(size!)).toBe(expected)
|
||||
})
|
||||
|
||||
it('has no output without a dimension', () => {
|
||||
expect(outputSize(SAMPLE, { width: null, height: null, fit: 'cover' })).toBeNull()
|
||||
})
|
||||
|
||||
it('crops to the box ratio when the box is smaller than the source in one dimension', () => {
|
||||
const size = outputSize(
|
||||
{ width: 300, height: 200 },
|
||||
{ width: 400, height: 150, fit: 'cover' },
|
||||
)
|
||||
|
||||
expect(size).toEqual({ width: 300, height: 113 })
|
||||
})
|
||||
|
||||
it('crops a square box out of the sample', () => {
|
||||
const size = outputSize(SAMPLE, { width: 1000, height: 1000, fit: 'cover' })
|
||||
|
||||
expect(size).toEqual({ width: 1000, height: 1000 })
|
||||
})
|
||||
})
|
||||
|
||||
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'],
|
||||
] as const)(
|
||||
'%s %s×%s %s %s q%s reads %s',
|
||||
(_name, width, height, fit, format, quality, expected) => {
|
||||
expect(labelOf({ width, height, fit, format, quality })).toBe(expected)
|
||||
},
|
||||
)
|
||||
|
||||
it('gives a smaller or equal estimate for a lower quality', () => {
|
||||
const bytes = (quality: number) => estimateVariant(request({ quality }))!.bytes
|
||||
const sizes = [1, 20, 40, 60, 80, 100].map(bytes)
|
||||
|
||||
expect(sizes).toEqual([...sizes].sort((left, right) => left - right))
|
||||
})
|
||||
|
||||
it('never exceeds the original size', () => {
|
||||
const estimate = estimateVariant(
|
||||
request({ width: 8000, height: 8000, format: 'png', quality: 100 }),
|
||||
)
|
||||
|
||||
expect(estimate!.bytes).toBeLessThanOrEqual(SAMPLE_SOURCE.bytes)
|
||||
})
|
||||
|
||||
it('is empty without a dimension', () => {
|
||||
expect(estimateVariant(request({ width: null, height: null }))).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
VARIANT_TEMPLATES,
|
||||
blankValues,
|
||||
hasChanged,
|
||||
lacksDimension,
|
||||
sliderValue,
|
||||
toDefinition,
|
||||
toNewVariant,
|
||||
validateField,
|
||||
valuesFromTemplate,
|
||||
valuesFromVariant,
|
||||
type FieldName,
|
||||
type VariantFormValues,
|
||||
} from '../variantForm'
|
||||
|
||||
const NAME_ERROR = 'Use lowercase letters, numbers and dashes, 2 to 32 characters.'
|
||||
const RANGE_ERROR = 'Enter a value from 1 to 8000.'
|
||||
const QUALITY_ERROR = 'Enter a value from 1 to 100.'
|
||||
|
||||
function values(overrides: Partial<VariantFormValues>): VariantFormValues {
|
||||
return { ...blankValues(), name: 'hero', width: '1200', height: '630', ...overrides }
|
||||
}
|
||||
|
||||
describe('Field errors', () => {
|
||||
it.each([
|
||||
['name', { name: 'Hero banner' }, NAME_ERROR],
|
||||
['name', { name: 'a' }, NAME_ERROR],
|
||||
['name', { name: 'a'.repeat(33) }, NAME_ERROR],
|
||||
['width', { width: '9000' }, RANGE_ERROR],
|
||||
['width', { width: '0' }, RANGE_ERROR],
|
||||
['height', { height: '8001' }, RANGE_ERROR],
|
||||
['quality', { quality: '140' }, QUALITY_ERROR],
|
||||
['quality', { quality: '0' }, QUALITY_ERROR],
|
||||
] as [FieldName, Partial<VariantFormValues>, string][])(
|
||||
'%s %j reads the message',
|
||||
(field, overrides, message) => {
|
||||
expect(validateField(field, values(overrides))).toBe(message)
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
['name', { name: 'ab' }],
|
||||
['name', { name: 'a-b-c' }],
|
||||
['name', { name: 'a'.repeat(32) }],
|
||||
['width', { width: '1' }],
|
||||
['height', { height: '8000' }],
|
||||
['quality', { quality: '1' }],
|
||||
['quality', { quality: '100' }],
|
||||
] as [FieldName, Partial<VariantFormValues>][])('%s %j shows no error', (field, overrides) => {
|
||||
expect(validateField(field, values(overrides))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not validate the quality of a PNG', () => {
|
||||
expect(validateField('quality', values({ format: 'png', quality: '999' }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats an empty dimension as absent', () => {
|
||||
expect(validateField('width', values({ width: '' }))).toBeUndefined()
|
||||
expect(lacksDimension(values({ width: '', height: '' }))).toBe(true)
|
||||
expect(lacksDimension(values({ width: '', height: '9' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Request body', () => {
|
||||
it('builds the create body from the form', () => {
|
||||
const body = toNewVariant(
|
||||
values({ format: 'webp', quality: '82', generateOnUpload: true, enabled: true }),
|
||||
)
|
||||
|
||||
expect(body).toEqual({
|
||||
name: 'hero',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fit: 'cover',
|
||||
format: 'webp',
|
||||
quality: 82,
|
||||
generate_on_upload: true,
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('sends an empty dimension as null', () => {
|
||||
const body = toNewVariant(values({ name: 'card', width: '800', height: '' }))
|
||||
|
||||
expect(body.width).toBe(800)
|
||||
expect(body.height).toBeNull()
|
||||
})
|
||||
|
||||
it('omits the quality of a PNG', () => {
|
||||
const definition = toDefinition(values({ format: 'png', quality: '70' }))
|
||||
|
||||
expect('quality' in definition).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form values', () => {
|
||||
it('starts blank with the documented defaults', () => {
|
||||
expect(blankValues()).toEqual({
|
||||
name: '',
|
||||
width: '',
|
||||
height: '',
|
||||
fit: 'cover',
|
||||
format: 'original',
|
||||
quality: '80',
|
||||
generateOnUpload: false,
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['thumb', '400', '300', '80'],
|
||||
['hero', '1200', '630', '82'],
|
||||
])('template %s pre-fills %s×%s q%s as cover WebP', (name, width, height, quality) => {
|
||||
const template = VARIANT_TEMPLATES.find((entry) => entry.name === name)!
|
||||
|
||||
expect(valuesFromTemplate(template)).toMatchObject({
|
||||
name,
|
||||
width,
|
||||
height,
|
||||
quality,
|
||||
fit: 'cover',
|
||||
format: 'webp',
|
||||
generateOnUpload: false,
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a stored PNG variant with a default hidden quality', () => {
|
||||
const form = valuesFromVariant({
|
||||
name: 'print',
|
||||
width: null,
|
||||
height: 2400,
|
||||
fit: 'inside',
|
||||
format: 'png',
|
||||
quality: null,
|
||||
generate_on_upload: false,
|
||||
enabled: false,
|
||||
generated_files: 74,
|
||||
cache_size: 0,
|
||||
})
|
||||
|
||||
expect([form.width, form.height, form.quality]).toEqual(['', '2400', '80'])
|
||||
})
|
||||
|
||||
it('detects a change against the baseline', () => {
|
||||
expect(hasChanged(blankValues(), blankValues())).toBe(false)
|
||||
expect(hasChanged({ ...blankValues(), name: 'x' }, blankValues())).toBe(true)
|
||||
})
|
||||
|
||||
it('clamps the slider position', () => {
|
||||
expect([sliderValue('60'), sliderValue('140'), sliderValue('0'), sliderValue('')]).toEqual([
|
||||
60, 100, 1, 1,
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import type { ImageVariant } from '@/composables/useImageVariants'
|
||||
import {
|
||||
formatDimensions,
|
||||
formatLabel,
|
||||
formatVariantSize,
|
||||
generatedLine,
|
||||
qualityCell,
|
||||
specLine,
|
||||
variantsSummary,
|
||||
} from '../variantFormat'
|
||||
|
||||
const MB = 1048576
|
||||
|
||||
function variant(overrides: Partial<ImageVariant>): ImageVariant {
|
||||
return {
|
||||
name: 'hero',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fit: 'cover',
|
||||
format: 'webp',
|
||||
quality: 82,
|
||||
generate_on_upload: false,
|
||||
enabled: true,
|
||||
generated_files: 244,
|
||||
cache_size: 84 * MB,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function defaultFixture(): ImageVariant[] {
|
||||
return [
|
||||
variant({
|
||||
name: 'card',
|
||||
width: 800,
|
||||
height: null,
|
||||
fit: 'inside',
|
||||
quality: 80,
|
||||
generated_files: 318,
|
||||
cache_size: 58 * MB,
|
||||
}),
|
||||
variant({ name: 'hero' }),
|
||||
variant({
|
||||
name: 'og',
|
||||
format: 'jpeg',
|
||||
quality: 80,
|
||||
generated_files: 96,
|
||||
cache_size: 9 * MB,
|
||||
}),
|
||||
variant({
|
||||
name: 'print',
|
||||
width: null,
|
||||
height: 2400,
|
||||
fit: 'inside',
|
||||
format: 'png',
|
||||
quality: null,
|
||||
enabled: false,
|
||||
generated_files: 74,
|
||||
cache_size: 21 * MB,
|
||||
}),
|
||||
variant({
|
||||
name: 'square',
|
||||
width: 600,
|
||||
height: 600,
|
||||
format: 'jpeg',
|
||||
quality: 85,
|
||||
generated_files: 140,
|
||||
cache_size: 19 * MB,
|
||||
}),
|
||||
variant({
|
||||
name: 'thumb',
|
||||
width: 400,
|
||||
height: 300,
|
||||
quality: 80,
|
||||
generated_files: 412,
|
||||
cache_size: 21 * MB,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
describe('Dimensions, quality and format cells', () => {
|
||||
it.each([
|
||||
[400, 300, 'webp', 80, '400×300', 'WebP', '80'],
|
||||
[800, null, 'jpeg', 80, '800 × auto', 'JPEG', '80'],
|
||||
[null, 2400, 'png', null, 'auto × 2400', 'PNG', '—'],
|
||||
[600, 600, 'original', 82, '600×600', 'Original', '82'],
|
||||
] as const)(
|
||||
'width %s height %s format %s quality %s reads %s, %s, %s',
|
||||
(width, height, format, quality, dimensions, label, cell) => {
|
||||
const entry = variant({ width, height, format, quality })
|
||||
|
||||
expect(formatDimensions(entry.width, entry.height)).toBe(dimensions)
|
||||
expect(formatLabel(entry.format)).toBe(label)
|
||||
expect(qualityCell(entry)).toBe(cell)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Spec line of a mobile card', () => {
|
||||
it('joins dimensions, fit, format and quality', () => {
|
||||
expect(specLine(variant({}))).toBe('1200×630 · cover · WebP · q82')
|
||||
})
|
||||
|
||||
it('has no quality part for print', () => {
|
||||
const print = defaultFixture()[3]!
|
||||
|
||||
expect(specLine(print)).toBe('auto × 2400 · inside · PNG')
|
||||
})
|
||||
|
||||
it('reads the generated count and size', () => {
|
||||
expect(generatedLine(variant({}))).toBe('244 generated · 84 MB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Footer sums are computed from the list', () => {
|
||||
it('sums the Default fixture', () => {
|
||||
expect(variantsSummary(defaultFixture())).toBe(
|
||||
'6 variants · 1,284 generated files · 212 MB',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the singular for one variant with one file', () => {
|
||||
const thumb = variant({ name: 'thumb', generated_files: 1, cache_size: 18 * 1024 })
|
||||
|
||||
expect(variantsSummary([thumb])).toBe('1 variant · 1 generated file · 18 KB')
|
||||
})
|
||||
|
||||
it('recomputes after a deletion', () => {
|
||||
const rest = defaultFixture().filter((entry) => entry.name !== 'hero')
|
||||
|
||||
expect(variantsSummary(rest)).toBe('5 variants · 1,040 generated files · 128 MB')
|
||||
})
|
||||
|
||||
it('reads an empty cache as 0 KB', () => {
|
||||
expect(variantsSummary([variant({ generated_files: 0, cache_size: 0 })])).toBe(
|
||||
'1 variant · 0 generated files · 0 KB',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Variant size formatter', () => {
|
||||
it.each([
|
||||
[0, '0 KB'],
|
||||
[1, '1 KB'],
|
||||
[18 * 1024, '18 KB'],
|
||||
[1023 * 1024, '1023 KB'],
|
||||
[MB, '1 MB'],
|
||||
[Math.round(3.9 * MB), '3.9 MB'],
|
||||
[84 * MB, '84 MB'],
|
||||
[88080384, '84 MB'],
|
||||
[2048 * MB, '2 GB'],
|
||||
])('formats %s bytes as %s', (bytes, expected) => {
|
||||
expect(formatVariantSize(bytes)).toBe(expected)
|
||||
})
|
||||
|
||||
it('uses French units and decimal comma', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect([formatVariantSize(18 * 1024), formatVariantSize(Math.round(3.9 * MB))]).toEqual([
|
||||
'18 Ko',
|
||||
'3,9 Mo',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { t } from '@/i18n'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
|
||||
export interface ToastCopy {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const BAD_REQUEST = 400
|
||||
const FORBIDDEN = 403
|
||||
const NOT_FOUND = 404
|
||||
|
||||
export function toastCopyFor(error: unknown): ToastCopy {
|
||||
const status = error instanceof ApiError ? error.status : 0
|
||||
if (status === BAD_REQUEST) {
|
||||
return {
|
||||
title: t('Check the request'),
|
||||
description: t("A value in the request isn't valid. Reload the page and try again."),
|
||||
}
|
||||
}
|
||||
if (status === FORBIDDEN) {
|
||||
return {
|
||||
title: t("You don't have access"),
|
||||
description: t('Only admins can manage image variants.'),
|
||||
}
|
||||
}
|
||||
if (status === NOT_FOUND) {
|
||||
return {
|
||||
title: t('Not found'),
|
||||
description: t('This variant or image no longer exists. Refresh the page.'),
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: t('Something went wrong'),
|
||||
description: t(
|
||||
"The server couldn't finish. Try again. If it keeps happening, check the server log.",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function isStatus(error: unknown, status: number) {
|
||||
return error instanceof ApiError && error.status === status
|
||||
}
|
||||
|
||||
export function isHandledByApiClient(error: unknown) {
|
||||
return error instanceof ApiError && error.status === 401
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { VariantFormat } from '@/composables/useImageVariants'
|
||||
import { formatVariantSize } from '@/lib/variants/variantFormat'
|
||||
import { outputSize, type OutputRequest, type PixelSize } from '@/lib/variants/variantOutput'
|
||||
|
||||
export type { PixelSize } from '@/lib/variants/variantOutput'
|
||||
|
||||
const BYTES_PER_MB = 1024 * 1024
|
||||
const FULL_QUALITY = 100
|
||||
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,
|
||||
}
|
||||
|
||||
export const SAMPLE_SOURCE = {
|
||||
filename: 'north-wall.jpg',
|
||||
width: 4032,
|
||||
height: 3024,
|
||||
bytes: Math.round(3.9 * BYTES_PER_MB),
|
||||
}
|
||||
|
||||
export interface EstimateRequest extends OutputRequest {
|
||||
format: VariantFormat
|
||||
quality: number
|
||||
}
|
||||
|
||||
export interface VariantEstimate {
|
||||
size: PixelSize
|
||||
bytes: number
|
||||
}
|
||||
|
||||
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
|
||||
return (
|
||||
LOSSY_BASE[request.format] *
|
||||
Math.pow(pixels, SIZE_EXPONENT) *
|
||||
Math.pow(quality, QUALITY_EXPONENT)
|
||||
)
|
||||
}
|
||||
|
||||
export function estimateVariant(request: EstimateRequest): VariantEstimate | null {
|
||||
const size = outputSize(SAMPLE_SOURCE, request)
|
||||
if (size === null) {
|
||||
return null
|
||||
}
|
||||
const bytes = Math.min(SAMPLE_SOURCE.bytes, rawBytes(size.width * size.height, request))
|
||||
return { size, bytes }
|
||||
}
|
||||
|
||||
export function formatPixelSize(size: PixelSize) {
|
||||
return `${size.width}×${size.height}`
|
||||
}
|
||||
|
||||
export function estimateLabel(estimate: VariantEstimate) {
|
||||
return t('{0} · about {1}', [formatPixelSize(estimate.size), formatVariantSize(estimate.bytes)])
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { t } from '@/i18n'
|
||||
import type {
|
||||
ImageVariant,
|
||||
NewVariantRequest,
|
||||
VariantDefinition,
|
||||
VariantFit,
|
||||
VariantFormat,
|
||||
} from '@/composables/useImageVariants'
|
||||
|
||||
export const NAME_MAX_LENGTH = 32
|
||||
const DIMENSION_MIN = 1
|
||||
const DIMENSION_MAX = 8000
|
||||
export const QUALITY_MIN = 1
|
||||
export const QUALITY_MAX = 100
|
||||
const DEFAULT_QUALITY = 80
|
||||
|
||||
const NAME_PATTERN = /^[a-z0-9-]{2,32}$/
|
||||
const WHOLE_NUMBER = /^\d+$/
|
||||
|
||||
export type FieldName = 'name' | 'width' | 'height' | 'quality'
|
||||
|
||||
export interface VariantFormValues {
|
||||
name: string
|
||||
width: string
|
||||
height: string
|
||||
fit: VariantFit
|
||||
format: VariantFormat
|
||||
quality: string
|
||||
generateOnUpload: boolean
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type FieldErrors = Partial<Record<FieldName, string>> & { dimension?: string }
|
||||
|
||||
export const FIELD_ORDER: FieldName[] = ['name', 'width', 'height', 'quality']
|
||||
|
||||
export const VARIANT_TEMPLATES = [
|
||||
{ name: 'thumb', width: 400, height: 300, quality: 80 },
|
||||
{ name: 'hero', width: 1200, height: 630, quality: 82 },
|
||||
] as const
|
||||
|
||||
export function blankValues(): VariantFormValues {
|
||||
return {
|
||||
name: '',
|
||||
width: '',
|
||||
height: '',
|
||||
fit: 'cover',
|
||||
format: 'original',
|
||||
quality: String(DEFAULT_QUALITY),
|
||||
generateOnUpload: false,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function valuesFromTemplate(
|
||||
template: (typeof VARIANT_TEMPLATES)[number],
|
||||
): VariantFormValues {
|
||||
return {
|
||||
...blankValues(),
|
||||
name: template.name,
|
||||
width: String(template.width),
|
||||
height: String(template.height),
|
||||
format: 'webp',
|
||||
quality: String(template.quality),
|
||||
}
|
||||
}
|
||||
|
||||
export function valuesFromVariant(variant: ImageVariant): VariantFormValues {
|
||||
return {
|
||||
name: variant.name,
|
||||
width: variant.width === null ? '' : String(variant.width),
|
||||
height: variant.height === null ? '' : String(variant.height),
|
||||
fit: variant.fit,
|
||||
format: variant.format,
|
||||
quality: String(variant.quality ?? DEFAULT_QUALITY),
|
||||
generateOnUpload: variant.generate_on_upload,
|
||||
enabled: variant.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
export function hasChanged(current: VariantFormValues, initial: VariantFormValues) {
|
||||
return (Object.keys(initial) as (keyof VariantFormValues)[]).some(
|
||||
(key) => current[key] !== initial[key],
|
||||
)
|
||||
}
|
||||
|
||||
export function usesQuality(format: VariantFormat) {
|
||||
return format !== 'png'
|
||||
}
|
||||
|
||||
function wholeNumberInRange(text: string, min: number, max: number) {
|
||||
const trimmed = text.trim()
|
||||
if (!WHOLE_NUMBER.test(trimmed)) {
|
||||
return null
|
||||
}
|
||||
const value = Number(trimmed)
|
||||
return value >= min && value <= max ? value : null
|
||||
}
|
||||
|
||||
export function parseDimension(text: string) {
|
||||
return wholeNumberInRange(text, DIMENSION_MIN, DIMENSION_MAX)
|
||||
}
|
||||
|
||||
function parseQuality(text: string) {
|
||||
return wholeNumberInRange(text, QUALITY_MIN, QUALITY_MAX)
|
||||
}
|
||||
|
||||
export function validateField(field: FieldName, values: VariantFormValues): string | undefined {
|
||||
if (field === 'name') {
|
||||
return NAME_PATTERN.test(values.name)
|
||||
? undefined
|
||||
: t('Use lowercase letters, numbers and dashes, 2 to 32 characters.')
|
||||
}
|
||||
if (field === 'quality') {
|
||||
return !usesQuality(values.format) || parseQuality(values.quality) !== null
|
||||
? undefined
|
||||
: t('Enter a value from 1 to 100.')
|
||||
}
|
||||
const text = values[field]
|
||||
return text.trim() === '' || parseDimension(text) !== null
|
||||
? undefined
|
||||
: t('Enter a value from 1 to 8000.')
|
||||
}
|
||||
|
||||
export function lacksDimension(values: VariantFormValues) {
|
||||
return values.width.trim() === '' && values.height.trim() === ''
|
||||
}
|
||||
|
||||
export function sliderValue(text: string) {
|
||||
const parsed = Number.parseInt(text, 10)
|
||||
if (Number.isNaN(parsed)) {
|
||||
return QUALITY_MIN
|
||||
}
|
||||
return Math.min(QUALITY_MAX, Math.max(QUALITY_MIN, parsed))
|
||||
}
|
||||
|
||||
export function toDefinition(values: VariantFormValues): VariantDefinition {
|
||||
const definition: VariantDefinition = {
|
||||
width: parseDimension(values.width),
|
||||
height: parseDimension(values.height),
|
||||
fit: values.fit,
|
||||
format: values.format,
|
||||
generate_on_upload: values.generateOnUpload,
|
||||
enabled: values.enabled,
|
||||
}
|
||||
if (usesQuality(values.format)) {
|
||||
definition.quality = parseQuality(values.quality) ?? DEFAULT_QUALITY
|
||||
}
|
||||
return definition
|
||||
}
|
||||
|
||||
export function toNewVariant(values: VariantFormValues): NewVariantRequest {
|
||||
return { name: values.name, ...toDefinition(values) }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { formatNumber, t } from '@/i18n'
|
||||
import type { ImageVariant, VariantFit, VariantFormat } from '@/composables/useImageVariants'
|
||||
|
||||
const BYTES_PER_KB = 1024
|
||||
const BYTES_PER_MB = BYTES_PER_KB * 1024
|
||||
const KB_PER_MB = 1024
|
||||
const MB_PER_GB = 1024
|
||||
const NO_QUALITY = '—'
|
||||
const SEPARATOR = ' · '
|
||||
|
||||
function oneDecimal(value: number) {
|
||||
return Math.round(value * 10) / 10
|
||||
}
|
||||
|
||||
export function formatVariantSize(bytes: number) {
|
||||
const kilobytes = Math.round(bytes / BYTES_PER_KB)
|
||||
if (bytes === 0) {
|
||||
return t('{0} KB', [0])
|
||||
}
|
||||
if (kilobytes < KB_PER_MB) {
|
||||
return t('{0} KB', [Math.max(1, kilobytes)])
|
||||
}
|
||||
const megabytes = bytes / BYTES_PER_MB
|
||||
if (megabytes < MB_PER_GB) {
|
||||
return t('{0} MB', [oneDecimal(megabytes)])
|
||||
}
|
||||
return t('{0} GB', [oneDecimal(megabytes / MB_PER_GB)])
|
||||
}
|
||||
|
||||
export function formatDimensions(width: number | null, height: number | null) {
|
||||
if (width !== null && height !== null) {
|
||||
return `${width}×${height}`
|
||||
}
|
||||
if (width !== null) {
|
||||
return `${width} × ${t('auto')}`
|
||||
}
|
||||
return `${t('auto')} × ${height ?? ''}`
|
||||
}
|
||||
|
||||
export function formatLabel(format: VariantFormat) {
|
||||
const labels: Record<VariantFormat, string> = {
|
||||
webp: 'WebP',
|
||||
jpeg: 'JPEG',
|
||||
png: 'PNG',
|
||||
original: t('Original'),
|
||||
}
|
||||
return labels[format]
|
||||
}
|
||||
|
||||
export function fitLabel(fit: VariantFit) {
|
||||
const labels: Record<VariantFit, string> = {
|
||||
cover: t('cover'),
|
||||
contain: t('contain'),
|
||||
inside: t('inside'),
|
||||
}
|
||||
return labels[fit]
|
||||
}
|
||||
|
||||
function hasQuality(variant: Pick<ImageVariant, 'format' | 'quality'>) {
|
||||
return variant.format !== 'png' && variant.quality !== null
|
||||
}
|
||||
|
||||
export function qualityCell(variant: Pick<ImageVariant, 'format' | 'quality'>) {
|
||||
return hasQuality(variant) ? String(variant.quality) : NO_QUALITY
|
||||
}
|
||||
|
||||
export function specLine(variant: ImageVariant) {
|
||||
const parts = [
|
||||
formatDimensions(variant.width, variant.height),
|
||||
fitLabel(variant.fit),
|
||||
formatLabel(variant.format),
|
||||
]
|
||||
if (hasQuality(variant)) {
|
||||
parts.push(t('q{0}', [variant.quality ?? 0]))
|
||||
}
|
||||
return parts.join(SEPARATOR)
|
||||
}
|
||||
|
||||
export function generatedLine(variant: ImageVariant) {
|
||||
return t('{0} generated · {1}', [
|
||||
formatNumber(variant.generated_files),
|
||||
formatVariantSize(variant.cache_size),
|
||||
])
|
||||
}
|
||||
|
||||
export function variantsSummary(items: readonly ImageVariant[]) {
|
||||
const files = items.reduce((total, item) => total + item.generated_files, 0)
|
||||
const bytes = items.reduce((total, item) => total + item.cache_size, 0)
|
||||
const variants = items.length === 1 ? t('1 variant') : t('{0} variants', [items.length])
|
||||
const generated =
|
||||
files === 1 ? t('1 generated file') : t('{0} generated files', [formatNumber(files)])
|
||||
return [variants, generated, formatVariantSize(bytes)].join(SEPARATOR)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { VariantFit } from '@/composables/useImageVariants'
|
||||
|
||||
export interface PixelSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface OutputRequest {
|
||||
width: number | null
|
||||
height: number | null
|
||||
fit: VariantFit
|
||||
}
|
||||
|
||||
function scaled(size: PixelSize, factor: number): PixelSize {
|
||||
return { width: Math.round(size.width * factor), height: Math.round(size.height * factor) }
|
||||
}
|
||||
|
||||
function singleDimension(source: PixelSize, width: number | null, height: number | null) {
|
||||
const factor = width !== null ? width / source.width : (height ?? 0) / source.height
|
||||
return scaled(source, Math.min(1, factor))
|
||||
}
|
||||
|
||||
function cropToBox(source: PixelSize, box: PixelSize): PixelSize {
|
||||
const boxRatio = box.width / box.height
|
||||
if (source.width / source.height > boxRatio) {
|
||||
return { width: source.height * boxRatio, height: source.height }
|
||||
}
|
||||
return { width: source.width, height: source.width / boxRatio }
|
||||
}
|
||||
|
||||
function coverBox(source: PixelSize, box: PixelSize) {
|
||||
if (box.width >= source.width && box.height >= source.height) {
|
||||
return source
|
||||
}
|
||||
const crop = cropToBox(source, box)
|
||||
return scaled(crop, Math.min(1, box.width / crop.width))
|
||||
}
|
||||
|
||||
export function outputSize(source: PixelSize, request: OutputRequest): PixelSize | null {
|
||||
const { width, height, fit } = request
|
||||
if (width === null && height === null) {
|
||||
return null
|
||||
}
|
||||
if (width === null || height === null) {
|
||||
return singleDimension(source, width, height)
|
||||
}
|
||||
if (fit === 'contain') {
|
||||
return { width, height }
|
||||
}
|
||||
if (fit === 'inside') {
|
||||
return scaled(source, Math.min(1, width / source.width, height / source.height))
|
||||
}
|
||||
return coverBox(source, { width, height })
|
||||
}
|
||||
Reference in new issue
Block a user