transform-image.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. import { Logger } from '@vendure/core';
  2. import sharp, { FormatEnum, Region, ResizeOptions } from 'sharp';
  3. import { getValidFormat } from './common';
  4. import { loggerCtx } from './constants';
  5. import { ImageTransformFormat, ImageTransformPreset } from './types';
  6. export type Dimensions = { w: number; h: number };
  7. export type Point = { x: number; y: number };
  8. /**
  9. * Applies transforms to the given image according to the query params passed.
  10. */
  11. export async function transformImage(
  12. originalImage: Buffer,
  13. queryParams: Record<string, string>,
  14. presets: ImageTransformPreset[],
  15. ): Promise<sharp.Sharp> {
  16. let targetWidth = Math.round(+queryParams.w) || undefined;
  17. let targetHeight = Math.round(+queryParams.h) || undefined;
  18. const quality =
  19. queryParams.q != null ? Math.round(Math.max(Math.min(+queryParams.q, 100), 1)) : undefined;
  20. let mode = queryParams.mode || 'crop';
  21. const fpx = +queryParams.fpx || undefined;
  22. const fpy = +queryParams.fpy || undefined;
  23. const imageFormat = getValidFormat(queryParams.format);
  24. if (queryParams.preset) {
  25. const matchingPreset = presets.find(p => p.name === queryParams.preset);
  26. if (matchingPreset) {
  27. targetWidth = matchingPreset.width;
  28. targetHeight = matchingPreset.height;
  29. mode = matchingPreset.mode;
  30. }
  31. }
  32. const options: ResizeOptions = {};
  33. if (mode === 'crop') {
  34. options.position = sharp.strategy.entropy;
  35. } else {
  36. options.fit = 'inside';
  37. }
  38. const image = sharp(originalImage);
  39. try {
  40. await applyFormat(image, imageFormat, quality);
  41. } catch (e: any) {
  42. Logger.error(e.message, loggerCtx, e.stack);
  43. }
  44. if (fpx && fpy && targetWidth && targetHeight && mode === 'crop') {
  45. const metadata = await image.metadata();
  46. if (metadata.width && metadata.height) {
  47. const xCenter = fpx * metadata.width;
  48. const yCenter = fpy * metadata.height;
  49. const { width, height, region } = resizeToFocalPoint(
  50. { w: metadata.width, h: metadata.height },
  51. { w: targetWidth, h: targetHeight },
  52. { x: xCenter, y: yCenter },
  53. );
  54. return image.resize(width, height).extract(region);
  55. }
  56. }
  57. return image.resize(targetWidth, targetHeight, options);
  58. }
  59. async function applyFormat(
  60. image: sharp.Sharp,
  61. format: ImageTransformFormat | undefined,
  62. quality: number | undefined,
  63. ) {
  64. switch (format) {
  65. case 'jpg':
  66. case 'jpeg':
  67. return image.jpeg({ quality });
  68. case 'png':
  69. return image.png();
  70. case 'webp':
  71. return image.webp({ quality });
  72. case 'avif':
  73. return image.avif({ quality });
  74. default: {
  75. if (quality) {
  76. // If a quality has been specified but no format, we need to determine the format from the image
  77. // and apply the quality to that format.
  78. const metadata = await image.metadata();
  79. if (isImageTransformFormat(metadata.format)) {
  80. return applyFormat(image, metadata.format, quality);
  81. }
  82. }
  83. return image;
  84. }
  85. }
  86. }
  87. function isImageTransformFormat(input: keyof FormatEnum | undefined): input is ImageTransformFormat {
  88. return !!input && ['jpg', 'jpeg', 'webp', 'avif'].includes(input);
  89. }
  90. /**
  91. * Resize an image but keep it centered on the focal point.
  92. * Based on the method outlined in https://github.com/lovell/sharp/issues/1198#issuecomment-384591756
  93. */
  94. export function resizeToFocalPoint(
  95. original: Dimensions,
  96. target: Dimensions,
  97. focalPoint: Point,
  98. ): { width: number; height: number; region: Region } {
  99. const { width, height, factor } = getIntermediateDimensions(original, target);
  100. const region = getExtractionRegion(factor, focalPoint, target, { w: width, h: height });
  101. return { width, height, region };
  102. }
  103. /**
  104. * Calculates the dimensions of the intermediate (resized) image.
  105. */
  106. function getIntermediateDimensions(
  107. original: Dimensions,
  108. target: Dimensions,
  109. ): { width: number; height: number; factor: number } {
  110. const hRatio = original.h / target.h;
  111. const wRatio = original.w / target.w;
  112. let factor: number;
  113. let width: number;
  114. let height: number;
  115. if (hRatio < wRatio) {
  116. factor = hRatio;
  117. height = Math.round(target.h);
  118. width = Math.round(original.w / factor);
  119. } else {
  120. factor = wRatio;
  121. width = Math.round(target.w);
  122. height = Math.round(original.h / factor);
  123. }
  124. return { width, height, factor };
  125. }
  126. /**
  127. * Calculates the Region to extract from the intermediate image.
  128. */
  129. function getExtractionRegion(
  130. factor: number,
  131. focalPoint: Point,
  132. target: Dimensions,
  133. intermediate: Dimensions,
  134. ): Region {
  135. const newXCenter = focalPoint.x / factor;
  136. const newYCenter = focalPoint.y / factor;
  137. const region: Region = {
  138. left: 0,
  139. top: 0,
  140. width: target.w,
  141. height: target.h,
  142. };
  143. if (intermediate.h < intermediate.w) {
  144. region.left = clamp(0, intermediate.w - target.w, Math.round(newXCenter - target.w / 2));
  145. } else {
  146. region.top = clamp(0, intermediate.h - target.h, Math.round(newYCenter - target.h / 2));
  147. }
  148. return region;
  149. }
  150. /**
  151. * Limit the input value to the specified min and max values.
  152. */
  153. function clamp(min: number, max: number, input: number) {
  154. return Math.min(Math.max(min, input), max);
  155. }