mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
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:
@@ -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",
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
68
api/src/utils/transformations.ts
Normal file
68
api/src/utils/transformations.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.CodeMirror .CodeMirror-placeholder {
|
.CodeMirror .CodeMirror-placeholder {
|
||||||
color: var(--foreground-subdued);
|
color: var(--foreground-subdued) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.CodeMirror:hover {
|
.CodeMirror:hover {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
31
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user