FEATURE: Optimize images before upload (#13432)

FEATURE: Optimize images before upload (#13432)

Integrates mozJPEG and Resize using WebAssembly to optimize user uploads in the composer on the client-side.

NPM libraries are sourced from our Squoosh fork, which was needed because we have an older asset pipeline.

diff --git a/app/assets/javascripts/discourse/app/components/composer-editor.js b/app/assets/javascripts/discourse/app/components/composer-editor.js
index d4bf509..7280058 100644
--- a/app/assets/javascripts/discourse/app/components/composer-editor.js
+++ b/app/assets/javascripts/discourse/app/components/composer-editor.js
@@ -672,6 +672,11 @@ export default Component.extend({
             filename: data.files[data.index].name,
           })}]()\n`
         );
+        this.setProperties({
+          uploadProgress: 0,
+          isUploading: true,
+          isCancellable: false,
+        });
       })
       .on("fileuploadprocessalways", (e, data) => {
         this.appEvents.trigger(
@@ -681,6 +686,11 @@ export default Component.extend({
           })}]()\n`,
           ""
         );
+        this.setProperties({
+          uploadProgress: 0,
+          isUploading: false,
+          isCancellable: false,
+        });
       });
 
     $element.on("fileuploadpaste", (e) => {
diff --git a/app/assets/javascripts/discourse/app/initializers/register-media-optimization-upload-processor.js b/app/assets/javascripts/discourse/app/initializers/register-media-optimization-upload-processor.js
new file mode 100644
index 0000000..3b6a975
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/initializers/register-media-optimization-upload-processor.js
@@ -0,0 +1,20 @@
+import { addComposerUploadProcessor } from "discourse/components/composer-editor";
+
+export default {
+  name: "register-media-optimization-upload-processor",
+
+  initialize(container) {
+    let siteSettings = container.lookup("site-settings:main");
+    if (siteSettings.composer_media_optimization_image_enabled) {
+      addComposerUploadProcessor(
+        { action: "optimizeJPEG" },
+        {
+          optimizeJPEG: (data) =>
+            container
+              .lookup("service:media-optimization-worker")
+              .optimizeImage(data),
+        }
+      );
+    }
+  },
+};
diff --git a/app/assets/javascripts/discourse/app/lib/media-optimization-utils.js b/app/assets/javascripts/discourse/app/lib/media-optimization-utils.js
new file mode 100644
index 0000000..b67424f
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/lib/media-optimization-utils.js
@@ -0,0 +1,63 @@
+import { Promise } from "rsvp";
+
+export async function fileToImageData(file) {
+  let drawable, err;
+
+  // Chrome and Firefox use a native method to do Image -> Bitmap Array (it happens of the main thread!)
+  // Safari uses the `<img async>` element due to https://bugs.webkit.org/show_bug.cgi?id=182424
+  if ("createImageBitmap" in self) {
+    drawable = await createImageBitmap(file);
+  } else {
+    const url = URL.createObjectURL(file);
+    const img = new Image();
+    img.decoding = "async";
+    img.src = url;
+    const loaded = new Promise((resolve, reject) => {
+      img.onload = () => resolve();
+      img.onerror = () => reject(Error("Image loading error"));
+    });
+
+    if (img.decode) {
+      // Nice off-thread way supported in Safari/Chrome.
+      // Safari throws on decode if the source is SVG.
+      // https://bugs.webkit.org/show_bug.cgi?id=188347
+      await img.decode().catch(() => null);
+    }
+
+    // Always await loaded, as we may have bailed due to the Safari bug above.
+    await loaded;
+
+    drawable = img;
+  }
+
+  const width = drawable.width,
+    height = drawable.height,
+    sx = 0,
+    sy = 0,
+    sw = width,
+    sh = height;
+  // Make canvas same size as image
+  const canvas = document.createElement("canvas");
+  canvas.width = width;
+  canvas.height = height;
+  // Draw image onto canvas
+  const ctx = canvas.getContext("2d");
+  if (!ctx) {
+    err = "Could not create canvas context";
+  }
+  ctx.drawImage(drawable, sx, sy, sw, sh, 0, 0, width, height);
+  const imageData = ctx.getImageData(0, 0, width, height);
+  canvas.remove();
+
+  // potentially transparent
+  if (/(\.|\/)(png|webp)$/i.test(file.type)) {
+    for (let i = 0; i < imageData.data.length; i += 4) {
+      if (imageData.data[i + 3] < 255) {
+        err = "Image has transparent pixels, won't convert to JPEG!";
+        break;
+      }
+    }
+  }
+
+  return { imageData, width, height, err };
+}
diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.js
index 5c25a4a..994d45d 100644
--- a/app/assets/javascripts/discourse/app/lib/plugin-api.js
+++ b/app/assets/javascripts/discourse/app/lib/plugin-api.js
@@ -951,8 +951,6 @@ class PluginApi {
   /**
    * Registers a pre-processor for file uploads
    * See https://github.com/blueimp/jQuery-File-Upload/wiki/Options#file-processing-options
-   * Your theme/plugin will also need to load https://github.com/blueimp/jQuery-File-Upload/blob/v10.13.0/js/jquery.fileupload-process.js
-   * for this hook to work.
    *
    * Useful for transforming to-be uploaded files client-side
    *
diff --git a/app/assets/javascripts/discourse/app/services/media-optimization-worker.js b/app/assets/javascripts/discourse/app/services/media-optimization-worker.js
new file mode 100644
index 0000000..2406924
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/services/media-optimization-worker.js
@@ -0,0 +1,128 @@
+import Service from "@ember/service";
+import { getOwner } from "@ember/application";
+import { Promise } from "rsvp";
+import { fileToImageData } from "discourse/lib/media-optimization-utils";
+import { getAbsoluteURL, getURLWithCDN } from "discourse-common/lib/get-url";
+
+export default class MediaOptimizationWorkerService extends Service {
+  appEvents = getOwner(this).lookup("service:app-events");
+  worker = null;
+  workerUrl = getAbsoluteURL("/javascripts/media-optimization-worker.js");
+  currentComposerUploadData = null;
+  currentPromiseResolver = null;
+
+  startWorker() {
+    this.worker = new Worker(this.workerUrl); // TODO come up with a workaround for FF that lacks type: module support
+  }
+
+  stopWorker() {
+    this.worker.terminate();
+    this.worker = null;
+  }
+
+  ensureAvailiableWorker() {
+    if (!this.worker) {
+      this.startWorker();
+      this.registerMessageHandler();
+      this.appEvents.on("composer:closed", this, "stopWorker");
+    }
+  }
+
+  logIfDebug(message) {
+    if (this.siteSettings.composer_media_optimization_debug_mode) {
+      // eslint-disable-next-line no-console
+      console.log(message);
+    }
+  }
+
+  optimizeImage(data) {
+    let file = data.files[data.index];
+    if (!/(\.|\/)(jpe?g|png|webp)$/i.test(file.type)) {
+      return data;
+    }
+    if (
+      file.size <
+      this.siteSettings
+        .composer_media_optimization_image_kilobytes_optimization_threshold
+    ) {
+      return data;
+    }
+    this.ensureAvailiableWorker();
+    return new Promise(async (resolve) => {
+      this.logIfDebug(`Transforming ${file.name}`);
+
+      this.currentComposerUploadData = data;
+      this.currentPromiseResolver = resolve;
+
+      const { imageData, width, height, err } = await fileToImageData(file);
+
+      if (err) {
+        this.logIfDebug(err);
+        return resolve(data);
+      }
+
+      this.worker.postMessage(
+        {
+          type: "compress",
+          file: imageData.data.buffer,
+          fileName: file.name,
+          width: width,
+          height: height,
+          settings: {
+            mozjpeg_script: getURLWithCDN(
+              "/javascripts/squoosh/mozjpeg_enc.js"
+            ),
+            mozjpeg_wasm: getURLWithCDN(
+              "/javascripts/squoosh/mozjpeg_enc.wasm"
+            ),
+            resize_script: getURLWithCDN(
+              "/javascripts/squoosh/squoosh_resize.js"
+            ),
+            resize_wasm: getURLWithCDN(
+              "/javascripts/squoosh/squoosh_resize_bg.wasm"
+            ),
+            resize_threshold: this.siteSettings
+              .composer_media_optimization_image_resize_dimensions_threshold,

[... diff too long, it was truncated ...]

GitHub sha: fa4a4625179bb1fb213328bb2c196bd41d24f804

This commit appears in #13432 which was approved by eviltrout. It was merged by Falco.