mirror of
https://github.com/discourse/discourse.git
synced 2025-06-04 23:14:44 +08:00
FEATURE: Optimize images before upload (#13432)
Integrates [mozJPEG](https://github.com/mozilla/mozjpeg) and [Resize](https://github.com/PistonDevelopers/resize) using WebAssembly to optimize user uploads in the composer on the client-side. NPM libraries are sourced from our [Squoosh fork](https://github.com/discourse/squoosh/tree/discourse), which was needed because we have an older asset pipeline.
This commit is contained in:

committed by
GitHub

parent
18de11f3a6
commit
fa4a462517
@ -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) => {
|
||||
|
@ -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),
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
@ -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 };
|
||||
}
|
@ -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
|
||||
*
|
||||
|
@ -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,
|
||||
resize_target: this.siteSettings
|
||||
.composer_media_optimization_image_resize_width_target,
|
||||
resize_pre_multiply: this.siteSettings
|
||||
.composer_media_optimization_image_resize_pre_multiply,
|
||||
resize_linear_rgb: this.siteSettings
|
||||
.composer_media_optimization_image_resize_linear_rgb,
|
||||
encode_quality: this.siteSettings
|
||||
.composer_media_optimization_image_encode_quality,
|
||||
debug_mode: this.siteSettings
|
||||
.composer_media_optimization_debug_mode,
|
||||
},
|
||||
},
|
||||
[imageData.data.buffer]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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}`, {
|
||||
type: "image/jpeg",
|
||||
});
|
||||
this.logIfDebug(
|
||||
`Finished optimization of ${optimizedFile.name} new size: ${optimizedFile.size}.`
|
||||
);
|
||||
let data = this.currentComposerUploadData;
|
||||
data.files[data.index] = optimizedFile;
|
||||
this.currentPromiseResolver(data);
|
||||
break;
|
||||
case "error":
|
||||
this.stopWorker();
|
||||
this.currentPromiseResolver(this.currentComposerUploadData);
|
||||
break;
|
||||
default:
|
||||
this.logIfDebug(`Sorry, we are out of ${e}.`);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
@ -34,6 +34,7 @@ module.exports = function (defaults) {
|
||||
app.import(vendorJs + "bootstrap-modal.js");
|
||||
app.import(vendorJs + "jquery.ui.widget.js");
|
||||
app.import(vendorJs + "jquery.fileupload.js");
|
||||
app.import(vendorJs + "jquery.fileupload-process.js");
|
||||
app.import(vendorJs + "jquery.autoellipsis-1.0.10.js");
|
||||
app.import(vendorJs + "show-html.js");
|
||||
|
||||
|
@ -21,6 +21,7 @@
|
||||
//= require jquery.color.js
|
||||
//= require jquery.fileupload.js
|
||||
//= require jquery.iframe-transport.js
|
||||
//= require jquery.fileupload-process.js
|
||||
//= require jquery.tagsinput.js
|
||||
//= require jquery.sortable.js
|
||||
//= require lodash.js
|
||||
|
@ -14,6 +14,7 @@
|
||||
//= require jquery.color.js
|
||||
//= require jquery.fileupload.js
|
||||
//= require jquery.iframe-transport.js
|
||||
//= require jquery.fileupload-process.js
|
||||
//= require jquery.tagsinput.js
|
||||
//= require jquery.sortable.js
|
||||
//= require lodash.js
|
||||
|
Reference in New Issue
Block a user