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:
parent
18de11f3a6
commit
fa4a462517
|
@ -672,6 +672,11 @@ export default Component.extend({
|
||||||
filename: data.files[data.index].name,
|
filename: data.files[data.index].name,
|
||||||
})}]()\n`
|
})}]()\n`
|
||||||
);
|
);
|
||||||
|
this.setProperties({
|
||||||
|
uploadProgress: 0,
|
||||||
|
isUploading: true,
|
||||||
|
isCancellable: false,
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.on("fileuploadprocessalways", (e, data) => {
|
.on("fileuploadprocessalways", (e, data) => {
|
||||||
this.appEvents.trigger(
|
this.appEvents.trigger(
|
||||||
|
@ -681,6 +686,11 @@ export default Component.extend({
|
||||||
})}]()\n`,
|
})}]()\n`,
|
||||||
""
|
""
|
||||||
);
|
);
|
||||||
|
this.setProperties({
|
||||||
|
uploadProgress: 0,
|
||||||
|
isUploading: false,
|
||||||
|
isCancellable: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$element.on("fileuploadpaste", (e) => {
|
$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
|
* Registers a pre-processor for file uploads
|
||||||
* See https://github.com/blueimp/jQuery-File-Upload/wiki/Options#file-processing-options
|
* 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
|
* 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 + "bootstrap-modal.js");
|
||||||
app.import(vendorJs + "jquery.ui.widget.js");
|
app.import(vendorJs + "jquery.ui.widget.js");
|
||||||
app.import(vendorJs + "jquery.fileupload.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 + "jquery.autoellipsis-1.0.10.js");
|
||||||
app.import(vendorJs + "show-html.js");
|
app.import(vendorJs + "show-html.js");
|
||||||
|
|
||||||
|
|
|
@ -21,6 +21,7 @@
|
||||||
//= require jquery.color.js
|
//= require jquery.color.js
|
||||||
//= require jquery.fileupload.js
|
//= require jquery.fileupload.js
|
||||||
//= require jquery.iframe-transport.js
|
//= require jquery.iframe-transport.js
|
||||||
|
//= require jquery.fileupload-process.js
|
||||||
//= require jquery.tagsinput.js
|
//= require jquery.tagsinput.js
|
||||||
//= require jquery.sortable.js
|
//= require jquery.sortable.js
|
||||||
//= require lodash.js
|
//= require lodash.js
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
//= require jquery.color.js
|
//= require jquery.color.js
|
||||||
//= require jquery.fileupload.js
|
//= require jquery.fileupload.js
|
||||||
//= require jquery.iframe-transport.js
|
//= require jquery.iframe-transport.js
|
||||||
|
//= require jquery.fileupload-process.js
|
||||||
//= require jquery.tagsinput.js
|
//= require jquery.tagsinput.js
|
||||||
//= require jquery.sortable.js
|
//= require jquery.sortable.js
|
||||||
//= require lodash.js
|
//= require lodash.js
|
||||||
|
|
|
@ -1824,6 +1824,12 @@ en:
|
||||||
|
|
||||||
strip_image_metadata: "Strip image metadata."
|
strip_image_metadata: "Strip image metadata."
|
||||||
|
|
||||||
|
composer_media_optimization_image_enabled: "Enables client-side media optimization of uploaded image files."
|
||||||
|
composer_media_optimization_image_kilobytes_optimization_threshold: "Minimum image file size to trigger client-side optimization"
|
||||||
|
composer_media_optimization_image_resize_dimensions_threshold: "Minimum image width to trigger client-side resize"
|
||||||
|
composer_media_optimization_image_resize_width_target: "Images with widths larger than `composer_media_optimization_image_dimensions_resize_threshold` will be resized to this width. Must be >= than `composer_media_optimization_image_dimensions_resize_threshold`."
|
||||||
|
composer_media_optimization_image_encode_quality: "JPEG encode quality used in the re-encode process."
|
||||||
|
|
||||||
min_ratio_to_crop: "Ratio used to crop tall images. Enter the result of width / height."
|
min_ratio_to_crop: "Ratio used to crop tall images. Enter the result of width / height."
|
||||||
|
|
||||||
simultaneous_uploads: "Maximum number of files that can be dragged & dropped in the composer"
|
simultaneous_uploads: "Maximum number of files that can be dragged & dropped in the composer"
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
# Additional MIME types that you'd like nginx to handle go in here
|
# Additional MIME types that you'd like nginx to handle go in here
|
||||||
types {
|
types {
|
||||||
text/csv csv;
|
text/csv csv;
|
||||||
|
application/wasm wasm;
|
||||||
}
|
}
|
||||||
|
|
||||||
upstream discourse {
|
upstream discourse {
|
||||||
|
@ -47,7 +48,7 @@ server {
|
||||||
gzip_vary on;
|
gzip_vary on;
|
||||||
gzip_min_length 1000;
|
gzip_min_length 1000;
|
||||||
gzip_comp_level 5;
|
gzip_comp_level 5;
|
||||||
gzip_types application/json text/css text/javascript application/x-javascript application/javascript image/svg+xml;
|
gzip_types application/json text/css text/javascript application/x-javascript application/javascript image/svg+xml application/wasm;
|
||||||
gzip_proxied any;
|
gzip_proxied any;
|
||||||
|
|
||||||
# Uncomment and configure this section for HTTPS support
|
# Uncomment and configure this section for HTTPS support
|
||||||
|
|
|
@ -1405,6 +1405,33 @@ files:
|
||||||
decompressed_backup_max_file_size_mb:
|
decompressed_backup_max_file_size_mb:
|
||||||
default: 100000
|
default: 100000
|
||||||
hidden: true
|
hidden: true
|
||||||
|
composer_media_optimization_image_enabled:
|
||||||
|
default: false
|
||||||
|
client: true
|
||||||
|
composer_media_optimization_image_kilobytes_optimization_threshold:
|
||||||
|
default: 1048576
|
||||||
|
client: true
|
||||||
|
composer_media_optimization_image_resize_dimensions_threshold:
|
||||||
|
default: 1920
|
||||||
|
client: true
|
||||||
|
composer_media_optimization_image_resize_width_target:
|
||||||
|
default: 1920
|
||||||
|
client: true
|
||||||
|
composer_media_optimization_image_resize_pre_multiply:
|
||||||
|
default: false
|
||||||
|
hidden: true
|
||||||
|
client: true
|
||||||
|
composer_media_optimization_image_resize_linear_rgb:
|
||||||
|
default: false
|
||||||
|
hidden: true
|
||||||
|
client: true
|
||||||
|
composer_media_optimization_image_encode_quality:
|
||||||
|
default: 75
|
||||||
|
client: true
|
||||||
|
composer_media_optimization_debug_mode:
|
||||||
|
default: false
|
||||||
|
client: true
|
||||||
|
hidden: true
|
||||||
|
|
||||||
trust:
|
trust:
|
||||||
default_trust_level:
|
default_trust_level:
|
||||||
|
|
|
@ -112,6 +112,8 @@ def dependencies
|
||||||
source: 'blueimp-file-upload/js/jquery.fileupload.js',
|
source: 'blueimp-file-upload/js/jquery.fileupload.js',
|
||||||
}, {
|
}, {
|
||||||
source: 'blueimp-file-upload/js/jquery.iframe-transport.js',
|
source: 'blueimp-file-upload/js/jquery.iframe-transport.js',
|
||||||
|
}, {
|
||||||
|
source: 'blueimp-file-upload/js/jquery.fileupload-process.js',
|
||||||
}, {
|
}, {
|
||||||
source: 'blueimp-file-upload/js/vendor/jquery.ui.widget.js',
|
source: 'blueimp-file-upload/js/vendor/jquery.ui.widget.js',
|
||||||
}, {
|
}, {
|
||||||
|
@ -191,6 +193,30 @@ def dependencies
|
||||||
{
|
{
|
||||||
source: 'sinon/pkg/sinon.js'
|
source: 'sinon/pkg/sinon.js'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: 'squoosh/codecs/mozjpeg/enc/mozjpeg_enc.js',
|
||||||
|
destination: 'squoosh',
|
||||||
|
public: true,
|
||||||
|
skip_versioning: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: 'squoosh/codecs/mozjpeg/enc/mozjpeg_enc.wasm',
|
||||||
|
destination: 'squoosh',
|
||||||
|
public: true,
|
||||||
|
skip_versioning: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: 'squoosh/codecs/resize/pkg/squoosh_resize.js',
|
||||||
|
destination: 'squoosh',
|
||||||
|
public: true,
|
||||||
|
skip_versioning: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: 'squoosh/codecs/resize/pkg/squoosh_resize_bg.wasm',
|
||||||
|
destination: 'squoosh',
|
||||||
|
public: true,
|
||||||
|
skip_versioning: true
|
||||||
|
},
|
||||||
|
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
|
@ -49,7 +49,8 @@
|
||||||
"puppeteer": "1.20",
|
"puppeteer": "1.20",
|
||||||
"qunit": "2.8.0",
|
"qunit": "2.8.0",
|
||||||
"route-recognizer": "^0.3.3",
|
"route-recognizer": "^0.3.3",
|
||||||
"sinon": "^9.0.2"
|
"sinon": "^9.0.2",
|
||||||
|
"squoosh": "discourse/squoosh#dc9649d"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"lodash": "4.17.21"
|
"lodash": "4.17.21"
|
||||||
|
|
|
@ -0,0 +1,166 @@
|
||||||
|
function resizeWithAspect(
|
||||||
|
input_width,
|
||||||
|
input_height,
|
||||||
|
target_width,
|
||||||
|
target_height,
|
||||||
|
) {
|
||||||
|
if (!target_width && !target_height) {
|
||||||
|
throw Error('Need to specify at least width or height when resizing');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target_width && target_height) {
|
||||||
|
return { width: target_width, height: target_height };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target_width) {
|
||||||
|
return {
|
||||||
|
width: Math.round((input_width / input_height) * target_height),
|
||||||
|
height: target_height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: target_width,
|
||||||
|
height: Math.round((input_height / input_width) * target_width),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function logIfDebug(message) {
|
||||||
|
if (DedicatedWorkerGlobalScope.debugMode) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optimize(imageData, fileName, width, height, settings) {
|
||||||
|
|
||||||
|
await loadLibs(settings);
|
||||||
|
|
||||||
|
const mozJpegDefaultOptions = {
|
||||||
|
quality: settings.encode_quality,
|
||||||
|
baseline: false,
|
||||||
|
arithmetic: false,
|
||||||
|
progressive: true,
|
||||||
|
optimize_coding: true,
|
||||||
|
smoothing: 0,
|
||||||
|
color_space: 3 /*YCbCr*/,
|
||||||
|
quant_table: 3,
|
||||||
|
trellis_multipass: false,
|
||||||
|
trellis_opt_zero: false,
|
||||||
|
trellis_opt_table: false,
|
||||||
|
trellis_loops: 1,
|
||||||
|
auto_subsample: true,
|
||||||
|
chroma_subsample: 2,
|
||||||
|
separate_chroma_quality: false,
|
||||||
|
chroma_quality: 75,
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialSize = imageData.byteLength;
|
||||||
|
logIfDebug(`Worker received imageData: ${initialSize}`);
|
||||||
|
|
||||||
|
let maybeResized;
|
||||||
|
|
||||||
|
// resize
|
||||||
|
if (width > settings.resize_threshold) {
|
||||||
|
try {
|
||||||
|
const target_dimensions = resizeWithAspect(width, height, settings.resize_target);
|
||||||
|
const resizeResult = self.codecs.resize(
|
||||||
|
new Uint8ClampedArray(imageData),
|
||||||
|
width, //in
|
||||||
|
height, //in
|
||||||
|
target_dimensions.width, //out
|
||||||
|
target_dimensions.height, //out
|
||||||
|
3, // 3 is lanczos
|
||||||
|
settings.resize_pre_multiply,
|
||||||
|
settings.resize_linear_rgb
|
||||||
|
);
|
||||||
|
maybeResized = new ImageData(
|
||||||
|
resizeResult,
|
||||||
|
target_dimensions.width,
|
||||||
|
target_dimensions.height,
|
||||||
|
).data;
|
||||||
|
width = target_dimensions.width;
|
||||||
|
height = target_dimensions.height;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Resize failed: ${error}`);
|
||||||
|
maybeResized = imageData;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logIfDebug(`Skipped resize: ${width} < ${settings.resize_threshold}`);
|
||||||
|
maybeResized = imageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// mozJPEG re-encode
|
||||||
|
const result = self.codecs.mozjpeg_enc.encode(
|
||||||
|
maybeResized,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
mozJpegDefaultOptions
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalSize = result.byteLength
|
||||||
|
logIfDebug(`Worker post reencode file: ${finalSize}`);
|
||||||
|
logIfDebug(`Reduction: ${(initialSize / finalSize).toFixed(1)}x speedup`);
|
||||||
|
|
||||||
|
let transferrable = Uint8Array.from(result).buffer; // decoded was allocated inside WASM so it **cannot** be transfered to another context, need to copy by value
|
||||||
|
|
||||||
|
return transferrable;
|
||||||
|
}
|
||||||
|
|
||||||
|
onmessage = async function (e) {
|
||||||
|
switch (e.data.type) {
|
||||||
|
case "compress":
|
||||||
|
try {
|
||||||
|
DedicatedWorkerGlobalScope.debugMode = e.data.settings.debug_mode;
|
||||||
|
let optimized = await optimize(
|
||||||
|
e.data.file,
|
||||||
|
e.data.fileName,
|
||||||
|
e.data.width,
|
||||||
|
e.data.height,
|
||||||
|
e.data.settings
|
||||||
|
);
|
||||||
|
postMessage(
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
file: optimized,
|
||||||
|
fileName: e.data.fileName
|
||||||
|
},
|
||||||
|
[optimized]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
postMessage({
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logIfDebug(`Sorry, we are out of ${e}.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadLibs(settings){
|
||||||
|
|
||||||
|
if (self.codecs) return;
|
||||||
|
|
||||||
|
importScripts(settings.mozjpeg_script);
|
||||||
|
importScripts(settings.resize_script);
|
||||||
|
|
||||||
|
let encoderModuleOverrides = {
|
||||||
|
locateFile: function(path, prefix) {
|
||||||
|
// if it's a mem init file, use a custom dir
|
||||||
|
if (path.endsWith(".wasm")) return settings.mozjpeg_wasm;
|
||||||
|
// otherwise, use the default, the prefix (JS file's dir) + the path
|
||||||
|
return prefix + path;
|
||||||
|
},
|
||||||
|
onRuntimeInitialized: function () {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const mozjpeg_enc_module = await mozjpeg_enc(encoderModuleOverrides);
|
||||||
|
|
||||||
|
const { resize } = wasm_bindgen;
|
||||||
|
await wasm_bindgen(settings.resize_wasm);
|
||||||
|
|
||||||
|
self.codecs = {mozjpeg_enc: mozjpeg_enc_module, resize: resize};
|
||||||
|
}
|
File diff suppressed because one or more lines are too long
Binary file not shown.
|
@ -0,0 +1,129 @@
|
||||||
|
let wasm_bindgen;
|
||||||
|
(function() {
|
||||||
|
const __exports = {};
|
||||||
|
let wasm;
|
||||||
|
|
||||||
|
let cachegetUint8Memory0 = null;
|
||||||
|
function getUint8Memory0() {
|
||||||
|
if (cachegetUint8Memory0 === null || cachegetUint8Memory0.buffer !== wasm.memory.buffer) {
|
||||||
|
cachegetUint8Memory0 = new Uint8Array(wasm.memory.buffer);
|
||||||
|
}
|
||||||
|
return cachegetUint8Memory0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let WASM_VECTOR_LEN = 0;
|
||||||
|
|
||||||
|
function passArray8ToWasm0(arg, malloc) {
|
||||||
|
const ptr = malloc(arg.length * 1);
|
||||||
|
getUint8Memory0().set(arg, ptr / 1);
|
||||||
|
WASM_VECTOR_LEN = arg.length;
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachegetInt32Memory0 = null;
|
||||||
|
function getInt32Memory0() {
|
||||||
|
if (cachegetInt32Memory0 === null || cachegetInt32Memory0.buffer !== wasm.memory.buffer) {
|
||||||
|
cachegetInt32Memory0 = new Int32Array(wasm.memory.buffer);
|
||||||
|
}
|
||||||
|
return cachegetInt32Memory0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachegetUint8ClampedMemory0 = null;
|
||||||
|
function getUint8ClampedMemory0() {
|
||||||
|
if (cachegetUint8ClampedMemory0 === null || cachegetUint8ClampedMemory0.buffer !== wasm.memory.buffer) {
|
||||||
|
cachegetUint8ClampedMemory0 = new Uint8ClampedArray(wasm.memory.buffer);
|
||||||
|
}
|
||||||
|
return cachegetUint8ClampedMemory0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClampedArrayU8FromWasm0(ptr, len) {
|
||||||
|
return getUint8ClampedMemory0().subarray(ptr / 1, ptr / 1 + len);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} input_image
|
||||||
|
* @param {number} input_width
|
||||||
|
* @param {number} input_height
|
||||||
|
* @param {number} output_width
|
||||||
|
* @param {number} output_height
|
||||||
|
* @param {number} typ_idx
|
||||||
|
* @param {boolean} premultiply
|
||||||
|
* @param {boolean} color_space_conversion
|
||||||
|
* @returns {Uint8ClampedArray}
|
||||||
|
*/
|
||||||
|
__exports.resize = function(input_image, input_width, input_height, output_width, output_height, typ_idx, premultiply, color_space_conversion) {
|
||||||
|
try {
|
||||||
|
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
||||||
|
var ptr0 = passArray8ToWasm0(input_image, wasm.__wbindgen_malloc);
|
||||||
|
var len0 = WASM_VECTOR_LEN;
|
||||||
|
wasm.resize(retptr, ptr0, len0, input_width, input_height, output_width, output_height, typ_idx, premultiply, color_space_conversion);
|
||||||
|
var r0 = getInt32Memory0()[retptr / 4 + 0];
|
||||||
|
var r1 = getInt32Memory0()[retptr / 4 + 1];
|
||||||
|
var v1 = getClampedArrayU8FromWasm0(r0, r1).slice();
|
||||||
|
wasm.__wbindgen_free(r0, r1 * 1);
|
||||||
|
return v1;
|
||||||
|
} finally {
|
||||||
|
wasm.__wbindgen_add_to_stack_pointer(16);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function load(module, imports) {
|
||||||
|
if (typeof Response === 'function' && module instanceof Response) {
|
||||||
|
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
||||||
|
try {
|
||||||
|
return await WebAssembly.instantiateStreaming(module, imports);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (module.headers.get('Content-Type') != 'application/wasm') {
|
||||||
|
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await module.arrayBuffer();
|
||||||
|
return await WebAssembly.instantiate(bytes, imports);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const instance = await WebAssembly.instantiate(module, imports);
|
||||||
|
|
||||||
|
if (instance instanceof WebAssembly.Instance) {
|
||||||
|
return { instance, module };
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init(input) {
|
||||||
|
if (typeof input === 'undefined') {
|
||||||
|
let src;
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
src = location.href;
|
||||||
|
} else {
|
||||||
|
src = document.currentScript.src;
|
||||||
|
}
|
||||||
|
input = src.replace(/\.js$/, '_bg.wasm');
|
||||||
|
}
|
||||||
|
const imports = {};
|
||||||
|
|
||||||
|
|
||||||
|
if (typeof input === 'string' || (typeof Request === 'function' && input instanceof Request) || (typeof URL === 'function' && input instanceof URL)) {
|
||||||
|
input = fetch(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const { instance, module } = await load(await input, imports);
|
||||||
|
|
||||||
|
wasm = instance.exports;
|
||||||
|
init.__wbindgen_wasm_module = module;
|
||||||
|
|
||||||
|
return wasm;
|
||||||
|
}
|
||||||
|
|
||||||
|
wasm_bindgen = Object.assign(init, __exports);
|
||||||
|
|
||||||
|
})();
|
Binary file not shown.
|
@ -0,0 +1,169 @@
|
||||||
|
/*
|
||||||
|
* jQuery File Upload Processing Plugin
|
||||||
|
* https://github.com/blueimp/jQuery-File-Upload
|
||||||
|
*
|
||||||
|
* Copyright 2012, Sebastian Tschan
|
||||||
|
* https://blueimp.net
|
||||||
|
*
|
||||||
|
* Licensed under the MIT license:
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* global define, require */
|
||||||
|
|
||||||
|
(function (factory) {
|
||||||
|
'use strict';
|
||||||
|
if (typeof define === 'function' && define.amd) {
|
||||||
|
// Register as an anonymous AMD module:
|
||||||
|
define(['jquery', './jquery.fileupload'], factory);
|
||||||
|
} else if (typeof exports === 'object') {
|
||||||
|
// Node/CommonJS:
|
||||||
|
factory(require('jquery'), require('./jquery.fileupload'));
|
||||||
|
} else {
|
||||||
|
// Browser globals:
|
||||||
|
factory(window.jQuery);
|
||||||
|
}
|
||||||
|
})(function ($) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var originalAdd = $.blueimp.fileupload.prototype.options.add;
|
||||||
|
|
||||||
|
// The File Upload Processing plugin extends the fileupload widget
|
||||||
|
// with file processing functionality:
|
||||||
|
$.widget('blueimp.fileupload', $.blueimp.fileupload, {
|
||||||
|
options: {
|
||||||
|
// The list of processing actions:
|
||||||
|
processQueue: [
|
||||||
|
/*
|
||||||
|
{
|
||||||
|
action: 'log',
|
||||||
|
type: 'debug'
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
],
|
||||||
|
add: function (e, data) {
|
||||||
|
var $this = $(this);
|
||||||
|
data.process(function () {
|
||||||
|
return $this.fileupload('process', data);
|
||||||
|
});
|
||||||
|
originalAdd.call(this, e, data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
processActions: {
|
||||||
|
/*
|
||||||
|
log: function (data, options) {
|
||||||
|
console[options.type](
|
||||||
|
'Processing "' + data.files[data.index].name + '"'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
},
|
||||||
|
|
||||||
|
_processFile: function (data, originalData) {
|
||||||
|
var that = this,
|
||||||
|
// eslint-disable-next-line new-cap
|
||||||
|
dfd = $.Deferred().resolveWith(that, [data]),
|
||||||
|
chain = dfd.promise();
|
||||||
|
this._trigger('process', null, data);
|
||||||
|
$.each(data.processQueue, function (i, settings) {
|
||||||
|
var func = function (data) {
|
||||||
|
if (originalData.errorThrown) {
|
||||||
|
// eslint-disable-next-line new-cap
|
||||||
|
return $.Deferred().rejectWith(that, [originalData]).promise();
|
||||||
|
}
|
||||||
|
return that.processActions[settings.action].call(
|
||||||
|
that,
|
||||||
|
data,
|
||||||
|
settings
|
||||||
|
);
|
||||||
|
};
|
||||||
|
chain = chain.then(func, settings.always && func);
|
||||||
|
});
|
||||||
|
chain
|
||||||
|
.done(function () {
|
||||||
|
that._trigger('processdone', null, data);
|
||||||
|
that._trigger('processalways', null, data);
|
||||||
|
})
|
||||||
|
.fail(function () {
|
||||||
|
that._trigger('processfail', null, data);
|
||||||
|
that._trigger('processalways', null, data);
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Replaces the settings of each processQueue item that
|
||||||
|
// are strings starting with an "@", using the remaining
|
||||||
|
// substring as key for the option map,
|
||||||
|
// e.g. "@autoUpload" is replaced with options.autoUpload:
|
||||||
|
_transformProcessQueue: function (options) {
|
||||||
|
var processQueue = [];
|
||||||
|
$.each(options.processQueue, function () {
|
||||||
|
var settings = {},
|
||||||
|
action = this.action,
|
||||||
|
prefix = this.prefix === true ? action : this.prefix;
|
||||||
|
$.each(this, function (key, value) {
|
||||||
|
if ($.type(value) === 'string' && value.charAt(0) === '@') {
|
||||||
|
settings[key] =
|
||||||
|
options[
|
||||||
|
value.slice(1) ||
|
||||||
|
(prefix
|
||||||
|
? prefix + key.charAt(0).toUpperCase() + key.slice(1)
|
||||||
|
: key)
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
settings[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
processQueue.push(settings);
|
||||||
|
});
|
||||||
|
options.processQueue = processQueue;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Returns the number of files currently in the processsing queue:
|
||||||
|
processing: function () {
|
||||||
|
return this._processing;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Processes the files given as files property of the data parameter,
|
||||||
|
// returns a Promise object that allows to bind callbacks:
|
||||||
|
process: function (data) {
|
||||||
|
var that = this,
|
||||||
|
options = $.extend({}, this.options, data);
|
||||||
|
if (options.processQueue && options.processQueue.length) {
|
||||||
|
this._transformProcessQueue(options);
|
||||||
|
if (this._processing === 0) {
|
||||||
|
this._trigger('processstart');
|
||||||
|
}
|
||||||
|
$.each(data.files, function (index) {
|
||||||
|
var opts = index ? $.extend({}, options) : options,
|
||||||
|
func = function () {
|
||||||
|
if (data.errorThrown) {
|
||||||
|
// eslint-disable-next-line new-cap
|
||||||
|
return $.Deferred().rejectWith(that, [data]).promise();
|
||||||
|
}
|
||||||
|
return that._processFile(opts, data);
|
||||||
|
};
|
||||||
|
opts.index = index;
|
||||||
|
that._processing += 1;
|
||||||
|
that._processingQueue = that._processingQueue
|
||||||
|
.then(func, func)
|
||||||
|
.always(function () {
|
||||||
|
that._processing -= 1;
|
||||||
|
if (that._processing === 0) {
|
||||||
|
that._trigger('processstop');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this._processingQueue;
|
||||||
|
},
|
||||||
|
|
||||||
|
_create: function () {
|
||||||
|
this._super();
|
||||||
|
this._processing = 0;
|
||||||
|
// eslint-disable-next-line new-cap
|
||||||
|
this._processingQueue = $.Deferred().resolveWith(this).promise();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
13
yarn.lock
13
yarn.lock
|
@ -2158,7 +2158,7 @@ lodash.get@^4.4.2:
|
||||||
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
|
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
|
||||||
integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=
|
integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=
|
||||||
|
|
||||||
lodash@4.17.21, lodash@^4.17.14, lodash@^4.17.19:
|
lodash@4.17.15, lodash@4.17.21, lodash@^4.17.14, lodash@^4.17.19:
|
||||||
version "4.17.21"
|
version "4.17.21"
|
||||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
||||||
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||||
|
@ -3091,6 +3091,12 @@ sprintf-js@~1.0.2:
|
||||||
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
|
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
|
||||||
integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=
|
integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=
|
||||||
|
|
||||||
|
squoosh@discourse/squoosh#dc9649d:
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://codeload.github.com/discourse/squoosh/tar.gz/dc9649d0a4d396d1251c22291b17d99f1716da44"
|
||||||
|
dependencies:
|
||||||
|
wasm-feature-detect "^1.2.9"
|
||||||
|
|
||||||
static-extend@^0.1.1:
|
static-extend@^0.1.1:
|
||||||
version "0.1.2"
|
version "0.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"
|
resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"
|
||||||
|
@ -3409,6 +3415,11 @@ walker@~1.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
makeerror "1.0.x"
|
makeerror "1.0.x"
|
||||||
|
|
||||||
|
wasm-feature-detect@^1.2.11:
|
||||||
|
version "1.2.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/wasm-feature-detect/-/wasm-feature-detect-1.2.11.tgz#e21992fd1f1d41a47490e392a5893cb39d81e29e"
|
||||||
|
integrity sha512-HUqwaodrQGaZgz1lZaNioIkog9tkeEJjrM3eq4aUL04whXOVDRc/o2EGb/8kV0QX411iAYWEqq7fMBmJ6dKS6w==
|
||||||
|
|
||||||
wcwidth@^1.0.1:
|
wcwidth@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8"
|
resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8"
|
||||||
|
|
Loading…
Reference in New Issue