From 898b580707a25a00dae05bce68d4e25cb509a375 Mon Sep 17 00:00:00 2001 From: Nitwel Date: Wed, 12 Apr 2023 17:08:51 +0200 Subject: [PATCH] Autoconvert assets if browser supports it (#18012) Co-authored-by: Pascal Jufer Co-authored-by: Rijk van Zanten --- api/src/constants.ts | 8 +- api/src/controllers/assets.ts | 18 +++- .../database/system-data/fields/settings.yaml | 2 + api/src/services/assets.ts | 10 +-- api/src/types/assets.ts | 18 +--- api/src/utils/transformations.ts | 85 +++++-------------- docs/configuration/project-settings.md | 2 +- docs/reference/files.md | 3 +- 8 files changed, 55 insertions(+), 91 deletions(-) diff --git a/api/src/constants.ts b/api/src/constants.ts index 3e6290aeea..a74c277594 100644 --- a/api/src/constants.ts +++ b/api/src/constants.ts @@ -6,31 +6,37 @@ import { getMilliseconds } from './utils/get-milliseconds.js'; export const SYSTEM_ASSET_ALLOW_LIST: TransformationParams[] = [ { key: 'system-small-cover', + format: 'auto', transforms: [['resize', { width: 64, height: 64, fit: 'cover' }]], }, { key: 'system-small-contain', + format: 'auto', transforms: [['resize', { width: 64, fit: 'contain' }]], }, { key: 'system-medium-cover', + format: 'auto', transforms: [['resize', { width: 300, height: 300, fit: 'cover' }]], }, { key: 'system-medium-contain', + format: 'auto', transforms: [['resize', { width: 300, fit: 'contain' }]], }, { key: 'system-large-cover', + format: 'auto', transforms: [['resize', { width: 800, height: 800, fit: 'cover' }]], }, { key: 'system-large-contain', + format: 'auto', transforms: [['resize', { width: 800, fit: 'contain' }]], }, ]; -export const ASSET_TRANSFORM_QUERY_KEYS = [ +export const ASSET_TRANSFORM_QUERY_KEYS: Array = [ 'key', 'transforms', 'width', diff --git a/api/src/controllers/assets.ts b/api/src/controllers/assets.ts index 0bd788dc84..6bfc3eba79 100644 --- a/api/src/controllers/assets.ts +++ b/api/src/controllers/assets.ts @@ -11,7 +11,7 @@ import logger from '../logger.js'; import useCollection from '../middleware/use-collection.js'; import { AssetsService } from '../services/assets.js'; import { PayloadService } from '../services/payload.js'; -import { TransformationMethods, TransformationParams, TransformationPreset } from '../types/assets.js'; +import { TransformationMethods, TransformationParams } from '../types/assets.js'; import asyncHandler from '../utils/async-handler.js'; import { getCacheControlHeader } from '../utils/get-cache-headers.js'; import { getConfigFromEnv } from '../utils/get-config-from-env.js'; @@ -139,12 +139,24 @@ router.get( schema: req.schema, }); - const transformation: TransformationParams | TransformationPreset = res.locals['transformation'].key - ? (res.locals['shortcuts'] as TransformationPreset[]).find( + const transformation: TransformationParams = res.locals['transformation'].key + ? (res.locals['shortcuts'] as TransformationParams[]).find( (transformation) => transformation['key'] === res.locals['transformation'].key ) : res.locals['transformation']; + if (transformation.format === 'auto' && req.headers.accept) { + let format: Exclude = 'jpg'; + + if (req.headers.accept.includes('image/webp')) { + format = 'webp'; + } else if (req.headers.accept.includes('image/avif')) { + format = 'avif'; + } + + transformation.format = format; + } + let range: Range | undefined = undefined; if (req.headers.range) { diff --git a/api/src/database/system-data/fields/settings.yaml b/api/src/database/system-data/fields/settings.yaml index f5cc98ffc1..e7e3df2bee 100644 --- a/api/src/database/system-data/fields/settings.yaml +++ b/api/src/database/system-data/fields/settings.yaml @@ -252,6 +252,8 @@ fields: options: allowNone: true choices: + - value: 'auto' + text: 'Auto' - value: jpeg text: JPEG - value: png diff --git a/api/src/services/assets.ts b/api/src/services/assets.ts index d6c08d0f0b..03a4c7eca8 100644 --- a/api/src/services/assets.ts +++ b/api/src/services/assets.ts @@ -17,13 +17,7 @@ import { RangeNotSatisfiableException } from '../exceptions/range-not-satisfiabl import { ServiceUnavailableException } from '../exceptions/service-unavailable.js'; import logger from '../logger.js'; import { getStorage } from '../storage/index.js'; -import type { - AbstractServiceOptions, - File, - Transformation, - TransformationParams, - TransformationPreset, -} from '../types/index.js'; +import type { AbstractServiceOptions, File, Transformation, TransformationParams } from '../types/index.js'; import { getMilliseconds } from '../utils/get-milliseconds.js'; import * as TransformationUtils from '../utils/transformations.js'; import { AuthorizationService } from './authorization.js'; @@ -41,7 +35,7 @@ export class AssetsService { async getAsset( id: string, - transformation: TransformationParams | TransformationPreset, + transformation: TransformationParams, range?: Range ): Promise<{ stream: Readable; file: any; stat: Stat }> { const storage = await getStorage(); diff --git a/api/src/types/assets.ts b/api/src/types/assets.ts index ea9a0f3048..7e2cfcc30d 100644 --- a/api/src/types/assets.ts +++ b/api/src/types/assets.ts @@ -65,21 +65,11 @@ export type TransformationMap = { export type Transformation = TransformationMap[keyof TransformationMap]; +export type TransformationResize = Pick; + export type TransformationParams = { key?: string; transforms?: Transformation[]; -}; - -// Transformation preset is defined in the admin UI. -export type TransformationPreset = TransformationPresetFormat & - TransformationPresetResize & - TransformationParams & { key: string }; - -export type TransformationPresetFormat = { - format?: 'jpg' | 'jpeg' | 'png' | 'webp' | 'tiff' | 'avif'; + format?: 'auto' | 'jpg' | 'jpeg' | 'png' | 'webp' | 'tiff' | 'avif'; quality?: number; -}; - -export type TransformationPresetResize = Pick; - -// @NOTE Keys used in TransformationParams should match ASSET_GENERATION_QUERY_KEYS in constants.ts +} & TransformationResize; diff --git a/api/src/utils/transformations.ts b/api/src/utils/transformations.ts index a9c6972fc6..9fd5180283 100644 --- a/api/src/utils/transformations.ts +++ b/api/src/utils/transformations.ts @@ -1,70 +1,29 @@ -import { isNil } from 'lodash-es'; -import type { - File, - Transformation, - TransformationParams, - TransformationPreset, - TransformationPresetFormat, - TransformationPresetResize, -} from '../types/index.js'; +import type { File, Transformation, TransformationParams } from '../types/index.js'; -// Extract transforms from a preset -export function resolvePreset(input: TransformationParams | TransformationPreset, file: File): Transformation[] { - // Do the format conversion last - return [extractResize(input), ...(input.transforms ?? []), extractToFormat(input, file)].filter( - (transform): transform is Transformation => transform !== undefined - ); -} +export function resolvePreset(input: TransformationParams, file: File): Transformation[] { + const transforms = input.transforms ?? []; -function extractOptions>( - keys: (keyof T)[], - numberKeys: (keyof T)[] = [], - booleanKeys: (keyof T)[] = [] -) { - return function (input: TransformationParams | TransformationPreset): T { - return Object.entries(input).reduce( - (config, [key, value]) => - keys.includes(key as any) && isNil(value) === false - ? { - ...config, - [key]: numberKeys.includes(key as any) - ? +value! - : booleanKeys.includes(key as any) - ? Boolean(value) - : value, - } - : config, - {} as T - ); - }; -} + if (input.format || input.quality) + transforms.push([ + 'toFormat', + input.format || (file.type!.split('/')[1] as any), + { + quality: input.quality ? Number(input.quality) : undefined, + }, + ]); -// Extract format transform from a preset -function extractToFormat(input: TransformationParams | TransformationPreset, file: File): Transformation | undefined { - const options = extractOptions(['format', 'quality'], ['quality'])(input); - return Object.keys(options).length > 0 - ? [ - 'toFormat', - options.format || (file.type!.split('/')[1] as any), - { - quality: options.quality, - }, - ] - : undefined; -} + if (input.width || input.height) + transforms.push([ + 'resize', + { + width: input.width ? Number(input.width) : undefined, + height: input.height ? Number(input.height) : undefined, + fit: input.fit, + withoutEnlargement: input.withoutEnlargement ? Boolean(input.withoutEnlargement) : undefined, + }, + ]); -function extractResize(input: TransformationParams | TransformationPreset): Transformation | undefined { - const resizable = ['width', 'height'].some((key) => key in input); - if (!resizable) return undefined; - - return [ - 'resize', - extractOptions( - ['width', 'height', 'fit', 'withoutEnlargement'], - ['width', 'height'], - ['withoutEnlargement'] - )(input), - ]; + return transforms; } /** diff --git a/docs/configuration/project-settings.md b/docs/configuration/project-settings.md index 21570e97a7..459d47abb0 100644 --- a/docs/configuration/project-settings.md +++ b/docs/configuration/project-settings.md @@ -117,7 +117,7 @@ following options to limit what transformations are possible. - **Height** — Sets the height of the image. - **Quality** — Adjusts the compression or quality of the image. - **Upscaling** — When enabled, images won't be upscaled. - - **Format** — Changes the output format to JPG, PNG, WebP, or TIFF. + - **Format** — Changes the output format. - **Additional Transformations** — Adds additional transformations using [Sharp](https://sharp.pixelplumbing.com/api-constructor). diff --git a/docs/reference/files.md b/docs/reference/files.md index 276fcde52a..5ddeb05e88 100644 --- a/docs/reference/files.md +++ b/docs/reference/files.md @@ -91,7 +91,8 @@ grained control: - **`height`** — The **height** of the thumbnail in pixels - **`quality`** — The optional **quality** of the thumbnail (`1` to `100`) - **`withoutEnlargement`** — Disable image up-scaling -- **`format`** — What file format to return the thumbnail in. One of `jpg`, `png`, `webp`, `tiff` +- **`format`** — What file format to return the thumbnail in. One of `auto`, `jpg`, `png`, `webp`, `tiff` + - `auto` — Will try to format it in `webp` or `avif` if the browser supports it, otherwise it will fallback to `jpg`. ### Advanced Transformations