Einfache Thumbnails für dein nächstes Strapi-Projekt

Einfache Thumbnails für dein nächstes Strapi-Projekt

Ich sitze derzeit an einem größeren Projekt für das ich zum ersten mal Strapi als headless CMS einsetze. Strapi ist noch relativ jung und abgesehen von ein paar Bugs wirklich schon weit entwickelt.

Was ich jedoch vermisse, ist die Fähigkeit des CMS eigene Thumbnails für meine App zu generieren. In diesem kleinen Hands-on zeige ich dir, wie ich das updatesicher in Strapi umgesetzt habe.

Dieses Tutorial funktioniert für Strapi 3.x.x

Folgende Features hat dein zukünftiges Thumbnail-System

  • Wir lassen den Kern der Software in Ruhe und bleiben somit relativ updatesicher
  • Generierung von Thumbnails in verschiedenen Größen
  • Automatische Generierung von Thumbnails beim Speichern von Datensätzen
  • API-Endpunkt um alle Thumbnails neu zu generieren
  • Bonus: Bild-Rotation im Backend

1. Installiere GraphicsMagick

Damit NodeJs serverseitig die Bilder generieren kann setze ich auf GraphicsMagick. GraphicsMagick muss einerseits als Packet für das Betriebssystem und auch als Node-Module installiert werden.

Da ich Strapi als Docker-Container laufen lasse ist die Erweiterung des Basis-Images nicht so schwierig. Hier meine Dockerfile:

FROM strapi/strapi

RUN apk update
RUN apk add graphicsmagick

EXPOSE 80

Du kannst GraphicsMagick auf den meisten Systemen aber auch einfach mit "apt-get install" installieren. Danach brauche ich noch das zugehörige Node-Module im Projekt:

npm install --save graphicsmagick

2. Erstellen des API-Endpunkts in Strapi

Zunächst brauchen wir einen Ort, für unsere Strapi-Erweiterung. Im api-Ordner erstelle ich daher zunächst einen neuen Ordner für den Endpunkt mit dem Namen "image" und lege darin die benötigten Ordner und Dateien an. Die Ordnerstruktur sieht wie folgt aus:

  • /api/image/config/routes.json
  • /api/image/controllers/Image.js
  • /api/image/helpers/ImageHelper.js

In der routes.json wird der Endpunkt noch definiert. Ich entschied mich für den Namen "/generateThumbnails"

{
  "routes": [
    {
      "method": "GET",
      "path": "/generateThumbnails",
      "handler": "image.generate",
      "config": {
        "policies": []
      }
    }
  ]
}

Im Image-Controller gibt es dann nur die Methode "generate":

'use strict';

/**
 * Product.js controller
 *
 * @description: A set of functions called "actions" for managing `Product`.
 */

const ImageHelper = require('../helpers/ImageHelper');

module.exports = {

  generate: async (ctx) => {

    var returnData = {};

    ImageHelper.generateSizesFromFolder('/uploads');

    ctx.send(JSON.stringify(returnData, null, 4));
  },

};

Wie dir sicher schon aufgefallen ist, liefert diese Route bisher nur ein leeres Objekt zurück. Aber ich denke das ist OK. Die Generierung der Thumbnails kann sehr lange dauern und es würde keinen Sinn machen mit "await" auf den Response zu warten. Betrachte diese Route also nur als ein Werkzeug, um die Generierung anzustoßen. Du kannst dir den Response aber natürlich beliebig anpassen.

Die Hauptfunktion dieses Controllers besteht im Aufruf von "ImageHelper.generateSizesFromFolder()", welcher wir uns nun näher widmen.

3. Der Image-Helper

Der Image-Helper ist das Herzstück unseres Thumbnail-Generators. Darin enthalten ist der Code, der letztlich unsere Bilder neu berechnet. Schau dir den Code der ImageHelper.js an. Die Kommentare sollten dir helfen das genauer zu verstehen:


// Import some required dependencys
const path = require('path');
const gm = require('gm');
const fs = require('fs');

// Export our helper functions
module.exports = {
  getSizeImagePathes: getSizeImagePathes,
  getSizeImagePath: getSizeImagePath,
  generateSizesFromFolder: generateSizesFromFolder,
  generateSizes: generateSizes,
  resize: resize,
  rotate: rotate,
  rename: rename,
  removeThumbnails: removeThumbnails
};

// Define our thumbnail sizes
const imageSizes = {
  thumbnail: [150, 150],
  small: [300, 300],
  medium: [500, 500],
  large: [2000, 2000]
};

// Define the uploads path
const uploadsPath = path.join(__dirname, '../../../public');

// Async implementation of readdir
const readdir = (path, opts = 'utf8') =>
  new Promise((resolve, reject) => {
    fs.readdir(path, (err, data) => {
      if (err) reject(err)
      else resolve(data)
    })
  })

// Generate thumbnails for each file in a folder
async function generateSizesFromFolder(folderName) {
  const files = await readdir(uploadsPath+folderName);
  for (let i = 0; i < files.length; i++) {
    await generateSizes(folderName+'/'+files[i]);
  }
}

// Creates different thumbnail versions from base file
async function generateSizes(filePath) {
  for(size in imageSizes) {
    if(imageSizes.hasOwnProperty(size)) {
      await resize(filePath, size);
    }
  }
}

// Take a file path and return the different pathes for the thumbnails
function getSizeImagePathes(filePath){
  var pathes = {};
  for(size in imageSizes) {
    if(imageSizes.hasOwnProperty(size)) {
      pathes[size] = getSizeImagePath(filePath, size);
    }
  }
  return pathes;
}

// Convert path to version with the requested size
function getSizeImagePath(filePath, size){
  return path.dirname(filePath)+'/thumbnails/'+size+'/'+path.basename(filePath);
}

// Resize image to size
function resize(filePath, size) {
  return new Promise(function (resolve, reject) {

    var targetFilePath = getSizeImagePath(filePath, size);

    if (!fs.existsSync(uploadsPath+path.dirname(filePath)+'/thumbnails/')){
      fs.mkdirSync(uploadsPath+path.dirname(filePath)+'/thumbnails/');
    }

    if (!fs.existsSync(uploadsPath+path.dirname(filePath)+'/thumbnails/'+size)){
      fs.mkdirSync(uploadsPath+path.dirname(filePath)+'/thumbnails/'+size);
    }

    try {
      gm(uploadsPath+filePath)
        .strip() // Removes any profiles or comments. Work with pure data
        .interlace('Line') // Line interlacing creates a progressive build up
        .resize(imageSizes[size][0], imageSizes[size][1])
        .gravity('Center')
        .crop(imageSizes[size][0], imageSizes[size][1])
        .quality(75)
        .write(uploadsPath+targetFilePath, function (err) {
          if(err) {
            console.error('Error resizing image!', err);
          }
          resolve();
        });
    } catch (err) {
      console.error('Error resizing image!', err);
      resolve();
    }
  });
}

// Rotate images
function rotate(filePath, deg){
  return new Promise(function (resolve, reject) {
    // console.log('Rotating image '+filePath);
    gm(uploadsPath+filePath)
    .rotate('green', deg)
    .write(uploadsPath+filePath, async function (err) {
      if (err){
        // console.error('Error rotating image!', err);
        reject();
      } else {
        resolve();
      }
    });
  });
}

// Async implementation of rename
function rename(sourcePath, targetPath){
  return new Promise(function (resolve, reject) {
    fs.rename(uploadsPath+sourcePath, uploadsPath+targetPath, function (err) {
      if (err){
        console.log(err);
        reject();
      } else {
        resolve();
      }
    });
  });
}

// Function to remove thumbnails for a given file
function removeThumbnails(filePath){

  var pathes = getSizeImagePathes(filePath);

  for(p in pathes){
    if(pathes.hasOwnProperty(p)){
      (function(path){

        fs.unlink(uploadsPath+path, function (err) {
          if (err) console.log(err);
        });

      })(pathes[p])
    }
  }

}

Schau dir die folgenden Zeilen genauer an. Sie definieren welche Thumbnail-Größen es in deinem System geben wird:

const imageSizes = {
  thumbnail: [150, 150],
  small: [300, 300],
  medium: [500, 500],
  large: [2000, 2000]
};

Du kannst diese auch gerne in die "config/custom.json" auslagern, so wie es in der Strapi Dokumentation beschrieben ist.

Aus Gründen der Einfachheit lasse ich sie hier aber in diesem Code.

4. Generiere die ersten Thumbnails

Damit du die Route "/generateThumbnails" überhaupt im Browser aufrufen kannst, musst du für diese im Backend noch die erforderlichen Berechtigungen setzen. Nachdem das passiert ist, kannst du die Route aufrufen. Du wirst nun auch feststellen, dass für jede Thumbnail-Kategorie ein Unterordner mit dem jeweiligen Namen angelegt wird. Also "/thumbnail", "/small", "/medium" und "/large"

5. Generiere Thumbnails beim Speichern verschiedener Datentypen

Erstelle dir einen neuen Datentyp. Zum Beispiel den Typ "Product". Füge deinem Produkt ein Medienfeld mit zum Beispiel dem Namen "image" hinzu. Für dieses Feld sollen nun nach jedem Speichern die Thumbnails neu generiert werden.

Füge zusätzlich ein Nummernfeld mit dem Namen "rotate" ein. In dieses kannst du die Rotations-Schritte eintragen, wenn du möchtest. Dieses Feld wird dafür sorgen, dass das Bild nach der Speicherung für jeden Schritt im Uhrzeigersinn um 90 Grad rotiert wird.

Warum ist die Rotation wichtig? In meiner App können User Bilder hochladen. Diese Bilder kommen in allen möglichen Ausrichtungen an. Sie stehen Teils auf dem Kopf oder liegen quer. Daher braucht es diese Funktion. Du kannst sie in deinem Projekt aber auch einfach weglassen.

Nachdem du den Produkt-Typ generiert hast, findest du im Verzeichnis /api/product/models/ die Product.js. Darin können wir nun unsere Hooks anpassen und auf das Speichern der Datensätze reagieren. Ich habe in diesem Beispiel die Datei bereits entsprechend angepasst:

'use strict';

/**
 * Lifecycle callbacks for the `Product` model.
 */

const ImageHelper = require('../../image/helpers/ImageHelper');
const fs = require('fs');
var crypto = require('crypto');

var currentId = false;

module.exports = {

  // Before updating a value.
  // Fired before an `update` query.
  beforeUpdate: async (model) => {

    // Note: model._update is only available if this hook was triggered from backend actions!
    if(model._update != undefined){
      if(model._update._id != undefined){
        // There is no way to get any document data in the afterUpdate callback :-(
        // So lets remember its ID
        currentId = model._update._id;
      }
    }

    // Get the product with the populated image
    var product = await strapi.models.product.findOne({_id: model._update._id}).populate('image');

    // We will use this hook to rotate the product image if the rotate flag was set
    if(model._update.rotate != 0){
      if(Number.isInteger(model._update.rotate)){
        if(model._update.rotate>0 && model._update.rotate<=3){
          if(product.image != undefined){

            // Rotate the image
            await ImageHelper.rotate(product.image.url, 90 * model._update.rotate);

            // Rename the image
            // This is necessary to avoid image caching in the backend or frontend
            
            // Generate a new name
            var current_date = (new Date()).valueOf().toString();
            var random = Math.random().toString();
            var hash = crypto.createHash('sha1').update(current_date + random).digest('hex');

            // Rename the image
            await ImageHelper.rename(product.image.url, '/uploads/'+hash+product.image.ext);

            // Update the image in the database
            const fileDocument = await strapi.plugins.upload.models.file.update({_id: product.image._id}, {
              name: hash+product.image.ext,
              hash: hash,
              url: '/uploads/'+hash+product.image.ext
            });

            // Remove thumbnails because we have renamed the image
            ImageHelper.removeThumbnails(product.image.url);

          }
        }
      }
      
      // Disable function on next update
      model._update.rotate = 0;

    }

  },

  // After updating a value.
  // Fired after an `update` query.
  afterUpdate: async (model, result) => {

    // I know this looks very hacky but...
    // At the time, when the afterUpdate function gets fired, the new data is not entirly written to the database
    // That means we have to wait until the new image has arrived
    if(currentId){
      (function(currentId){
        setTimeout(async function(){

          // Get the product with the populated image
          var product = await strapi.models.product.findOne({_id: currentId}).populate('image');

          // Re-Generate image thumbnails
          if(product.image != undefined){
            ImageHelper.generateSizes(product.image.url);
          }

        }, 5000);
      })(currentId)
      currentId=false;
    }

  }

};

Wie du sicher schon gemerkt hast, ist das System etwas hacky. Ich würde mir wünschen, dass ich das sauberer implementieren könnte, sehe aber gerade keine andere Möglichkeit. Zunächst wird "beforeUpdate" aufgerufen. In dieser Methode wird das Bild rotiert, wenn der "rotate" Wert gesetzt wurde. Außerdem wird die ID des zu speichernden Datensatzes in currentId abgelegt. Das ist daher wichtig, weil ich erst in der "afterUpdate" Methode die Thumbnails generieren kann. Denn erst nach dem Speichern steht das Bild zur Verfügung. Dummerweise gibt es in "afterUpdate" gerade nicht die Möglichkeit auf die ID des Dokuments zuzugreifen, weshalb ich diese vorher speichern muss.

Zudem erkennst du in der "afterUpdate" Methode einen Timeout, der die Generierung der Thumbnails um 5 Sekunden verzögert. Zum Zeitpunkt des Speicherns stehen die neuen Daten manchmal nämlich noch nicht zur Verfügung.

Wenn du die Thumbnails auch gleich beim Erstellen eines Datensatzes generieren möchtest, solltest du dir die Hooks "beforeCreate" und "afterCreate" einmal genauer ansehen. Das funktioniert dann auf ähnliche Weise.

6. Das Bild im Frontend bekommen

Nachdem nun die Thumbnails automatisch generiert werden, fehlt noch die Möglichkeit diese auch im Frontend durch die API zu bekommen.

Dazu muss lediglich der name der Thumbnailgröße dem Bild vorangestellt werden. Aus "/uploads/5cde7e77bc7374001e0e7169.jpg" wird also "/uploads/medium/5cde7e77bc7374001e0e7169.jpg"

Fertig ist dein neuer Sterapi Thumbnail Generator!

Titelbild: https://pixabay.com/de/photos/erinnerungen-bilder-fotos-alte-box-407021/