Sarp IŞIK brand logo.

How To Generate Different Image Formats With Strapi-plugin-upload

2020-07-21 - by Sarp IŞIK

Photo by Safar Safarov on Unsplash

This tutorial is an implementation of how to edit strapi-plugin-upload to generate different image formats on the fly.

Note: This is not a production-ready implementation and this article based on the current version of Strapi 3.0.6 and the same version of strapi-plugin-upload. Future updates might break the app behavior.

As described in strapi's website:

Strapi is an open-source, Node.js based, headless CMS to manage content and make it available through a fully customizable API. It is designed to build practical, production-ready Node.js APIs in hours instead of weeks.

Today I will show you how to customize the behavior of strapi-plugin-upload to generate different image formats. The final code of this part can be found in Github repo.

Prerequisites

  • Basic knowledge of Strapi
  • Node js

Table of Contents

Installation

npx create-strapi-app customize-image-formats --quickstart --no-run

For simplicity, I am using the --quickstart flag to bootstrap the SQLite database and --no-run to prevent the app to automatically start the server because we will install the graphql plugin. The last part is optional so you can skip.

After installation is done run the following command to install graphql plugin(optional).

cd customize-image-formats && npm run strapi install graphql

Copying The Default Implementation

According to the documentation, we can modify the installed plugins under the extensions directory. We are going to copy 2 files named "Upload.js" and "image-manipulation.js" from the source code.

Upload.js

First, let's create Upload.js:

mkdir -p extensions/upload/services && $_ && touch Upload.js

and copy the exported method named uploadFileAndPersist from the source code to our newly created Upload.js file:

1module.exports = {
2 async uploadFileAndPersist(fileData) {
3 const config = strapi.plugins.upload.config;
4
5 const {
6 getDimensions,
7 generateThumbnail,
8 generateResponsiveFormats,
9 } = strapi.plugins.upload.services['image-manipulation'];
10
11 await strapi.plugins.upload.provider.upload(fileData);
12
13 const thumbnailFile = await generateThumbnail(fileData);
14 if (thumbnailFile) {
15 await strapi.plugins.upload.provider.upload(thumbnailFile);
16 delete thumbnailFile.buffer;
17 _.set(fileData, 'formats.thumbnail', thumbnailFile);
18 }
19
20 const formats = await generateResponsiveFormats(fileData);
21 if (Array.isArray(formats) && formats.length > 0) {
22 for (const format of formats) {
23 if (!format) continue;
24
25 const { key, file } = format;
26
27 await strapi.plugins.upload.provider.upload(file);
28 delete file.buffer;
29
30 _.set(fileData, ['formats', key], file);
31 }
32 }
33
34 const { width, height } = await getDimensions(fileData.buffer);
35
36 delete fileData.buffer;
37
38 _.assign(fileData, {
39 provider: config.provider,
40 width,
41 height,
42 });
43
44 return this.add(fileData);
45 }
46}

We also need to add the required modules:

"use strict";
const _ = require("lodash");
module.exports = { ... }

In default, the plugin generates 3 different size objects called "small", "medium", "large" and registers them into the "formats" attribute. We can modify this attribute because it's schema defined as the JSON type. We will change each size type under "formats" attribute to an array of objects so we can add different formats later such as "webp" within the different dimensions.

Let's define the "fileFormats" attribute as an empty object. This object will be replacement of the default formats object:

async uploadFileAndPersist(fileData) {
const fileFormats = {};
const config = strapi.plugins.upload.config;
...
}

We can start to change the registration of different formats. First, let's change the thumbnail format implementation:

module.exports = {
async uploadFileAndPersist(fileData) {
...
const thumbnailFile = await generateThumbnail(fileData);
if (thumbnailFile) {
await strapi.plugins.upload.provider.upload(thumbnailFile);
delete thumbnailFile.buffer;
fileFormats["thumbnail"] = [thumbnailFile];
}
...
}
}

Next, we are going to change the implementation of different sizes:

module.exports = {
async uploadFileAndPersist(fileData) {
...
const formats = await generateResponsiveFormats(fileData);
if (Array.isArray(formats) && formats.length > 0) {
for (const format of formats) {
if (!format || !(Array.isArray(format) && format.length > 0)) continue;
for (const { key, file } of format) {
await strapi.plugins.upload.provider.upload(file);
delete file.buffer;
// "key" is here as "small", "medium", "large"...
if (!(fileFormats.hasOwnProperty(key))) {
fileFormats[key] = [];
}
// "file" is created format. "png", "jpeg", "webp"...
fileFormats[key].push(file);
}
}
}
// Format generation of all size's has done.
_.set(fileData, ["formats"], fileFormats);
...
}
}

image-manipulation.js

For now, we are done with customizing the upload handler. It is time to copy the "image-manipulation.js" from the source code to:

touch image-manipulation.js

First, we need to fix the requirement of the helper module:

...
const { bytesToKbytes } = require("strapi-plugin-upload/utils/file");
...

I also created / modified some helper functions:

// Gets the format name after the dot "."
const getFormat = (ext) => ext.substring(1);
...
const resizeTo = (buffer, options, format) =>
sharp(buffer)
.resize(options)
.toFormat(format)
.toBuffer()
.catch(() => null);
const reFormat = (format) => (format === "jpeg" ? "jpg" : format);
// Renames the file extension.
const reName = (name, toName) =>
name.split(".").slice(0, -1).join().concat(toName);

We also need to change parts where the above helper functions are called:

...
const generateThumbnail = async (file) => {
...
if (
width > THUMBNAIL_RESIZE_OPTIONS.width ||
height > THUMBNAIL_RESIZE_OPTIONS.height
) {
const newBuff = await resizeTo(
file.buffer,
THUMBNAIL_RESIZE_OPTIONS,
getFormat(file.ext)
);
}
...
}
...
const generateResponsiveFormats = async (file) => {
const {
responsiveDimensions = false,
} = await strapi.plugins.upload.services.upload.getSettings();
if (!responsiveDimensions) return [];
if (!(await canBeProccessed(file.buffer))) return [];
const originalDimensions = await getDimensions(file.buffer);
const format = getFormat(file.ext);
const toFormats = [format];
// We need to check the original image format to prevent duplication.
if (format !== "webp") {
toFormats.push("webp");
}
return await Promise.all(
toFormats.map((toFormat) => {
// For each format,
return Promise.all(
BREAKPOINTS.map(([key, breakpoint]) => {
if (breakpointSmallerThan(breakpoint, originalDimensions)) {
// Generate breakpoint sized image
return generateBreakpoint(key, {
file,
toFormat,
breakpoint,
originalDimensions,
});
}
})
);
})
);
};
const generateBreakpoint = async (key, { file, toFormat, breakpoint }) => {
const newBuff = await resizeTo(
file.buffer,
{
width: breakpoint,
height: breakpoint,
fit: "inside",
},
toFormat
);
if (newBuff) {
const data = await getMetadatas(newBuff);
const { width, height, size } = data;
const format = reFormat(data.format);
const ext = `.${format}`;
return {
key,
file: {
name: `${key}_${reName(file.name, ext)}`,
hash: `${key}_${file.hash}`,
ext,
mime: `image/${format}`,
width,
height,
size: bytesToKbytes(size),
buffer: newBuff,
path: file.path ? file.path : null,
},
};
}
};
...

One more refactoring left that converting breakpoints object to 2d array for an easier iteration:

...
const BREAKPOINTS = [
["large", 1000],
["medium", 750],
["small", 500],
];
...

We can delete the "optimize" function which we don't need.

Generating Base64

Until now, we only re-implemented the current functionalities. In addition to that, we are going to add our implementation of generating a base64 format. First, let's create a resize option object.:

const BASE64_RESIZE_OPTIONS = {
width: 20,
height: 20,
fit: "inside",
};

We set width/height to 20px so that we can use it to have a blurred effect similar to medium's style and we set fit to "inside" to resize the image to be as large as possible while ensuring its dimensions are less than or equal to defined width or height(20px). More about resize options can be found on sharp's website. Next, let's create the base64 generator:

const generateBase64 = async (file) => {
const newBuff = await sharp(file.buffer)
.resize(BASE64_RESIZE_OPTIONS)
.toBuffer({
resolveWithObject: true,
});
if (newBuff) {
const { data, info } = newBuff;
return {
url: `data:image/${info.format};base64,${data.toString("base64")}`,
width: info.width,
height: info.height,
};
}
};

As an addition, I kept the width and height information but you can skip those attributes because the important thing is url property which is our small image as a string in base64 format. Lastly, let's export our generator:

...
module.exports = {
getDimensions,
generateBase64,
generateResponsiveFormats,
generateThumbnail,
bytesToKbytes,
};

We defined the "generateBase64" but we haven't called it yet. Let's go back to Upload.js and edit the "uploadFileAndPersist" method:

...
module.exports = {
async uploadFileAndPersist(fileData) {
...
const {
getDimensions,
generateBase64,
generateThumbnail,
generateResponsiveFormats,
} = strapi.plugins.upload.services["image-manipulation"];
await strapi.plugins.upload.provider.upload(fileData);
const base64 = await generateBase64(fileData);
if (base64) {
fileFormats["base64"] = [base64];
}
...
}
};

It is time to test! Navigate to the root directory of the app and run:

npm run develop

Once you uploaded the image, you can check and see the created formats under the path "./public/uploads". Also, if you visit the graphql, you can query the formats with the following:

query Uploads {
files {
formats
}
}

Ending Notes

So far we implemented our image upload handler. In the next part, We will handle the process of deleting the uploaded image and its generated formats. You can find the final code of this part in Github repo.



© 2020, Sarp IŞIK