FEATURE: Limit client side image compression on Safari to version >= 18 (#28373)

This PR limits this feature:

On all devices:
- Browsers with OffScreenCanvas support
- Browsers with createImageBitmap

On Apple Safari
- At least version 18

It also adds a routine that terminates the worker after 5 uses on all devices to handle any WASM memory leaks. All this together fixes crashes that could occur on iPhones.

It still leaves the feature disabled by default on iOS, which will be revisited after testing this changes.
This commit is contained in:
Rafael dos Santos Silva 2024-10-02 12:37:41 -03:00 committed by GitHub
parent fc1c5f6a8d
commit fd57a64174
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 31 additions and 54 deletions

View File

@ -8,14 +8,6 @@ export default {
const capabilities = owner.lookup("service:capabilities");
if (siteSettings.composer_media_optimization_image_enabled) {
// NOTE: There are various performance issues with the Canvas
// in iOS Safari that are causing crashes when processing images
// with spikes of over 100% CPU usage. The cause of this is unknown,
// but profiling points to CanvasRenderingContext2D.getImageData()
// and CanvasRenderingContext2D.drawImage().
//
// Until Safari makes some progress with OffscreenCanvas or other
// alternatives we cannot support this workflow.
if (
capabilities.isIOS &&
!siteSettings.composer_ios_media_optimisation_image_enabled
@ -23,6 +15,22 @@ export default {
return;
}
// Restrict feature to browsers that support OffscreenCanvas
if (typeof OffscreenCanvas === "undefined") {
return;
}
if (!("createImageBitmap" in self)) {
return;
}
// prior to v18, Safari has WASM memory growth bugs
// eg https://github.com/emscripten-core/emscripten/issues/19144
let match = window.navigator.userAgent.match(/Mobile\/([0-9]+)\./);
let safariVersion = match ? parseInt(match[1], 10) : null;
if (capabilities.isSafari && safariVersion && safariVersion < 18) {
return;
}
addComposerUploadPreProcessor(
UppyMediaOptimization,
({ isMobileDevice }) => {

View File

@ -1,35 +1,5 @@
import { Promise } from "rsvp";
import { helperContext } from "discourse-common/lib/helpers";
// Chrome and Firefox use a native method to do Image -> Bitmap Array (it happens of the main thread!)
// Safari < 15 uses the `<img async>` element due to https://bugs.webkit.org/show_bug.cgi?id=182424
// Safari > 15 still uses `<img async>` due to their buggy createImageBitmap not handling EXIF rotation
async function fileToDrawable(file) {
const caps = helperContext().capabilities;
if ("createImageBitmap" in self && !caps.isApple) {
return 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;
return img;
}
return await createImageBitmap(file);
}
function drawableToImageData(drawable) {
@ -40,17 +10,7 @@ function drawableToImageData(drawable) {
sw = width,
sh = height;
const offscreenCanvasSupported = typeof OffscreenCanvas !== "undefined";
// Make canvas same size as image
let canvas;
if (offscreenCanvasSupported) {
canvas = new OffscreenCanvas(width, height);
} else {
canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
}
let canvas = new OffscreenCanvas(width, height);
// Draw image onto canvas
const ctx = canvas.getContext("2d");
@ -60,10 +20,6 @@ function drawableToImageData(drawable) {
ctx.drawImage(drawable, sx, sy, sw, sh, 0, 0, width, height);
const imageData = ctx.getImageData(0, 0, width, height);
if (!offscreenCanvasSupported) {
canvas.remove();
}
return imageData;
}

View File

@ -29,6 +29,8 @@ export default class MediaOptimizationWorkerService extends Service {
workerUrl = getAbsoluteURL("/javascripts/media-optimization-worker.js");
currentComposerUploadData = null;
promiseResolvers = null;
workerDoneCount = 0;
workerPendingCount = 0;
async optimizeImage(data, opts = {}) {
this.promiseResolvers = this.promiseResolvers || {};
@ -98,6 +100,7 @@ export default class MediaOptimizationWorkerService extends Service {
},
[imageData.data.buffer]
);
this.workerPendingCount++;
});
}
@ -147,7 +150,9 @@ export default class MediaOptimizationWorkerService extends Service {
this.workerInstalled = false;
this.worker.terminate();
this.worker = null;
this.workerDoneCount = 0;
}
this.workerPendingCount = 0;
}
registerMessageHandler() {
@ -163,6 +168,13 @@ export default class MediaOptimizationWorkerService extends Service {
this.promiseResolvers[e.data.fileId](optimizedFile);
this.workerDoneCount++;
this.workerPendingCount--;
if (this.workerDoneCount > 4 && this.workerPendingCount === 0) {
this.logIfDebug("Terminating worker to release memory in WASM.");
this.stopWorker();
}
break;
case "error":
this.logIfDebug(
@ -174,6 +186,7 @@ export default class MediaOptimizationWorkerService extends Service {
}
this.promiseResolvers[e.data.fileId]();
this.workerPendingCount--;
break;
case "installed":
this.logIfDebug("Worker installed.");