Repository: kaliber5/ember-responsive-image
Pull request #116 (2021-01-24 14:02:44)

Add WebP format support
This is the first part of #57. It adds the build-time processing. The major change here is that we not only save the resized images in the original format (until now either jpeg or png), but in *additional* ones. So we have a new key `type` in the meta data, and adopted the service method to also query for that type. Given that work, adding new formats like AVIF (#85) should be very easy.

The frontend still needs to be updated to support these additional formats. Currently it still only exposes the original format. Adding additional ones requires some new markup, namely using `<picture>` instead of just `<img srcset>`. This will be done in a separate PR.

/cc @andreasschacht @lolmaus 


Patch:
diff --git a/addon/services/responsive-image.js b/addon/services/responsive-image.js
index 99a029e1..ec042bce 100644
--- a/addon/services/responsive-image.js
+++ b/addon/services/responsive-image.js
@@ -4,6 +4,8 @@ import { assert } from '@ember/debug';
 
 const screenWidth = typeof screen !== 'undefined' ? screen.width : 320;
 
+const extentionTypeMapping = new Map([['jpg', 'jpeg']]);
+
 /**
  * Service class to provides images generated by the responsive images package
  *
@@ -56,17 +58,23 @@ export default class ResponsiveImageService extends Service {
     return A(this.meta[imageName].images);
   }
 
+  getType(imageName) {
+    const extension = imageName.split('.').pop();
+    return extentionTypeMapping.get(extension) ?? extension;
+  }
+
   /**
    * returns the image which fits for given size
    *
    * @method getImageBySize
    * @param {String} imageName The origin name of the Image
    * @param {Number} size The width of the image in percent of the screenwidth
+   * @param {String} type The image type (jpeg, png, webp etc.)
    * @return {String} The url of the image
    * @public
    */
-  getImageBySize(imageName, size) {
-    return this.getImageDataBySize(imageName, size).image;
+  getImageBySize(imageName, size, type) {
+    return this.getImageDataBySize(imageName, size, type)?.image;
   }
 
   /**
@@ -75,12 +83,15 @@ export default class ResponsiveImageService extends Service {
    * @method getImageDataBySize
    * @param {String} imageName The origin name of the Image
    * @param {Number} size The width of the image in percent of the screenwidth
+   * @param {String} type The image type (jpeg, png, webp etc.)
    * @return {Object} The data with image,width and height
    * @public
    */
-  getImageDataBySize(imageName, size) {
+  getImageDataBySize(imageName, size, type = this.getType(imageName)) {
     let width = this.getDestinationImageWidthBySize(imageName, size);
-    return this.getImages(imageName).findBy('width', width);
+    return this.getImages(imageName).find(
+      (img) => img.width === width && img.type === type
+    );
   }
 
   /**
@@ -146,7 +157,10 @@ export default class ResponsiveImageService extends Service {
       'No script tag found containing meta data for ember-responsive-image',
       script
     );
-    return JSON.parse(script.textContent);
+    const meta = JSON.parse(script.textContent);
+    // eslint-disable-next-line ember/no-side-effects
+    this._meta = meta;
+    return meta;
   }
   set meta(meta) {
     this._meta = meta;
diff --git a/ember-cli-build.js b/ember-cli-build.js
index 7e9e52b9..cefd9cd5 100644
--- a/ember-cli-build.js
+++ b/ember-cli-build.js
@@ -7,6 +7,7 @@ module.exports = function (defaults) {
     // Add options here
     fingerprint: {
       enabled: true,
+      extensions: ['js', 'css', 'png', 'jpg', 'gif', 'map', 'webp'],
       exclude: ['testem.js'],
       customHash: '00e24234f1b58e32b935b1041432916f',
     },
diff --git a/lib/image-writer.js b/lib/image-writer.js
index 08661173..a1bf4533 100644
--- a/lib/image-writer.js
+++ b/lib/image-writer.js
@@ -9,6 +9,10 @@ const CachingWriter = require('broccoli-caching-writer');
 const async = require('async-q');
 const sharp = require('sharp');
 
+const imageExtensions = {
+  jpeg: 'jpg',
+};
+
 class ImageResizer extends CachingWriter {
   constructor(
     inputNodes,
@@ -44,14 +48,14 @@ class ImageResizer extends CachingWriter {
   }
 
   async build() {
-    let sourcePath = this.inputPaths[0];
-    let destinationPath = path.join(
+    const sourcePath = this.inputPaths[0];
+    const destinationPath = path.join(
       this.outputPath,
       this.image_options.destinationDir
     );
-    let justCopy = this.image_options.justCopy;
+    const justCopy = this.image_options.justCopy;
 
-    let files = this.getFiles(sourcePath, null, true);
+    const files = this.getFiles(sourcePath, null, true);
 
     if (
       this.image_options.justCopy &&
@@ -62,17 +66,31 @@ class ImageResizer extends CachingWriter {
       );
     }
 
-    let tasks = files
-      .map((file) => {
-        this.writeInfoLine(file);
-        this.addConfigData(file);
-        if (justCopy) {
-          return this.copyImages(file, sourcePath, destinationPath);
-        } else {
-          return this.generateImages(file, sourcePath, destinationPath);
-        }
-      })
-      .reduce((res, fns) => res.concat(fns), []); // flat map to an array of promise-functions
+    let tasks = [];
+    for (const file of files) {
+      let image = sharp(path.join(sourcePath, file))
+        .jpeg({
+          quality: this.image_options.quality,
+          progressive: true,
+          force: false,
+        })
+        .webp({
+          quality: this.image_options.quality,
+          force: false,
+        });
+      this.writeInfoLine(file);
+      this.addConfigData(file);
+
+      const meta = await image.metadata();
+      let newTasks;
+      if (justCopy) {
+        newTasks = this.copyImages(file, image, meta, destinationPath);
+      } else {
+        newTasks = this.generateImages(file, image, meta, destinationPath);
+      }
+
+      tasks = [...tasks, ...newTasks];
+    }
 
     await async.parallelLimit(tasks, 4);
     this.writeInfoLine(
@@ -82,20 +100,18 @@ class ImageResizer extends CachingWriter {
 
   /**
    *
-   * @param file
-   * @param sourcePath
+   * @param filename
+   * @param image
+   * @param meta
    * @param destinationPath
    * @returns {Array} an array of promise-functions
    */
-  generateImages(file, sourcePath, destinationPath) {
-    let image = sharp(path.join(sourcePath, file));
-    let metaPromise = image.metadata();
+  generateImages(filename, image, meta, destinationPath) {
     return this.image_options.supportedWidths.map((width) => {
       return async () => {
-        let meta = await metaPromise;
-        await this.generateImage(
-          file,
-          sourcePath,
+        await this.generateResizedImages(
+          filename,
+          image,
           destinationPath,
           width,
           meta
@@ -104,69 +120,109 @@ class ImageResizer extends CachingWriter {
     });
   }
 
+  imageFormatsFor(meta) {
+    if (this.image_options.formats) {
+      return this.image_options.formats;
+    }
+
+    const preferredFormats = ['webp'];
+    // generate next-gen formats, but also include the original one (e.g. png or jpeg)
+    return preferredFormats.includes(meta.format)
+      ? preferredFormats
+      : [...preferredFormats, meta.format];
+  }
+
   /**
    * copy file from sourcePath to destinationPath for all the needed image size
    *
-   * @param file
-   * @param sourcePath
+   * @param filename
+   * @param image
+   * @param meta
    * @param destinationPath
    * @returns {Array} an array of promise-functions
    */
-  copyImages(file, sourcePath, destinationPath) {
-    let source = path.join(sourcePath, file);
-    let image = sharp(source);
-    let metaPromise = image.metadata();
-    return this.image_options.supportedWidths.map((width) => {
-      return async () => {
-        let meta = await metaPromise;
-        let generatedFilename = this.generateFilename(file, width);
-        let destination = path.join(destinationPath, generatedFilename);
-        this.insertMetadata(file, generatedFilename, width, meta);
-        if (this.imagePreProcessors.length || this.imagePostProcessors.length) {
-          let preProcessedSharp = await this.preProcessImage(
+  copyImages(filename, image, meta, destinationPath) {
+    const source = path.join(this.inputPaths[0], filename);
+    const formats = this.imageFormatsFor(meta);
+    const tasks = [];
+
+    for (const width of this.image_options.supportedWidths) {
+      for (const format of formats) {
+        const task = async () => {
+          const generatedFilename = this.generateFilename(
+            filename,
+            width,
+            format
+          );
+          const destination = path.join(destinationPath, generatedFilename);
+          this.insertMetadata(filename, generatedFilename, format, width, meta);
+          const preProcessedSharp = await this.preProcessImage(
             sharp(source),
-            file,
+            filename,
             width
           );
-          await this.postProcessImage(preProcessedSharp, file, width);
-          return fs.copy(source, destination);
-        } else {
+          await this.postProcessImage(preProcessedSharp, filename, width);
           return fs.copy(source, destination);
-        }
-      };
-    });
+        };
+
+        tasks.push(task);
+      }
+    }
+
+    return tasks;
   }
 
   /**
    * generate resized file from sourcePath to destinationPath for all the needed image size
    *
-   * @param file
-   * @param sourcePath
+   * @param filename
+   * @param sharpObject
    * @param destinationPath
    * @param width
    * @param {Object} meta
    * @returns {deferred.promise|*}
    */
-  async generateImage(file, sourcePath, destinationPath, width, meta) {
-    let source = path.join(sourcePath, file);
-    let generatedFilename = this.generateFilename(file, width);
-    let destination = path.join(destinationPath, generatedFilename);
-    fs.ensureDirSync(path.dirname(destination));
-
-    this.insertMetadata(file, generatedFilename, width, meta);
-    let sharped = sharp(source);
-    let preProcessedSharp = await this.preProcessImage(sharped, file, width);
-    preProcessedSharp.resize(width, null, { withoutEnlargement: true }).jpeg({
-      quality: this.image_options.quality,
-      progressive: true,
-      force: false,
-    });
-    let postProcessed = await this.postProcessImage(
+  async generateResizedImages(
+    filename,
+    sharpObject,
+    destinationPath,
+    width,
+    meta
+  ) {
+    const preProcessedSharp = await this.preProcessImage(
+      sharpObject.clone(),
+      filename,
+      width
+    );
+    preProcessedSharp.resize(width, null, { withoutEnlargement: true });
+    const postProcessedSharp = await this.postProcessImage(
       preProcessedSharp,
-      file,
+      filename,
       width
     );
-    return postProcessed.toFile(destination);
+
+    const formats = this.imageFormatsFor(meta);
+    return Promise.all(
+      formats.map((format) =>
+        this.saveImage(
+          filename,
+          postProcessedSharp,
+          destinationPath,
+          format,
+          width,
+          meta
+        )
+      )
+    );
+  }
+
+  async saveImage(filename, image, destinationPath, format, width, meta) {
+    const generatedFilename = this.generateFilename(filename, width, format);
+    const destination = path.join(destinationPath, generatedFilename);
+    await fs.ensureDir(path.dirname(destination));
+    this.insertMetadata(filename, generatedFilename, format, width, meta);
+
+    await image.toFormat(format).toFile(destination);
   }
 
   /**
@@ -215,16 +271,13 @@ class ImageResizer extends CachingWriter {
     return result;
   }
 
-  generateFilename(file, width) {
-    return (
-      file.substr(0, file.lastIndexOf('.')) +
-      width +
-      'w.' +
-      file.substr(file.lastIndexOf('.') + 1)
-    );
+  generateFilename(file, width, format) {
+    const ext = imageExtensions[format] || format;
+    const base = file.substr(0, file.lastIndexOf('.'));
+    return `${base}${width}w.${ext}`;
   }
 
-  insertMetadata(filename, imagename, width, meta) {
+  insertMetadata(filename, imagename, format, width, meta) {
     let image = path.join(
       this.image_options.rootURL,
       this.image_options.destinationDir,
@@ -240,6 +293,7 @@ class ImageResizer extends CachingWriter {
     let height = Math.round(width / aspectRatio);
     let metadata = {
       image,
+      type: format,
       width,
       height,
     };


Test patch:
diff --git a/node-tests/generate.test.js b/node-tests/generate.test.js
index ccd8d7c2..70ca031b 100644
--- a/node-tests/generate.test.js
+++ b/node-tests/generate.test.js
@@ -8,13 +8,20 @@ const hash = '00e24234f1b58e32b935b1041432916f';
 // compare with tests/dummy/config/environment.js
 const images = [
   {
-    file: 'assets/images/test.jpg',
+    file: 'assets/images/image.jpg',
     type: 'image/jpeg',
     sizes: [50, 100],
     alpha: false,
     jpeqQuality: 50,
     removeSource: true,
   },
+  {
+    file: 'assets/images/image.webp',
+    type: 'image/webp',
+    sizes: [50, 100],
+    alpha: false,
+    removeSource: true,
+  },
   {
     file: 'assets/images/test.png',
     type: 'image/png',
@@ -22,6 +29,13 @@ const images = [
     alpha: true,
     removeSource: true,
   },
+  {
+    file: 'assets/images/test.webp',
+    type: 'image/webp',
+    sizes: [50, 100],
+    alpha: true,
+    removeSource: true,
+  },
   {
     file: 'assets/images/recursive/dir/test.png',
     type: 'image/png',
@@ -36,6 +50,13 @@ const images = [
     alpha: false,
     removeSource: false,
   },
+  {
+    file: 'assets/images/small.webp',
+    type: 'image/webp',
+    sizes: [10, 25],
+    alpha: false,
+    removeSource: true,
+  },
 ];
 const aspectRatio = 2;
 const appDir = './dist';
diff --git a/tests/dummy/public/assets/images/test.jpg b/tests/dummy/public/assets/images/image.jpg
similarity index 100%
rename from tests/dummy/public/assets/images/test.jpg
rename to tests/dummy/public/assets/images/image.jpg
diff --git a/tests/unit/services/responsive-image-test.js b/tests/unit/services/responsive-image-test.js
index e9da2ecf..63279306 100644
--- a/tests/unit/services/responsive-image-test.js
+++ b/tests/unit/services/responsive-image-test.js
@@ -10,12 +10,28 @@ const meta = {
           '/assets/images/responsive/test100w-00e24234f1b58e32b935b1041432916f.png',
         width: 100,
         height: 100,
+        type: 'png',
       },
       {
         image:
           '/assets/images/responsive/test50w-00e24234f1b58e32b935b1041432916f.png',
         width: 50,
         height: 50,
+        type: 'png',
+      },
+      {
+        image:
+          '/assets/images/responsive/test100w-00e24234f1b58e32b935b1041432916f.webp',
+        width: 100,
+        height: 100,
+        type: 'webp',
+      },
+      {
+        image:
+          '/assets/images/responsive/test50w-00e24234f1b58e32b935b1041432916f.webp',
+        width: 50,
+        height: 50,
+        type: 'webp',
       },
     ],
   },
@@ -46,6 +62,17 @@ module('ResponsiveImageService', function (hooks) {
     assert.deepEqual(images, meta['test.png'].images[1]);
   });
 
+  test('retrieve generated image data by size and type', function (assert) {
+    let service = this.owner.lookup('service:responsive-image');
+    service.physicalWidth = 100;
+    let images = service.getImageDataBySize('test.png', 120, 'webp');
+    assert.deepEqual(images, meta['test.png'].images[2]);
+    images = service.getImageDataBySize('test.png', 60, 'webp');
+    assert.deepEqual(images, meta['test.png'].images[2]);
+    images = service.getImageDataBySize('test.png', 45, 'webp');
+    assert.deepEqual(images, meta['test.png'].images[3]);
+  });
+
   test('retrieve a generated image by size', function (assert) {
     let service = this.owner.lookup('service:responsive-image');
     service.physicalWidth = 100;
@@ -56,4 +83,15 @@ module('ResponsiveImageService', function (hooks) {
     image = service.getImageBySize('test.png', 45);
     assert.equal(image, meta['test.png'].images[1].image);
   });
+
+  test('retrieve a generated image by size and type', function (assert) {
+    let service = this.owner.lookup('service:responsive-image');
+    service.physicalWidth = 100;
+    let image = service.getImageBySize('test.png', 120, 'webp');
+    assert.equal(image, meta['test.png'].images[2].image);
+    image = service.getImageBySize('test.png', 60, 'webp');
+    assert.equal(image, meta['test.png'].images[2].image);
+    image = service.getImageBySize('test.png', 45, 'webp');
+    assert.equal(image, meta['test.png'].images[3].image);
+  });
 });