FIX: Handle image decoding failure in composer image optimization (#13555)

FIX: Handle image decoding failure in composer image optimization (#13555)

There are some hard limits in browser Canvas implementations, that will throw a runtime exception when crossed. Since those limits are platform dependent, the best we can do is catch it and back off from trying to optimize a problematic file.

For example, a 60MB PNG can be processed fine by Chrome but Firefox will fail trying to extract the ImageData from the CanvasRenderingContext2D with NS_ERROR_FAILURE.

Also cleans up the media-optimization-utils and add post-resize size logs

diff --git a/app/assets/javascripts/discourse/app/lib/media-optimization-utils.js b/app/assets/javascripts/discourse/app/lib/media-optimization-utils.js
index b67424f..2d18cbf 100644
--- a/app/assets/javascripts/discourse/app/lib/media-optimization-utils.js
+++ b/app/assets/javascripts/discourse/app/lib/media-optimization-utils.js
@@ -1,12 +1,10 @@
 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
+// 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
+async function fileToDrawable(file) {
   if ("createImageBitmap" in self) {
-    drawable = await createImageBitmap(file);
+    return await createImageBitmap(file);
   } else {
     const url = URL.createObjectURL(file);
     const img = new Image();
@@ -26,38 +24,55 @@ export async function fileToImageData(file) {
 
     // Always await loaded, as we may have bailed due to the Safari bug above.
     await loaded;
-
-    drawable = img;
+    return img;
   }
+}
 
+function drawableToimageData(drawable) {
   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";
+    throw "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();
+  return imageData;
+}
 
-  // 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;
-      }
+function isTransparent(type, imageData) {
+  if (!/(\.|\/)(png|webp)$/i.test(type)) {
+    return false;
+  }
+
+  for (let i = 0; i < imageData.data.length; i += 4) {
+    if (imageData.data[i + 3] < 255) {
+      return true;
     }
   }
 
-  return { imageData, width, height, err };
+  return false;
+}
+
+export async function fileToImageData(file) {
+  const drawable = await fileToDrawable(file);
+  const imageData = drawableToimageData(drawable);
+
+  if (isTransparent(file.type, imageData)) {
+    throw "Image has transparent pixels, won't convert to JPEG!";
+  }
+
+  return imageData;
 }
diff --git a/app/assets/javascripts/discourse/app/services/media-optimization-worker.js b/app/assets/javascripts/discourse/app/services/media-optimization-worker.js
index 2406924..f108061 100644
--- a/app/assets/javascripts/discourse/app/services/media-optimization-worker.js
+++ b/app/assets/javascripts/discourse/app/services/media-optimization-worker.js
@@ -54,10 +54,11 @@ export default class MediaOptimizationWorkerService extends Service {
       this.currentComposerUploadData = data;
       this.currentPromiseResolver = resolve;
 
-      const { imageData, width, height, err } = await fileToImageData(file);
-
-      if (err) {
-        this.logIfDebug(err);
+      let imageData;
+      try {
+        imageData = await fileToImageData(file);
+      } catch (error) {
+        this.logIfDebug(error);
         return resolve(data);
       }
 
@@ -66,8 +67,8 @@ export default class MediaOptimizationWorkerService extends Service {
           type: "compress",
           file: imageData.data.buffer,
           fileName: file.name,
-          width: width,
-          height: height,
+          width: imageData.width,
+          height: imageData.height,
           settings: {
             mozjpeg_script: getURLWithCDN(
               "/javascripts/squoosh/mozjpeg_enc.js"
@@ -102,8 +103,6 @@ export default class MediaOptimizationWorkerService extends Service {
 
   registerMessageHandler() {
     this.worker.onmessage = (e) => {
-      this.logIfDebug("Main: Message received from worker script");
-      this.logIfDebug(e);
       switch (e.data.type) {
         case "file":
           let optimizedFile = new File([e.data.file], `${e.data.fileName}`, {
diff --git a/public/javascripts/media-optimization-worker.js b/public/javascripts/media-optimization-worker.js
index 7bbdf13..7bd1968 100644
--- a/public/javascripts/media-optimization-worker.js
+++ b/public/javascripts/media-optimization-worker.js
@@ -81,6 +81,7 @@ async function optimize(imageData, fileName, width, height, settings) {
       ).data;
       width = target_dimensions.width;
       height = target_dimensions.height;
+      logIfDebug(`Worker post resizing file: ${maybeResized.byteLength}`);
     } catch (error) {
       console.error(`Resize failed: ${error}`);
       maybeResized = imageData;

GitHub sha: 99da2210348bd22ee045e50f876f89400d5de489

This commit appears in #13555 which was approved by blake. It was merged by Falco.