Allow custom transformations of assets (#6593)

* Allow custom transformations of assets

This exposes one query parameter `transforms`, which is a JSON array of
shard transformation operations.

It also updates the asset presets. The UX for this still needs some work

* Rename options to arguments for presets

More explicit

* options -> arguments in setting spec

* Better errors for invalid JSON in asset presets

* Add limit to transforms query parameter

* Use flattened option for extra transforms

* Fix placeholder color of code input

* Allow "simple mode" aliases

* Add documentation

Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
This commit is contained in:
Tim
2021-07-22 07:57:47 +12:00
committed by GitHub
parent 7dffa48570
commit 2c9ff3bca6
15 changed files with 387 additions and 118 deletions

View File

@@ -150,6 +150,7 @@
"memcached": "^2.2.2", "memcached": "^2.2.2",
"mysql": "^2.18.1", "mysql": "^2.18.1",
"nodemailer-mailgun-transport": "^2.1.3", "nodemailer-mailgun-transport": "^2.1.3",
"object-hash": "^2.2.0",
"pg": "^8.6.0", "pg": "^8.6.0",
"sqlite3": "^5.0.2", "sqlite3": "^5.0.2",
"tedious": "^11.0.8" "tedious": "^11.0.8"
@@ -178,6 +179,7 @@
"@types/node": "15.12.2", "@types/node": "15.12.2",
"@types/node-cron": "2.0.4", "@types/node-cron": "2.0.4",
"@types/nodemailer": "6.4.4", "@types/nodemailer": "6.4.4",
"@types/object-hash": "^2.1.0",
"@types/qs": "6.9.7", "@types/qs": "6.9.7",
"@types/sharp": "0.28.4", "@types/sharp": "0.28.4",
"@types/stream-json": "1.7.1", "@types/stream-json": "1.7.1",

View File

@@ -1,42 +1,42 @@
import { Transformation } from './types'; import { TransformationParams } from './types';
export const SYSTEM_ASSET_ALLOW_LIST: Transformation[] = [ export const SYSTEM_ASSET_ALLOW_LIST: TransformationParams[] = [
{ {
key: 'system-small-cover', key: 'system-small-cover',
width: 64, transforms: [['resize', { width: 64, height: 64, fit: 'cover' }]],
height: 64,
fit: 'cover',
}, },
{ {
key: 'system-small-contain', key: 'system-small-contain',
width: 64, transforms: [['resize', { width: 64, fit: 'contain' }]],
fit: 'contain',
}, },
{ {
key: 'system-medium-cover', key: 'system-medium-cover',
width: 300, transforms: [['resize', { width: 300, height: 300, fit: 'cover' }]],
height: 300,
fit: 'cover',
}, },
{ {
key: 'system-medium-contain', key: 'system-medium-contain',
width: 300, transforms: [['resize', { width: 300, fit: 'contain' }]],
fit: 'contain',
}, },
{ {
key: 'system-large-cover', key: 'system-large-cover',
width: 800, transforms: [['resize', { width: 800, height: 800, fit: 'cover' }]],
height: 600,
fit: 'cover',
}, },
{ {
key: 'system-large-contain', key: 'system-large-contain',
width: 800, transforms: [['resize', { width: 800, fit: 'contain' }]],
fit: 'contain',
}, },
]; ];
export const ASSET_TRANSFORM_QUERY_KEYS = ['key', 'width', 'height', 'fit', 'withoutEnlargement', 'quality']; export const ASSET_TRANSFORM_QUERY_KEYS = [
'key',
'transforms',
'width',
'height',
'format',
'fit',
'quality',
'withoutEnlargement',
];
export const FILTER_VARIABLES = ['$NOW', '$CURRENT_USER', '$CURRENT_ROLE']; export const FILTER_VARIABLES = ['$NOW', '$CURRENT_USER', '$CURRENT_ROLE'];

View File

@@ -10,7 +10,7 @@ import { ForbiddenException, InvalidQueryException, RangeNotSatisfiableException
import useCollection from '../middleware/use-collection'; import useCollection from '../middleware/use-collection';
import { AssetsService, PayloadService } from '../services'; import { AssetsService, PayloadService } from '../services';
import storage from '../storage'; import storage from '../storage';
import { Transformation } from '../types/assets'; import { TransformationParams, TransformationMethods, TransformationPreset } from '../types/assets';
import asyncHandler from '../utils/async-handler'; import asyncHandler from '../utils/async-handler';
const router = Router(); const router = Router();
@@ -68,26 +68,63 @@ router.get(
if ('key' in transformation && Object.keys(transformation).length > 1) { if ('key' in transformation && Object.keys(transformation).length > 1) {
throw new InvalidQueryException(`You can't combine the "key" query parameter with any other transformation.`); throw new InvalidQueryException(`You can't combine the "key" query parameter with any other transformation.`);
} }
if ('quality' in transformation && (Number(transformation.quality) < 1 || Number(transformation.quality) > 100)) {
throw new InvalidQueryException(`"quality" Parameter has to between 1 to 100`); if ('transforms' in transformation) {
let transforms: unknown;
// Try parse the JSON array
try {
transforms = JSON.parse(transformation['transforms'] as string);
} catch {
throw new InvalidQueryException(`"transforms" Parameter needs to be a JSON array of allowed transformations.`);
}
// Check if it is actually an array.
if (!Array.isArray(transforms)) {
throw new InvalidQueryException(`"transforms" Parameter needs to be a JSON array of allowed transformations.`);
}
// Check against ASSETS_TRANSFORM_MAX_OPERATIONS
if (transforms.length > Number(env.ASSETS_TRANSFORM_MAX_OPERATIONS)) {
throw new InvalidQueryException(
`"transforms" Parameter is only allowed ${env.ASSETS_TRANSFORM_MAX_OPERATIONS} transformations.`
);
}
// Check the transformations are valid
transforms.forEach((transform) => {
const name = transform[0];
if (!TransformationMethods.includes(name)) {
throw new InvalidQueryException(`"transforms" Parameter does not allow "${name}" as a transformation.`);
}
});
transformation.transforms = transforms;
} }
const systemKeys = SYSTEM_ASSET_ALLOW_LIST.map((transformation) => transformation.key); const systemKeys = SYSTEM_ASSET_ALLOW_LIST.map((transformation) => transformation.key!);
const allKeys: string[] = [ const allKeys: string[] = [
...systemKeys, ...systemKeys,
...(assetSettings.storage_asset_presets || []).map((transformation: Transformation) => transformation.key), ...(assetSettings.storage_asset_presets || []).map((transformation: TransformationParams) => transformation.key),
]; ];
// For use in the next request handler // For use in the next request handler
res.locals.shortcuts = [...SYSTEM_ASSET_ALLOW_LIST, ...(assetSettings.storage_asset_presets || [])]; res.locals.shortcuts = [...SYSTEM_ASSET_ALLOW_LIST, ...(assetSettings.storage_asset_presets || [])];
res.locals.transformation = transformation; res.locals.transformation = transformation;
if (Object.keys(transformation).length === 0) { if (
Object.keys(transformation).length === 0 ||
('transforms' in transformation && transformation.transforms!.length === 0)
) {
return next(); return next();
} }
if (assetSettings.storage_asset_transform === 'all') { if (assetSettings.storage_asset_transform === 'all') {
if (transformation.key && allKeys.includes(transformation.key as string) === false) if (transformation.key && allKeys.includes(transformation.key as string) === false) {
throw new InvalidQueryException(`Key "${transformation.key}" isn't configured.`); throw new InvalidQueryException(`Key "${transformation.key}" isn't configured.`);
}
return next(); return next();
} else if (assetSettings.storage_asset_transform === 'presets') { } else if (assetSettings.storage_asset_transform === 'presets') {
if (allKeys.includes(transformation.key as string)) return next(); if (allKeys.includes(transformation.key as string)) return next();
@@ -107,9 +144,9 @@ router.get(
schema: req.schema, schema: req.schema,
}); });
const transformation: Transformation = res.locals.transformation.key const transformation: TransformationParams | TransformationPreset = res.locals.transformation.key
? res.locals.shortcuts.find( ? (res.locals.shortcuts as TransformationPreset[]).find(
(transformation: Transformation) => transformation.key === res.locals.transformation.key (transformation) => transformation.key === res.locals.transformation.key
) )
: res.locals.transformation; : res.locals.transformation;

View File

@@ -124,7 +124,7 @@ fields:
options: options:
slug: true slug: true
onlyOnCreate: false onlyOnCreate: false
width: half width: full
- field: fit - field: fit
name: Fit name: Fit
type: string type: string
@@ -173,6 +173,7 @@ fields:
step: 1 step: 1
width: half width: half
- field: withoutEnlargement - field: withoutEnlargement
name: Upscaling
type: boolean type: boolean
schema: schema:
default_value: false default_value: false
@@ -181,6 +182,51 @@ fields:
width: half width: half
options: options:
label: Don't upscale images label: Don't upscale images
- field: format
name: Format
type: string
schema:
is_nullable: false
default_value: ''
meta:
interface: select-dropdown
options:
allowNone: true
choices:
- value: jpeg
text: JPEG
- value: png
text: PNG
- value: webp
text: WebP
- value: tiff
text: Tiff
width: half
- field: transforms
name: Additional Transformations
type: json
schema:
is_nullable: false
default_value: []
meta:
note:
The Sharp method name and its arguments. See https://sharp.pixelplumbing.com/api-constructor for more
information.
interface: json
options:
template: >
[
["blur", 45],
["grayscale"],
["extend", { "right": 500, "background": "rgb(255, 0, 0)" }]
]
placeholder: >
[
["blur", 45],
["grayscale"],
["extend", { "right": 500, "background": "rgb(255, 0, 0)" }]
]
width: full
template: '{{key}}' template: '{{key}}'
special: json special: json
width: full width: full

View File

@@ -68,6 +68,7 @@ const defaults: Record<string, any> = {
ASSETS_CACHE_TTL: '30d', ASSETS_CACHE_TTL: '30d',
ASSETS_TRANSFORM_MAX_CONCURRENT: 1, ASSETS_TRANSFORM_MAX_CONCURRENT: 1,
ASSETS_TRANSFORM_IMAGE_MAX_DIMENSION: 6000, ASSETS_TRANSFORM_IMAGE_MAX_DIMENSION: 6000,
ASSETS_TRANSFORM_MAX_OPERATIONS: 5,
}; };
// Allows us to force certain environment variable into a type, instead of relying // Allows us to force certain environment variable into a type, instead of relying

View File

@@ -1,15 +1,24 @@
import { Range, StatResponse } from '@directus/drive'; import { Range, StatResponse } from '@directus/drive';
import { Knex } from 'knex';
import path from 'path';
import sharp, { ResizeOptions } from 'sharp';
import getDatabase from '../database';
import { RangeNotSatisfiableException, IllegalAssetTransformation } from '../exceptions';
import storage from '../storage';
import { AbstractServiceOptions, Accountability, Transformation } from '../types';
import { AuthorizationService } from './authorization';
import { Semaphore } from 'async-mutex'; import { Semaphore } from 'async-mutex';
import { Knex } from 'knex';
import { contentType } from 'mime-types';
import ObjectHash from 'object-hash';
import path from 'path';
import sharp from 'sharp';
import getDatabase from '../database';
import env from '../env'; import env from '../env';
import { File } from '../types'; import { IllegalAssetTransformation, RangeNotSatisfiableException } from '../exceptions';
import storage from '../storage';
import {
AbstractServiceOptions,
Accountability,
File,
Transformation,
TransformationParams,
TransformationPreset,
} from '../types';
import { AuthorizationService } from './authorization';
import * as TransformationUtils from '../utils/transformations';
sharp.concurrency(1); sharp.concurrency(1);
@@ -30,7 +39,7 @@ export class AssetsService {
async getAsset( async getAsset(
id: string, id: string,
transformation: Transformation, transformation: TransformationParams | TransformationPreset,
range?: Range range?: Range
): Promise<{ stream: NodeJS.ReadableStream; file: any; stat: StatResponse }> { ): Promise<{ stream: NodeJS.ReadableStream; file: any; stat: StatResponse }> {
const publicSettings = await this.knex const publicSettings = await this.knex
@@ -53,18 +62,23 @@ export class AssetsService {
} }
const type = file.type; const type = file.type;
const transforms = TransformationUtils.resolvePreset(transformation, file);
// We can only transform JPEG, PNG, and WebP // We can only transform JPEG, PNG, and WebP
if (type && Object.keys(transformation).length > 0 && ['image/jpeg', 'image/png', 'image/webp'].includes(type)) { if (type && transforms.length > 0 && ['image/jpeg', 'image/png', 'image/webp', 'image/tiff'].includes(type)) {
const resizeOptions = this.parseTransformation(transformation); const maybeNewFormat = TransformationUtils.maybeExtractFormat(transforms);
const assetFilename = const assetFilename =
path.basename(file.filename_disk, path.extname(file.filename_disk)) + path.basename(file.filename_disk, path.extname(file.filename_disk)) +
this.getAssetSuffix(transformation) + getAssetSuffix(transforms) +
path.extname(file.filename_disk); (maybeNewFormat ? `.${maybeNewFormat}` : path.extname(file.filename_disk));
const { exists } = await storage.disk(file.storage).exists(assetFilename); const { exists } = await storage.disk(file.storage).exists(assetFilename);
if (maybeNewFormat) {
file.type = contentType(assetFilename) || null;
}
if (exists) { if (exists) {
return { return {
stream: storage.disk(file.storage).getStream(assetFilename, range), stream: storage.disk(file.storage).getStream(assetFilename, range),
@@ -94,15 +108,9 @@ export class AssetsService {
const transformer = sharp({ const transformer = sharp({
limitInputPixels: Math.pow(env.ASSETS_TRANSFORM_IMAGE_MAX_DIMENSION, 2), limitInputPixels: Math.pow(env.ASSETS_TRANSFORM_IMAGE_MAX_DIMENSION, 2),
sequentialRead: true, sequentialRead: true,
}) }).rotate();
.rotate()
.resize(resizeOptions);
if (transformation.quality) { transforms.forEach(([method, ...args]) => (transformer[method] as any).apply(transformer, args));
transformer.toFormat(type.substring(6) as 'jpeg' | 'png' | 'webp', {
quality: Number(transformation.quality),
});
}
await storage.disk(file.storage).put(assetFilename, readStream.pipe(transformer), type); await storage.disk(file.storage).put(assetFilename, readStream.pipe(transformer), type);
@@ -118,28 +126,9 @@ export class AssetsService {
return { stream: readStream, file, stat }; return { stream: readStream, file, stat };
} }
} }
private parseTransformation(transformation: Transformation): ResizeOptions {
const resizeOptions: ResizeOptions = {};
if (transformation.width) resizeOptions.width = Number(transformation.width);
if (transformation.height) resizeOptions.height = Number(transformation.height);
if (transformation.fit) resizeOptions.fit = transformation.fit;
if (transformation.withoutEnlargement)
resizeOptions.withoutEnlargement = Boolean(transformation.withoutEnlargement);
return resizeOptions;
}
private getAssetSuffix(transformation: Transformation) {
if (Object.keys(transformation).length === 0) return '';
return (
'__' +
Object.entries(transformation)
.sort((a, b) => (a[0] > b[0] ? 1 : -1))
.map((e) => e.join('_'))
.join(',')
);
}
} }
const getAssetSuffix = (transforms: Transformation[]) => {
if (Object.keys(transforms).length === 0) return '';
return `__${ObjectHash.sha1(transforms)}`;
};

View File

@@ -49,7 +49,7 @@ export class FilesService extends ItemsService {
const fileExtension = const fileExtension =
path.extname(payload.filename_download) || (payload.type && '.' + extension(payload.type)) || ''; path.extname(payload.filename_download) || (payload.type && '.' + extension(payload.type)) || '';
payload.filename_disk = primaryKey + fileExtension; payload.filename_disk = primaryKey + (fileExtension || '');
if (!payload.type) { if (!payload.type) {
payload.type = 'application/octet-stream'; payload.type = 'application/octet-stream';

View File

@@ -1,10 +1,84 @@
export type Transformation = { import { ResizeOptions, Sharp } from 'sharp';
// List of allowed sharp methods to expose.
//
// This is a literal, so we can use it to validate request parameters.
export const TransformationMethods /*: readonly (keyof Sharp)[]*/ = [
// Output options
// https://sharp.pixelplumbing.com/api-output
'toFormat',
'jpeg',
'png',
'tiff',
'webp',
// Resizing
// https://sharp.pixelplumbing.com/api-resize
'resize',
'extend',
'extract',
'trim',
// Image operations
// https://sharp.pixelplumbing.com/api-operation
'rotate',
'flip',
'flop',
'sharpen',
'median',
'blur',
'flatten',
'gamma',
'negate',
'normalise',
'normalize',
'clahe',
'convolve',
'threshold',
'linear',
'recomb',
'modulate',
// Color manipulation
// https://sharp.pixelplumbing.com/api-colour
'tint',
'greyscale',
'grayscale',
'toColorspace',
'toColourspace',
// Channel manipulation
// https://sharp.pixelplumbing.com/api-channel
'removeAlpha',
'ensureAlpha',
'extractChannel',
'bandbool',
] as const;
// Helper types
type AllowedSharpMethods = Pick<Sharp, typeof TransformationMethods[number]>;
export type TransformationMap = {
[M in keyof AllowedSharpMethods]: readonly [M, ...Parameters<AllowedSharpMethods[M]>];
};
export type Transformation = TransformationMap[keyof TransformationMap];
export type TransformationParams = {
key?: string; key?: string;
width?: number; // width transforms?: Transformation[];
height?: number; // height };
fit?: 'cover' | 'contain' | 'inside' | 'outside'; // fit
withoutEnlargement?: boolean; // Without Enlargement // 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';
quality?: number; quality?: number;
}; };
// @NOTE Keys used in Transformation should match ASSET_GENERATION_QUERY_KEYS in constants.ts export type TransformationPresetResize = Pick<ResizeOptions, 'width' | 'height' | 'fit' | 'withoutEnlargement'>;
// @NOTE Keys used in TransformationParams should match ASSET_GENERATION_QUERY_KEYS in constants.ts

View File

@@ -0,0 +1,68 @@
import { isNil } from 'lodash';
import {
File,
Transformation,
TransformationParams,
TransformationPreset,
TransformationPresetFormat,
TransformationPresetResize,
} from '../types';
// 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
);
}
function extractOptions<T extends Record<string, any>>(keys: (keyof T)[], numberKeys: (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 : value,
}
: config,
{} as T
);
};
}
// Extract format transform from a preset
function extractToFormat(input: TransformationParams | TransformationPreset, file: File): Transformation | undefined {
const options = extractOptions<TransformationPresetFormat>(['format', 'quality'], ['quality'])(input);
return Object.keys(options).length > 0
? [
'toFormat',
options.format || (file.type!.split('/')[1] as any),
{
quality: options.quality,
},
]
: undefined;
}
function extractResize(input: TransformationParams | TransformationPreset): Transformation | undefined {
const resizable = ['width', 'height'].some((key) => key in input);
if (!resizable) return undefined;
return [
'resize',
extractOptions<TransformationPresetResize>(
['width', 'height', 'fit', 'withoutEnlargement'],
['width', 'height']
)(input),
];
}
/**
* Try to extract a file format from an array of `Transformation`'s.
*/
export function maybeExtractFormat(transforms: Transformation[]): string | undefined {
const toFormats = transforms.filter((t) => t[0] === 'toFormat');
const lastToFormat = toFormats[toFormats.length - 1];
return lastToFormat ? lastToFormat[1]?.toString() : undefined;
}

View File

@@ -22,7 +22,7 @@
} }
.CodeMirror .CodeMirror-placeholder { .CodeMirror .CodeMirror-placeholder {
color: var(--foreground-subdued); color: var(--foreground-subdued) !important;
} }
.CodeMirror:hover { .CodeMirror:hover {

View File

@@ -35,9 +35,15 @@ permissions and other built-in features.
## Requesting a Thumbnail ## Requesting a Thumbnail
Fetching thumbnails is as easy as adding query parameters to the original file's URL. If a requested thumbnail doesn't Fetching thumbnails is as easy as adding a `key` query parameter to the original file's URL. In the Admin App, you can
yet exist, it is dynamically generated and immediately returned. When requesting a thumbnail, the following parameters configure different asset presets that control the output of any given image. If a requested thumbnail doesn't yet
are all required, supports thumbnail for `jpeg`,`png` and `webp` exist, it is dynamically generated and immediately returned.
- **`key`** — This **key** of the [Storage Asset Preset](/guides/files#creating-thumbnail-presets), a shortcut for the
below parameters
Alternatively, if you have "Storage Asset Transform" set to all, you can use the following parameters for more fine
grained control:
- **`fit`** — The **fit** of the thumbnail while always preserving the aspect ratio, can be any of the following - **`fit`** — The **fit** of the thumbnail while always preserving the aspect ratio, can be any of the following
options: options:
@@ -51,23 +57,31 @@ are all required, supports thumbnail for `jpeg`,`png` and `webp`
- **`height`** — The **height** of the thumbnail in pixels - **`height`** — The **height** of the thumbnail in pixels
- **`quality`** — The **quality** of the thumbnail (`1` to `100`) is `Optional` - **`quality`** — The **quality** of the thumbnail (`1` to `100`) is `Optional`
- **`withoutEnlargement`** — Disable image up-scaling - **`withoutEnlargement`** — Disable image up-scaling
- **`download`** — Add `Content-Disposition` header and force browser to download file - **`format`** — What file format to return the thumbnail in. One of `jpg`, `png`, `webp`, `tiff`
``` ```
example.com/assets/<file-id>?fit=<fit>&width=<width>&height=<height>&quality=<quality> example.com/assets/<file-id>?fit=<fit>&width=<width>&height=<height>&quality=<quality>
example.com/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?fit=cover&width=200&height=200&quality=80 example.com/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?fit=cover&width=200&height=200&quality=80
``` ```
Alternatively, you can reference a specific thumbnail by its preset key. For even more advanced control over the file generation, Directus exposes
[the full `sharp` API](https://sharp.pixelplumbing.com/api-operation) through the `transform` query parameter. This
- **`key`** — This **key** of the [Storage Asset Preset](/guides/files#creating-thumbnail-presets), a shortcut for the parameter accepts a two-dimensional array with the format `[Operation, ...arguments]`, for example:
above parameters
``` ```
example.com/assets/<file-id>?key=<preset-key> ?transforms=[
example.com/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?key=card ["blur", 45],
["tint", "rgb(255, 0, 0)"],
["expand", { "right": 200, "bottom": 150 }]
]
``` ```
### Downloading an asset
To automatically download the file when opening it in a browser, add the `download` query parameter.
- **`download`** — Add `Content-Disposition` header and force browser to download file
### Cover vs Contain ### Cover vs Contain
For easier comparison, both of the examples below were requested at `200` width, `200` height, and `75` quality. The For easier comparison, both of the examples below were requested at `200` width, `200` height, and `75` quality. The

View File

@@ -267,6 +267,7 @@ STORAGE_LOCAL_ROOT="./uploads"
| `ASSETS_CACHE_TTL` | How long assets will be cached for in the browser. Sets the `max-age` value of the `Cache-Control` header. | `30m` | | `ASSETS_CACHE_TTL` | How long assets will be cached for in the browser. Sets the `max-age` value of the `Cache-Control` header. | `30m` |
| `ASSETS_TRANSFORM_MAX_CONCURRENT` | How many file transformations can be done simultaneously | 4 | | `ASSETS_TRANSFORM_MAX_CONCURRENT` | How many file transformations can be done simultaneously | 4 |
| `ASSETS_TRANSFORM_IMAGE_MAX_DIMENSION` | The max pixel dimensions size (width/height) that is allowed to be transformed | 6000 | | `ASSETS_TRANSFORM_IMAGE_MAX_DIMENSION` | The max pixel dimensions size (width/height) that is allowed to be transformed | 6000 |
| `ASSETS_TRANSFORM_MAX_OPERATIONS` | The max number of transform operations that is allowed to be processed (excludes saved presets) | 5 |
Image transformations can be fairly heavy on memory usage. If you're using a system with 1GB or less available memory, Image transformations can be fairly heavy on memory usage. If you're using a system with 1GB or less available memory,
we recommend lowering the allowed concurrent transformations to prevent you from overflowing your server. we recommend lowering the allowed concurrent transformations to prevent you from overflowing your server.

31
package-lock.json generated
View File

@@ -159,6 +159,7 @@
"@types/node": "15.12.2", "@types/node": "15.12.2",
"@types/node-cron": "2.0.4", "@types/node-cron": "2.0.4",
"@types/nodemailer": "6.4.4", "@types/nodemailer": "6.4.4",
"@types/object-hash": "^2.1.0",
"@types/qs": "6.9.7", "@types/qs": "6.9.7",
"@types/sharp": "0.28.4", "@types/sharp": "0.28.4",
"@types/stream-json": "1.7.1", "@types/stream-json": "1.7.1",
@@ -182,6 +183,7 @@
"memcached": "^2.2.2", "memcached": "^2.2.2",
"mysql": "^2.18.1", "mysql": "^2.18.1",
"nodemailer-mailgun-transport": "^2.1.3", "nodemailer-mailgun-transport": "^2.1.3",
"object-hash": "^2.2.0",
"pg": "^8.6.0", "pg": "^8.6.0",
"sqlite3": "^5.0.2", "sqlite3": "^5.0.2",
"tedious": "^11.0.8" "tedious": "^11.0.8"
@@ -281,6 +283,15 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
}, },
"api/node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"optional": true,
"engines": {
"node": ">= 6"
}
},
"api/node_modules/rxjs": { "api/node_modules/rxjs": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.2.0.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.2.0.tgz",
@@ -7684,6 +7695,12 @@
"integrity": "sha512-Nd8y/5t/7CRakPYiyPzr/IAfYusy1FkcZYFEAcoMZkwpJv2n4Wm+olW+e7xBdHEXhOnWdG9ddbar0gqZWS4x5Q==", "integrity": "sha512-Nd8y/5t/7CRakPYiyPzr/IAfYusy1FkcZYFEAcoMZkwpJv2n4Wm+olW+e7xBdHEXhOnWdG9ddbar0gqZWS4x5Q==",
"dev": true "dev": true
}, },
"node_modules/@types/object-hash": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@types/object-hash/-/object-hash-2.1.1.tgz",
"integrity": "sha512-X2LV33+tSr9LQXcQBatyPGtWOOranhjGYjEnphsdtNRdviCY/3ciCkahwohx1/ary2dMHIKIRoncBF/69Wr38A==",
"dev": true
},
"node_modules/@types/object-path": { "node_modules/@types/object-path": {
"version": "0.11.1", "version": "0.11.1",
"resolved": "https://registry.npmjs.org/@types/object-path/-/object-path-0.11.1.tgz", "resolved": "https://registry.npmjs.org/@types/object-path/-/object-path-0.11.1.tgz",
@@ -66811,6 +66828,12 @@
"integrity": "sha512-Nd8y/5t/7CRakPYiyPzr/IAfYusy1FkcZYFEAcoMZkwpJv2n4Wm+olW+e7xBdHEXhOnWdG9ddbar0gqZWS4x5Q==", "integrity": "sha512-Nd8y/5t/7CRakPYiyPzr/IAfYusy1FkcZYFEAcoMZkwpJv2n4Wm+olW+e7xBdHEXhOnWdG9ddbar0gqZWS4x5Q==",
"dev": true "dev": true
}, },
"@types/object-hash": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@types/object-hash/-/object-hash-2.1.1.tgz",
"integrity": "sha512-X2LV33+tSr9LQXcQBatyPGtWOOranhjGYjEnphsdtNRdviCY/3ciCkahwohx1/ary2dMHIKIRoncBF/69Wr38A==",
"dev": true
},
"@types/object-path": { "@types/object-path": {
"version": "0.11.1", "version": "0.11.1",
"resolved": "https://registry.npmjs.org/@types/object-path/-/object-path-0.11.1.tgz", "resolved": "https://registry.npmjs.org/@types/object-path/-/object-path-0.11.1.tgz",
@@ -75317,6 +75340,7 @@
"@types/node": "15.12.2", "@types/node": "15.12.2",
"@types/node-cron": "2.0.4", "@types/node-cron": "2.0.4",
"@types/nodemailer": "6.4.4", "@types/nodemailer": "6.4.4",
"@types/object-hash": "^2.1.0",
"@types/qs": "6.9.7", "@types/qs": "6.9.7",
"@types/sharp": "0.28.4", "@types/sharp": "0.28.4",
"@types/stream-json": "1.7.1", "@types/stream-json": "1.7.1",
@@ -75376,6 +75400,7 @@
"node-machine-id": "^1.1.12", "node-machine-id": "^1.1.12",
"nodemailer": "^6.6.1", "nodemailer": "^6.6.1",
"nodemailer-mailgun-transport": "^2.1.3", "nodemailer-mailgun-transport": "^2.1.3",
"object-hash": "^2.2.0",
"openapi3-ts": "^2.0.0", "openapi3-ts": "^2.0.0",
"ora": "^5.4.0", "ora": "^5.4.0",
"otplib": "^12.0.1", "otplib": "^12.0.1",
@@ -75467,6 +75492,12 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
}, },
"object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"optional": true
},
"rxjs": { "rxjs": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.2.0.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.2.0.tgz",

View File

@@ -85,5 +85,34 @@ properties:
quality: quality:
description: Quality of the compression used. description: Quality of the compression used.
type: integer type: integer
format:
description: Reformat output image
type: string
enum:
- ''
- jpeg
- png
- webp
- tiff
transforms:
description: Additional transformations to apply
type: list
nullable: true
items:
type: object
properties:
method:
description: The Sharp method name
type: string
arguments:
description: A list of arguments to pass to the Sharp method
type: array
nullable: true
items:
type: object
properties:
argument:
description: A JSON representation of the argument value
type: string
example: null example: null
nullable: true nullable: true

View File

@@ -16,34 +16,11 @@ get:
description: The key of the asset size configured in settings. description: The key of the asset size configured in settings.
schema: schema:
type: string type: string
- name: width - name: transforms
in: query in: query
description: Width of the file in pixels. description: A JSON array of image transformations
schema:
type: integer
- name: height
in: query
description: Height of the file in pixels.
schema:
type: integer
- name: fit
in: query
description: Fit of the file
schema: schema:
type: string type: string
enum: [crop, contain, inside, outside]
- name: withoutEnlargement
in: query
description: No image upscale.
schema:
type: boolean
- name: quality
in: query
description: Quality of compression.
schema:
type: integer
minimum: 1
maximum: 100
- name: download - name: download
in: query in: query
description: Download the asset to your computer description: Download the asset to your computer