feat: image variants per image

This commit is contained in:
fsylvain committed 2026-10-08 19:57:51 +02:00
1 parent f567866161
commit 951fef18ba
92 files changed
+8695 -413

No files matched your search

@@ -230,6 +230,8 @@ describe('The catalogue is complete in both languages', () => {
'media.delete',
'media.merge',
'media.purge',
'media.variant_generate_all',
'media.variant_purge_image',
'system.media_purge',
'user.register',
'user.verify',
@@ -1,12 +1,14 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import { ArticleImage, findArticleImagePos } from '../articleImage'
import { bumpMediaRevision } from '../mediaRevision'
function mountHarness(onRequestRemove = vi.fn()) {
setActivePinia(createPinia())
let editor: Editor
const Harness = defineComponent({
components: { EditorContent },
+48
View File
@@ -2,6 +2,8 @@ import { formatNumber, t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
import type { ActivityItem } from '@/lib/activity'
import { sizeLabel } from '@/lib/activityRetention'
import { formatSize } from '@/lib/mediaFormat'
import { causeText } from '@/lib/variants/variantCauses'
export interface SentenceSegment {
text: string
@@ -56,6 +58,14 @@ const CATALOGUE: Record<string, Template> = {
success: 'Ran the scheduled duplicate purge: {0}',
failed: 'Failed to run the scheduled duplicate purge',
},
'media.variant_generate_all': {
success: 'Generated all variants of {0}',
failed: 'Failed to generate the variants of {0}',
},
'media.variant_purge_image': {
success: 'Purged the variants of {0}',
failed: 'Failed to purge the variants of {0}',
},
'user.register': {
success: 'Registered the account {0}',
failed: 'Failed to register the account {0}',
@@ -224,6 +234,44 @@ export function composeSentence(item: ActivityItem): SentenceSegment[] {
return segments.filter((segment) => segment.text !== '')
}
function causeList(value: unknown) {
const causes = Array.isArray(value) ? value.filter((cause) => typeof cause === 'string') : []
return [...new Set(causes)].map(causeText).join(', ')
}
function generateAllDetail(params: Record<string, unknown>) {
const generated = count(params.generated)
const failed = count(params.failed)
if (failed === 0) {
return generated === 1
? t('1 variant generated')
: t('{0} variants generated', [formatNumber(generated)])
}
return t('{0} generated · {1} failed ({2})', [
formatNumber(generated),
formatNumber(failed),
causeList(params.causes),
])
}
function purgeImageDetail(params: Record<string, unknown>) {
const files = count(params.purged_files)
const size = formatSize(count(params.purged_bytes))
return files === 1
? t('Removed 1 file ({0})', [size])
: t('Removed {0} files ({1})', [formatNumber(files), size])
}
export function detailLine(item: ActivityItem): string | null {
if (item.action === 'media.variant_generate_all') {
return generateAllDetail(item.params)
}
if (item.action === 'media.variant_purge_image') {
return purgeImageDetail(item.params)
}
return null
}
export function sentenceText(item: ActivityItem) {
return composeSentence(item)
.map((segment) => segment.text)
+18
View File
@@ -36,6 +36,24 @@ export function formatSize(bytes: number) {
])
}
export function totalMeta(item: { size: number; variant_size: number; variant_count: number }) {
const total = formatSize(item.size + item.variant_size)
if (item.variant_count > 0) {
return t('{0} total · {1} + {2}', [
total,
formatSize(item.size),
formatSize(item.variant_size),
])
}
return t('{0} total', [total])
}
export function removalNote(count: number, bytes: number) {
return count === 1
? t('Also removes 1 generated variant ({0})', [formatSize(bytes)])
: t('Also removes {0} generated variants ({1})', [count, formatSize(bytes)])
}
export function formatDimensions(width: number | null, height: number | null) {
if (width === null || height === null) {
return t(VECTOR_DIMENSIONS)
+11 -2
View File
@@ -1,13 +1,15 @@
import type { LocationQuery } from 'vue-router'
export type MediaFilter = 'all' | 'unused' | 'duplicates'
export type MediaSort = 'newest' | 'oldest' | 'name' | 'largest'
export type MediaSort = 'newest' | 'oldest' | 'name' | 'largest' | 'total_size'
export type MediaVariantsFilter = 'all' | 'with' | 'without'
export type MediaView = 'grid' | 'list'
export interface MediaControls {
q: string
filter: MediaFilter
sort: MediaSort
variants: MediaVariantsFilter
view: MediaView
}
@@ -17,11 +19,13 @@ export const DEFAULT_CONTROLS: MediaControls = {
q: '',
filter: 'all',
sort: 'newest',
variants: 'all',
view: 'grid',
}
const FILTERS: MediaFilter[] = ['all', 'unused', 'duplicates']
const SORTS: MediaSort[] = ['newest', 'oldest', 'name', 'largest']
const SORTS: MediaSort[] = ['newest', 'oldest', 'name', 'largest', 'total_size']
const VARIANTS_FILTERS: MediaVariantsFilter[] = ['all', 'with', 'without']
const VIEWS: MediaView[] = ['grid', 'list']
function firstValue(value: LocationQuery[string] | undefined) {
@@ -37,6 +41,7 @@ export function parseMediaControls(query: LocationQuery): MediaControls {
q: firstValue(query.q).trim(),
filter: pick(FILTERS, firstValue(query.filter), DEFAULT_CONTROLS.filter),
sort: pick(SORTS, firstValue(query.sort), DEFAULT_CONTROLS.sort),
variants: pick(VARIANTS_FILTERS, firstValue(query.variants), DEFAULT_CONTROLS.variants),
view: pick(VIEWS, firstValue(query.view), DEFAULT_CONTROLS.view),
}
}
@@ -52,6 +57,9 @@ export function toRouteQuery(controls: MediaControls) {
if (controls.sort !== DEFAULT_CONTROLS.sort) {
query.sort = controls.sort
}
if (controls.variants !== DEFAULT_CONTROLS.variants) {
query.variants = controls.variants
}
if (controls.view !== DEFAULT_CONTROLS.view) {
query.view = controls.view
}
@@ -63,6 +71,7 @@ export function toRequestQuery(controls: MediaControls) {
...(controls.q ? [`q=${encodeURIComponent(controls.q)}`] : []),
`filter=${controls.filter}`,
`sort=${controls.sort}`,
...(controls.variants === 'all' ? [] : [`variants=${controls.variants}`]),
`limit=${MEDIA_PAGE_LIMIT}`,
]
return parts.join('&')
+2
View File
@@ -1,2 +1,4 @@
export const UNDERLINE_TAB_TRIGGER =
'h-9 px-0 py-0 data-[state=inactive]:text-toned in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:bottom-0 in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:h-0.5 max-md:h-11 max-md:text-[0.9375rem]'
export const PANEL_TAB_TRIGGER = UNDERLINE_TAB_TRIGGER.replace('h-9', 'h-10')
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { withMediaRevision } from '@/lib/mediaRevision'
import { bodyHasVariantImage, variantOfSrc, withVariant } from '../blockVariant'
describe('Reading the variant of an image src', () => {
it.each([
['/static/uploadedImages/1.jpg?v=hero', 'hero'],
['/static/uploadedImages/1.jpg?v=hero&rev=3', 'hero'],
['/static/uploadedImages/1.jpg?rev=3&v=thumb#top', 'thumb'],
['/static/uploadedImages/1.jpg', null],
['/static/uploadedImages/1.jpg?rev=3', null],
['/static/uploadedImages/1.jpg?v=', null],
[undefined, null],
])('%s reads %s', (src, expected) => {
expect(variantOfSrc(src)).toBe(expected)
})
})
describe('Rewriting the variant of an image src', () => {
it.each([
['/static/uploadedImages/1.jpg?v=hero', 'thumb', '/static/uploadedImages/1.jpg?v=thumb'],
['/static/uploadedImages/1.jpg?v=hero', '', '/static/uploadedImages/1.jpg'],
['/static/uploadedImages/1.jpg', 'hero', '/static/uploadedImages/1.jpg?v=hero'],
[
'/static/uploadedImages/1.jpg?rev=3&v=hero#a',
'og',
'/static/uploadedImages/1.jpg?rev=3&v=og#a',
],
])('%s with %s becomes %s', (src, name, expected) => {
expect(withVariant(src, name)).toBe(expected)
})
})
describe('Detecting variant images in a body', () => {
it('finds an img whose src carries ?v=', () => {
expect(bodyHasVariantImage('<p>x</p><img src="/static/uploadedImages/1.jpg?v=hero">')).toBe(
true,
)
})
it('ignores plain images and text', () => {
expect(bodyHasVariantImage('<p>?v=hero</p><img src="/static/uploadedImages/1.jpg">')).toBe(
false,
)
})
})
describe('The cache-busting parameter coexists with the variant', () => {
it('is appended after ?v=', () => {
expect(withMediaRevision('/static/uploadedImages/1.jpg?v=hero', 3)).toBe(
'/static/uploadedImages/1.jpg?v=hero&rev=3',
)
})
})
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it } from 'vitest'
import { initializeLocale, setLocale } from '@/i18n'
import type { ImageVariant } from '@/composables/useImageVariants'
import { buildOptions, insertedUrl, originalOption, variantOption } from '../variantOptions'
const NORTH_WALL = {
url: '/static/uploadedImages/1.jpg',
width: 4032,
height: 3024,
content_type: 'image/jpeg',
}
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: 0,
cache_size: 0,
...overrides,
}
}
afterEach(() => {
localStorage.clear()
initializeLocale()
})
describe('Option labels', () => {
it('reads the original from the image', () => {
expect(originalOption(NORTH_WALL).label).toBe('Original · 4032×3024 · JPEG')
expect(originalOption({ ...NORTH_WALL, content_type: 'image/webp' }).label).toBe(
'Original · 4032×3024 · WEBP',
)
expect(
originalOption({
...NORTH_WALL,
width: null,
height: null,
content_type: 'image/svg+xml',
}).label,
).toBe('Original · Vector · SVG')
})
it.each([
[variant({}), 'Hero · 1200×630 · WebP'],
[
variant({ name: 'card', width: 800, height: null, format: 'webp' }),
'Card · 800 × auto · WebP',
],
[
variant({ name: 'tall', width: null, height: 2400, format: 'png' }),
'Tall · auto × 2400 · PNG',
],
[variant({ name: 'keep', format: 'original' }), 'Keep · 1200×630 · Original'],
])('reads a variant', (entry, label) => {
expect(variantOption(entry).label).toBe(label)
})
it('offers only Original for a vector image', () => {
const options = buildOptions(
{ ...NORTH_WALL, width: null, height: null, content_type: 'image/svg+xml' },
[variant({})],
)
expect(options.map((option) => option.label)).toEqual(['Original · Vector · SVG'])
})
it('translates the original label', () => {
setLocale('fr')
expect(originalOption(NORTH_WALL).label).toBe('Original · 4032×3024 · JPEG')
})
})
describe('The inserted url', () => {
it('is the image url for Original and carries ?v= for a variant', () => {
expect(insertedUrl(NORTH_WALL, '')).toBe('/static/uploadedImages/1.jpg')
expect(insertedUrl(NORTH_WALL, 'hero')).toBe('/static/uploadedImages/1.jpg?v=hero')
})
})
+39
View File
@@ -0,0 +1,39 @@
const VARIANT_PARAMETER = 'v'
const HASH = '#'
const QUERY = '?'
function splitSrc(src: string) {
const hashAt = src.indexOf(HASH)
const withoutHash = hashAt === -1 ? src : src.slice(0, hashAt)
const hash = hashAt === -1 ? '' : src.slice(hashAt)
const queryAt = withoutHash.indexOf(QUERY)
if (queryAt === -1) {
return { base: withoutHash, query: '', hash }
}
return { base: withoutHash.slice(0, queryAt), query: withoutHash.slice(queryAt + 1), hash }
}
export function variantOfSrc(src: string | undefined | null) {
if (!src) {
return null
}
const name = new URLSearchParams(splitSrc(src).query).get(VARIANT_PARAMETER)
return name === null || name === '' ? null : name
}
export function withVariant(src: string, name: string) {
const { base, query, hash } = splitSrc(src)
const params = new URLSearchParams(query)
params.delete(VARIANT_PARAMETER)
const kept = params.toString()
const parts = [kept, name === '' ? '' : `${VARIANT_PARAMETER}=${encodeURIComponent(name)}`]
const next = parts.filter((part) => part !== '').join('&')
return `${base}${next === '' ? '' : QUERY + next}${hash}`
}
export function bodyHasVariantImage(html: string) {
const document = new DOMParser().parseFromString(html, 'text/html')
return Array.from(document.querySelectorAll('img')).some(
(image) => variantOfSrc(image.getAttribute('src')) !== null,
)
}
+26
View File
@@ -0,0 +1,26 @@
import { t } from '@/i18n'
import type { VariantCause } from '@/composables/useMediaVariants'
const PAYLOAD_TOO_LARGE = 413
const UNSUPPORTED_MEDIA_TYPE = 415
const UNPROCESSABLE = 422
export function causeText(cause: string) {
if (cause === 'too_large') {
return t('Image too large')
}
if (cause === 'unsupported_format') {
return t('Unsupported source format')
}
return t('Generation failed')
}
export function causeOfStatus(status: number): VariantCause | null {
if (status === PAYLOAD_TOO_LARGE) {
return 'too_large'
}
if (status === UNSUPPORTED_MEDIA_TYPE || status === UNPROCESSABLE) {
return 'unsupported_format'
}
return null
}
+71
View File
@@ -0,0 +1,71 @@
import { t } from '@/i18n'
import type { ImageVariant, VariantFormat } from '@/composables/useImageVariants'
import { formatType } from '@/lib/mediaFormat'
import { formatDimensions, formatLabel } from '@/lib/variants/variantFormat'
const SEPARATOR = ' · '
const VECTOR = 'Vector'
const SVG_TYPE = 'image/svg+xml'
const ORIGINAL_VALUE = ''
export interface VariantOption {
value: string
label: string
}
export interface OptionSource {
url: string
width: number | null
height: number | null
content_type: string
}
export const ORIGINAL_OPTION_VALUE = ORIGINAL_VALUE
export function isVector(source: OptionSource) {
return source.content_type === SVG_TYPE
}
function sourceSize(source: OptionSource) {
if (source.width === null || source.height === null) {
return t(VECTOR)
}
return `${source.width}×${source.height}`
}
export function capitalised(name: string) {
return name.charAt(0).toUpperCase() + name.slice(1)
}
export function originalOption(source: OptionSource): VariantOption {
return {
value: ORIGINAL_VALUE,
label: [t('Original'), sourceSize(source), formatType(source.content_type)].join(SEPARATOR),
}
}
export function bareOriginalOption(): VariantOption {
return { value: ORIGINAL_VALUE, label: t('Original') }
}
export function variantOption(variant: ImageVariant): VariantOption {
return {
value: variant.name,
label: [
capitalised(variant.name),
formatDimensions(variant.width, variant.height),
formatLabel(variant.format as VariantFormat),
].join(SEPARATOR),
}
}
export function buildOptions(source: OptionSource, variants: ImageVariant[]): VariantOption[] {
const offered = isVector(source) ? [] : variants.map(variantOption)
return [originalOption(source), ...offered]
}
export function insertedUrl(source: OptionSource, variantName: string) {
return variantName === ORIGINAL_VALUE
? source.url
: `${source.url}?v=${encodeURIComponent(variantName)}`
}
+23
View File
@@ -0,0 +1,23 @@
const MAX_MAGNITUDE = 99
const PERCENT = 100
export function savedPercent(originalBytes: number, variantBytes: number) {
if (originalBytes <= 0) {
return 0
}
const change = ((variantBytes - originalBytes) / originalBytes) * PERCENT
const rounded = Math.sign(change) * Math.round(Math.abs(change))
if (variantBytes > 0 && rounded < -MAX_MAGNITUDE) {
return -MAX_MAGNITUDE
}
return rounded
}
export function signedPercent(originalBytes: number, variantBytes: number) {
const percent = savedPercent(originalBytes, variantBytes)
return `${percent > 0 ? '+' : ''}${percent}%`
}
export function savesPercent(originalBytes: number, variantBytes: number) {
return Math.max(0, -savedPercent(originalBytes, variantBytes))
}