diff --git a/.changeset/cold-cobras-carry.md b/.changeset/cold-cobras-carry.md new file mode 100644 index 0000000000..03190cfb9a --- /dev/null +++ b/.changeset/cold-cobras-carry.md @@ -0,0 +1,5 @@ +--- +"docs": patch +--- + +Added docs for focal point support diff --git a/.changeset/loud-crews-fix.md b/.changeset/loud-crews-fix.md new file mode 100644 index 0000000000..5d695f3b11 --- /dev/null +++ b/.changeset/loud-crews-fix.md @@ -0,0 +1,8 @@ +--- +"@directus/app": minor +"@directus/api": minor +"@directus/types": patch +"@directus/sdk": patch +--- + +Added focal point support for images diff --git a/api/src/constants.ts b/api/src/constants.ts index ee152939c9..2732af8fff 100644 --- a/api/src/constants.ts +++ b/api/src/constants.ts @@ -38,7 +38,7 @@ export const SYSTEM_ASSET_ALLOW_LIST: TransformationParams[] = [ }, ]; -export const ASSET_TRANSFORM_QUERY_KEYS: Array = [ +export const ASSET_TRANSFORM_QUERY_KEYS = [ 'key', 'transforms', 'width', @@ -47,7 +47,9 @@ export const ASSET_TRANSFORM_QUERY_KEYS: Array = [ 'fit', 'quality', 'withoutEnlargement', -]; + 'focal_point_x', + 'focal_point_y', +] as const satisfies Readonly<(keyof TransformationParams)[]>; export const FILTER_VARIABLES = ['$NOW', '$CURRENT_USER', '$CURRENT_ROLE']; diff --git a/api/src/database/migrations/20231215A-add-focalpoints.ts b/api/src/database/migrations/20231215A-add-focalpoints.ts new file mode 100644 index 0000000000..3f2e421a4b --- /dev/null +++ b/api/src/database/migrations/20231215A-add-focalpoints.ts @@ -0,0 +1,15 @@ +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('directus_files', (table) => { + table.integer('focal_point_x').nullable(); + table.integer('focal_point_y').nullable(); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('directus_files', (table) => { + table.dropColumn('focal_point_x'); + table.dropColumn('focal_point_y'); + }); +} diff --git a/api/src/database/system-data/fields/files.yaml b/api/src/database/system-data/fields/files.yaml index d3e9dfb5d4..2cb51e8999 100644 --- a/api/src/database/system-data/fields/files.yaml +++ b/api/src/database/system-data/fields/files.yaml @@ -46,6 +46,22 @@ fields: width: half readonly: true + - field: focal_point_divider + interface: presentation-divider + options: + icon: image_search + title: $t:field_options.directus_files.focal_point_divider + special: + - alias + - no-data + width: full + + - field: focal_point_x + width: half + + - field: focal_point_y + width: half + - field: storage_divider interface: presentation-divider options: diff --git a/api/src/services/assets.ts b/api/src/services/assets.ts index be8be64395..8e50a20fb4 100644 --- a/api/src/services/assets.ts +++ b/api/src/services/assets.ts @@ -24,6 +24,7 @@ import type { AbstractServiceOptions, Transformation, TransformationSet } from ' import { getMilliseconds } from '../utils/get-milliseconds.js'; import * as TransformationUtils from '../utils/transformations.js'; import { AuthorizationService } from './authorization.js'; +import { FilesService } from './files.js'; const env = useEnv(); const logger = useLogger(); @@ -32,10 +33,12 @@ export class AssetsService { knex: Knex; accountability: Accountability | null; authorizationService: AuthorizationService; + filesService: FilesService; constructor(options: AbstractServiceOptions) { this.knex = options.knex || getDatabase(); this.accountability = options.accountability || null; + this.filesService = new FilesService(options); this.authorizationService = new AuthorizationService(options); } @@ -66,7 +69,7 @@ export class AssetsService { await this.authorizationService.checkAccess('read', 'directus_files', id); } - const file = (await this.knex.select('*').from('directus_files').where({ id }).first()) as File; + const file = (await this.filesService.readOne(id, { limit: 1 })) as File | undefined; if (!file) throw new ForbiddenError(); diff --git a/api/src/types/assets.ts b/api/src/types/assets.ts index adb2b3e305..3e7fde697f 100644 --- a/api/src/types/assets.ts +++ b/api/src/types/assets.ts @@ -74,6 +74,8 @@ export type TransformationParams = { transforms?: Transformation[]; format?: TransformationFormat | 'auto'; quality?: number; + focal_point_x?: number; + focal_point_y?: number; } & TransformationResize; export type TransformationSet = { diff --git a/api/src/utils/transformations.test.ts b/api/src/utils/transformations.test.ts index 9d3f08aac4..e80f7b0a60 100644 --- a/api/src/utils/transformations.test.ts +++ b/api/src/utils/transformations.test.ts @@ -1,9 +1,9 @@ -import { expect, test, describe } from 'vitest'; -import { maybeExtractFormat, resolvePreset } from './transformations.js'; -import type { File } from '../types/files.js'; +import type { File } from '@directus/types'; +import { describe, expect, test } from 'vitest'; import type { Transformation, TransformationParams } from '../types/assets.js'; +import { maybeExtractFormat, resolvePreset } from './transformations.js'; -const inputFile: File = { +const inputFile = { id: '43a15f67-84a7-4e07-880d-e46a9f33c542', storage: 'local', filename_disk: 'test', @@ -12,18 +12,22 @@ const inputFile: File = { type: null, folder: null, uploaded_by: null, - uploaded_on: new Date(), + uploaded_on: '2023-12-19T16:12:53.149Z', charset: null, filesize: 123, - width: null, - height: null, + width: 1920, + height: 1080, duration: null, embed: null, description: null, location: null, tags: null, metadata: null, -}; + modified_by: null, + modified_on: '', + focal_point_x: null, + focal_point_y: null, +} satisfies File; describe('resolvePreset', () => { test('Prevent input mutation #18301', () => { @@ -88,7 +92,7 @@ describe('resolvePreset', () => { ]); }); - test('Add resize transformation', () => { + test('Add resize transformation: cover without focal point', () => { const transformationParams: TransformationParams = { key: 'system-small-cover', width: 64, @@ -111,6 +115,140 @@ describe('resolvePreset', () => { ]); }); + test('Add resize transformation: cover with centered focal point', () => { + const transformationParams: TransformationParams = { + key: 'system-small-cover', + width: 64, + height: 64, + fit: 'cover', + }; + + const output = resolvePreset( + { transformationParams }, + { ...inputFile, focal_point_x: inputFile.width / 2, focal_point_y: inputFile.height / 2 }, + ); + + /* + * The following is relevant for a centered focal point + * The initial aspect ratio is 16:9 so we have to resize the image to an intermediate size + * that fully covers our desired 1:1 aspect ratio and then crop out the final dimensions + * This results in: 1080/64 == 16.875 --> 1920/16.875 == 113.77 (round up) == 114 width + * Next we need the inner padding to get the centered crop: (114 - 64) / 2 == 25 + * That results in the following + * <──────────114───────────> + * <──25──><───64───><──25──> + * ┌──────┬──────────┬──────┐ + * │ │ │ │ + * │ │ extract │ │ + * │ │ centered │ │ + * │ │ │ │ + * └──────┴──────────┴──────┘ + */ + expect(output).toStrictEqual([ + [ + 'resize', + { + width: 114, + height: 64, + fit: 'cover', + withoutEnlargement: undefined, + }, + ], + ['extract', { left: 25, top: 0, width: 64, height: 64 }], + ]); + }); + + test('Add resize transformation: cover with negative focal point', () => { + const transformationParams: TransformationParams = { + key: 'system-small-cover', + width: 64, + height: 64, + fit: 'cover', + }; + + const output = resolvePreset({ transformationParams }, { ...inputFile, focal_point_x: -999, focal_point_y: -999 }); + + /* + * That should result in the following + * <──────────114────────────> + * <─────64──────><────50────> + * ┌─────────────┬───────────┐ + * │ │ │ + * │ extract │ │ + * │ │ │ + * └─────────────┴───────────┘ + */ + expect(output).toStrictEqual([ + [ + 'resize', + { + width: 114, + height: 64, + fit: 'cover', + withoutEnlargement: undefined, + }, + ], + ['extract', { left: 0, top: 0, width: 64, height: 64 }], + ]); + }); + + test('Add resize transformation: cover with out of bounds focal point', () => { + const transformationParams: TransformationParams = { + key: 'system-small-cover', + width: 64, + height: 64, + fit: 'cover', + }; + + const output = resolvePreset({ transformationParams }, { ...inputFile, focal_point_x: 9999, focal_point_y: -999 }); + + /* + * That should result in the following + * <──────────114────────────> + * <────50────><─────64──────> + * ┌───────────┬─────────────┐ + * │ │ │ + * │ │ extract │ + * │ │ │ + * └───────────┴─────────────┘ + */ + expect(output).toStrictEqual([ + [ + 'resize', + { + width: 114, + height: 64, + fit: 'cover', + withoutEnlargement: undefined, + }, + ], + ['extract', { left: 50, top: 0, width: 64, height: 64 }], + ]); + }); + + test('Add resize transformation: contain', () => { + const transformationParams: TransformationParams = { + key: 'system-small-cover', + width: 64, + height: 64, + fit: 'contain', + }; + + const output = resolvePreset({ transformationParams }, inputFile); + + expect(output).toStrictEqual([ + [ + 'resize', + { + width: 64, + height: 64, + fit: 'contain', + withoutEnlargement: undefined, + }, + ], + ]); + }); + test('Resolve auto format (fallback)', () => { const transformationParams: TransformationParams = { format: 'auto', diff --git a/api/src/utils/transformations.ts b/api/src/utils/transformations.ts index 094f0a9591..1c15f42f14 100644 --- a/api/src/utils/transformations.ts +++ b/api/src/utils/transformations.ts @@ -1,4 +1,6 @@ import type { File } from '@directus/types'; +import { clamp } from 'lodash-es'; +import type { Region } from 'sharp'; import type { Transformation, TransformationFormat, TransformationSet } from '../types/index.js'; export function resolvePreset({ transformationParams, acceptFormat }: TransformationSet, file: File): Transformation[] { @@ -14,18 +16,66 @@ export function resolvePreset({ transformationParams, acceptFormat }: Transforma ]); } - if (transformationParams.width || transformationParams.height) { - transforms.push([ - 'resize', - { - width: transformationParams.width ? Number(transformationParams.width) : undefined, - height: transformationParams.height ? Number(transformationParams.height) : undefined, - fit: transformationParams.fit, - withoutEnlargement: transformationParams.withoutEnlargement - ? Boolean(transformationParams.withoutEnlargement) - : undefined, - }, - ]); + if ((transformationParams.width || transformationParams.height) && file.width && file.height) { + const toWidth = transformationParams.width ? Number(transformationParams.width) : undefined; + const toHeight = transformationParams.height ? Number(transformationParams.height) : undefined; + + const toFocalPointX = transformationParams.focal_point_x + ? Number(transformationParams.focal_point_x) + : file.focal_point_x; + + const toFocalPointY = transformationParams.focal_point_y + ? Number(transformationParams.focal_point_y) + : file.focal_point_y; + + /* + * Focal point cropping only works with a fixed size (width x height) when `cover`ing, + * since the other modes show the whole image. Sharp by default also simply scales up/down + * when only supplied with one dimension, so we **must** check, else we break existing behaviour. + * See: https://sharp.pixelplumbing.com/api-resize#resize + * Also only crop to focal point when explicitly defined so that users can still `cover` with + * other parameters like `position` and `gravity` - Else fall back to regular behaviour + */ + if ( + (transformationParams.fit === undefined || transformationParams.fit === 'cover') && + toWidth && + toHeight && + toFocalPointX !== null && + toFocalPointY !== null + ) { + const transformArgs = getResizeArguments( + { w: file.width, h: file.height }, + { w: toWidth, h: toHeight }, + { x: toFocalPointX, y: toFocalPointY }, + ); + + transforms.push( + [ + 'resize', + { + width: transformArgs.width, + height: transformArgs.height, + fit: transformationParams.fit, + withoutEnlargement: transformationParams.withoutEnlargement + ? Boolean(transformationParams.withoutEnlargement) + : undefined, + }, + ], + ['extract', transformArgs.region], + ); + } else { + transforms.push([ + 'resize', + { + width: toWidth, + height: toHeight, + fit: transformationParams.fit, + withoutEnlargement: transformationParams.withoutEnlargement + ? Boolean(transformationParams.withoutEnlargement) + : undefined, + }, + ]); + } } return transforms; @@ -63,3 +113,72 @@ export function maybeExtractFormat(transforms: Transformation[]): string | undef const lastToFormat = toFormats[toFormats.length - 1]; return lastToFormat ? lastToFormat[1]?.toString() : undefined; } + +type Dimensions = { w: number; h: number }; +type FocalPoint = { x: number; y: number }; + +/** + * Resize an image but keep it centered on the focal point. + * Based on the method outlined in https://github.com/lovell/sharp/issues/1198#issuecomment-384591756 + */ +function getResizeArguments( + original: Dimensions, + target: Dimensions, + focalPoint?: FocalPoint | null, +): { width: number; height: number; region: Region } { + const { width, height, factor } = getIntermediateDimensions(original, target); + + const region = getExtractionRegion(factor, focalPoint ?? { x: original.w / 2, y: original.h / 2 }, target, { + w: width, + h: height, + }); + + return { width, height, region }; +} + +/** + * Calculates the dimensions of the intermediate (resized) image. + */ +function getIntermediateDimensions( + original: Dimensions, + target: Dimensions, +): { width: number; height: number; factor: number } { + const hRatio = original.h / target.h; + const wRatio = original.w / target.w; + + let factor: number; + let width: number; + let height: number; + + if (hRatio < wRatio) { + factor = hRatio; + height = Math.round(target.h); + width = Math.round(original.w / factor); + } else { + factor = wRatio; + width = Math.round(target.w); + height = Math.round(original.h / factor); + } + + return { width, height, factor }; +} + +/** + * Calculates the Region to extract from the intermediate image. + */ +function getExtractionRegion( + factor: number, + focalPoint: FocalPoint, + target: Dimensions, + intermediate: Dimensions, +): Region { + const newXCenter = focalPoint.x / factor; + const newYCenter = focalPoint.y / factor; + + return { + left: clamp(Math.round(newXCenter - target.w / 2), 0, intermediate.w - target.w), + top: clamp(Math.round(newYCenter - target.h / 2), 0, intermediate.h - target.h), + width: target.w, + height: target.h, + }; +} diff --git a/app/src/lang/translations/en-US.yaml b/app/src/lang/translations/en-US.yaml index 3af40dd049..480cf64caa 100644 --- a/app/src/lang/translations/en-US.yaml +++ b/app/src/lang/translations/en-US.yaml @@ -506,7 +506,11 @@ months: november: November december: December drag_mode: Drag Mode +move_tool: Move Tool +crop_tool: Crop Tool +focal_point_tool: Focal Point Tool cancel_crop: Cancel Crop +cancel_selection: Cancel Selection original: Original url: URL import_label: Import @@ -1412,6 +1416,7 @@ field_options: description: An optional description... location: An optional location... storage_divider: File Naming + focal_point_divider: Focal Point filename_disk: Name on disk storage... filename_download: Name when downloading... directus_roles: diff --git a/app/src/views/private/components/image-editor.vue b/app/src/views/private/components/image-editor.vue index eb254400ec..7dd0e44b57 100644 --- a/app/src/views/private/components/image-editor.vue +++ b/app/src/views/private/components/image-editor.vue @@ -3,19 +3,25 @@ import api, { addTokenToURL } from '@/api'; import { useSettingsStore } from '@/stores/settings'; import { getRootPath } from '@/utils/get-root-path'; import { unexpectedError } from '@/utils/unexpected-error'; +import type { File } from '@directus/types'; import Cropper from 'cropperjs'; +import { isEqual } from 'lodash'; import throttle from 'lodash/throttle'; import { nanoid } from 'nanoid/non-secure'; import { computed, nextTick, reactive, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; -type Image = { - type: string; - filesize: number; - filename_download: string; - width: number; - height: number; -}; +const imageFields = [ + 'type', + 'filesize', + 'filename_download', + 'width', + 'height', + 'focal_point_x', + 'focal_point_y', +] as const; + +type Image = Pick; const props = defineProps<{ id: string; @@ -43,7 +49,7 @@ const internalActive = computed({ }, }); -const { loading, error, imageData, imageElement, save, saving, fetchImage, onImageLoad } = useImage(); +const { loading, error, imageData, imageElement, hasEdits, save, saving, fetchImage, onImageLoad } = useImage(); const { cropperInstance, @@ -115,14 +121,24 @@ function useImage() { const imageElement = ref(null); + const hasEdits = computed(() => { + return ( + !isEqual( + { x: 0, y: 0, width: 0, height: 0, rotate: 0, scaleX: 1, scaleY: 1 }, + cropperInstance.value?.getData(), + ) || cropping.value + ); + }); + return { loading, error, imageData, - saving, fetchImage, imageElement, + hasEdits, save, + saving, onImageLoad, }; @@ -132,7 +148,7 @@ function useImage() { const response = await api.get(`/files/${props.id}`, { params: { - fields: ['type', 'filesize', 'filename_download', 'width', 'height'], + fields: imageFields, }, }); @@ -144,36 +160,88 @@ function useImage() { } } - function save() { + async function saveImage(focalPoint?: { x: number | null; y: number | null }) { + // Wrap the callback so that we can actually listen to it + return new Promise((resolve, reject) => { + cropperInstance.value + ?.getCroppedCanvas({ + imageSmoothingQuality: 'high', + }) + .toBlob( + async (blob) => { + if (blob === null) { + return reject(`Couldn't process and save edited image`); + } + + const formData = new FormData(); + + if (focalPoint) { + formData.append('focal_point_x' as keyof File, focalPoint.x?.toString() ?? ''); + formData.append('focal_point_y' as keyof File, focalPoint.y?.toString() ?? ''); + } + + formData.append('file', blob, imageData.value?.filename_download); + + api.patch(`/files/${props.id}`, formData).then( + () => resolve(), + (err) => reject(err), + ); + }, + imageData.value?.type ?? undefined, + ); + }); + } + + async function save() { saving.value = true; - cropperInstance.value - ?.getCroppedCanvas({ - imageSmoothingQuality: 'high', - }) - .toBlob( - async (blob) => { - if (blob === null) { - saving.value = false; - return; - } + // Only save focal point if we're also actively selecting it + const gotChangeForFocalPoint = localDragMode.value === 'focal_point' && cropping.value; - const formData = new FormData(); - formData.append('file', blob, imageData.value?.filename_download); + let focalPointX = null; + let focalPointY = null; - try { - await api.patch(`/files/${props.id}`, formData); - emit('refresh'); - internalActive.value = false; - randomId.value = nanoid(); - } catch (error) { - unexpectedError(error); - } finally { - saving.value = false; - } - }, - imageData.value?.type, - ); + // Important: Check focal point first because we must(!) reset the cropping area + // before saving so that we're not wrongfully cropping + if (gotChangeForFocalPoint) { + const data = cropperInstance.value?.getData(); + focalPointX = Math.round((data?.x ?? 0) + (data?.width ?? 0) / 2); + focalPointY = Math.round((data?.y ?? 0) + (data?.height ?? 0) / 2); + cropperInstance.value?.clear(); + } + + // Important: This check has to go after(!) we reset the cropping area because + // `getData` returns info about our focal point, which leads to a false positive + const gotChangeForImg = !isEqual( + { x: 0, y: 0, width: 0, height: 0, rotate: 0, scaleX: 1, scaleY: 1 }, + cropperInstance.value?.getData(), + ); + + // Collapse focal point patch request and image data into one request if we can + let patchRequest = null; + + if (gotChangeForImg && gotChangeForFocalPoint) { + patchRequest = saveImage({ x: focalPointX, y: focalPointY }); + } else if (gotChangeForImg) { + patchRequest = saveImage(); + } else if (gotChangeForFocalPoint) { + patchRequest = api.patch(`/files/${props.id}`, { + focal_point_x: focalPointX, + focal_point_y: focalPointY, + } satisfies Partial); + } + + try { + await patchRequest; + emit('refresh'); + localDragMode.value = 'move'; + randomId.value = nanoid(); + internalActive.value = false; + } catch (error) { + unexpectedError(error); + } finally { + saving.value = false; + } } async function onImageLoad() { @@ -182,6 +250,8 @@ function useImage() { } } +const localDragMode = ref<'move' | 'crop' | 'focal_point'>('move'); + function useCropper() { const cropperInstance = ref(null); @@ -193,7 +263,7 @@ function useCropper() { }); watch(imageData, () => { - if (!imageData.value) return; + if (!imageData.value || !imageData.value.width || !imageData.value.height) return; localAspectRatio.value = imageData.value.width / imageData.value.height; newDimensions.width = imageData.value.width; newDimensions.height = imageData.value.height; @@ -207,12 +277,11 @@ function useCropper() { localAspectRatio.value = newAspectRatio; cropperInstance.value?.setAspectRatio(newAspectRatio); cropperInstance.value?.crop(); - dragMode.value = 'crop'; }, }); const aspectRatioIcon = computed(() => { - if (!imageData.value) return 'crop_original'; + if (!imageData.value || !imageData.value.width || !imageData.value.height) return 'crop_original'; if (customAspectRatios) { const customAspectRatio = customAspectRatios.find((customAR) => customAR.value == aspectRatio.value); @@ -237,19 +306,36 @@ function useCropper() { } }); - const localDragMode = ref<'move' | 'crop'>('move'); - const dragMode = computed({ get() { return localDragMode.value; }, - set(newMode: 'move' | 'crop') { - cropperInstance.value?.setDragMode(newMode); + set(newMode) { + cropperInstance.value?.setDragMode(newMode === 'move' ? 'move' : 'crop'); localDragMode.value = newMode; if (newMode === 'move') { cropperInstance.value?.clear(); localCropping.value = false; + } else if (newMode === 'crop') { + cropperInstance.value?.crop(); + } else if (newMode === 'focal_point') { + const canvasData = cropperInstance.value?.getCanvasData(); + // Size the box as percentage so that it stays visible for both small and large images + const boxSize = Math.max(16, (canvasData?.naturalWidth ?? 0) * 0.1); + let centeredX = 0; + let centeredY = 0; + + if (imageData.value && imageData.value.focal_point_x !== null && imageData.value.focal_point_y !== null) { + centeredX = imageData.value.focal_point_x - boxSize / 2; + centeredY = imageData.value.focal_point_y - boxSize / 2; + } else { + centeredX = (canvasData?.naturalWidth ?? 0) / 2 - boxSize / 2; + centeredY = (canvasData?.naturalHeight ?? 0) / 2 - boxSize / 2; + } + + aspectRatio.value = 1; + cropperInstance.value?.setData({ x: centeredX, y: centeredY, width: boxSize, height: boxSize }); } }, }); @@ -290,6 +376,7 @@ function useCropper() { } localCropping.value = false; + localDragMode.value = 'move'; cropperInstance.value = new Cropper(imageElement.value as HTMLImageElement, { autoCrop: false, @@ -347,7 +434,7 @@ function useCropper() { } function setAspectRatio() { - if (imageData.value) { + if (imageData.value && imageData.value.width && imageData.value.height) { aspectRatio.value = imageData.value.width / imageData.value.height; } } @@ -375,19 +462,35 @@ function setAspectRatio() { error -
+
-
- - +
+ + +
@@ -445,7 +548,7 @@ function setAspectRatio() { {{ t('free') }}
@@ -479,6 +582,22 @@ function setAspectRatio() {