diff --git a/addons/mass_mailing/static/src/js/mass_mailing_widget.js b/addons/mass_mailing/static/src/js/mass_mailing_widget.js
index 034da3f20dd4fbdd6a42291f6ead786126394eed..a5cba75e32f003f8db2eb2181f092ecd47156727 100644
--- a/addons/mass_mailing/static/src/js/mass_mailing_widget.js
+++ b/addons/mass_mailing/static/src/js/mass_mailing_widget.js
@@ -58,7 +58,7 @@ var MassMailingFieldHtml = FieldHtml.extend({
 
         var $editable = this.wysiwyg.getEditable();
 
-        return this.wysiwyg.saveCroppedImages(this.$content).then(function () {
+        return this.wysiwyg.saveModifiedImages(this.$content).then(function () {
             return self.wysiwyg.save().then(function (result) {
                 self._isDirty = result.isDirty;
 
diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py
index c037e783b6115b27d1c9827710c81546ed488ef9..2b50098453401351517c068ef54f4fa0c40ca7eb 100644
--- a/addons/web_editor/controllers/main.py
+++ b/addons/web_editor/controllers/main.py
@@ -168,31 +168,6 @@ class Web_Editor(http.Controller):
         attachment = self._attachment_create(url=url, res_id=res_id, res_model=res_model)
         return attachment._get_media_info()
 
-    @http.route('/web_editor/attachment/<model("ir.attachment"):attachment>/update', type='json', auth='user', methods=['POST'], website=True)
-    def attachment_update(self, attachment, name=None, width=0, height=0, quality=0, copy=False, **kwargs):
-        if attachment.type == 'url':
-            raise UserError(_("You cannot change the quality, the width or the name of an URL attachment."))
-        if copy:
-            original = attachment
-            attachment = attachment.copy()
-            attachment.original_id = original
-            # Uniquify url by adding a path segment with the id before the name
-            if attachment.url:
-                url_fragments = attachment.url.split('/')
-                url_fragments.insert(-1, str(attachment.id))
-                attachment.url = '/'.join(url_fragments)
-        elif attachment.original_id:
-            attachment.datas = attachment.original_id.datas
-        data = {}
-        if name:
-            data['name'] = name
-        try:
-            data['datas'] = tools.image_process(attachment.datas, size=(width, height), quality=quality)
-        except UserError:
-            pass  # not an image
-        attachment.write(data)
-        return attachment._get_media_info()
-
     @http.route('/web_editor/attachment/remove', type='json', auth='user', website=True)
     def remove(self, ids, **kwargs):
         """ Removes a web-based image attachment if it is used by no view (template)
@@ -226,12 +201,10 @@ class Web_Editor(http.Controller):
 
     @http.route('/web_editor/get_image_info', type='json', auth='user', website=True)
     def get_image_info(self, src=''):
-        """This route is used from image crop widget to get image info.
-        It is used to display the original image when we crop a previously
-        cropped image.
+        """This route is used to determine the original of an attachment so that
+        it can be used as a base to modify it again (crop/optimization/filters).
         """
         id_match = re.search('^/web/image/([^/?]+)', src)
-        attachment = []
         if id_match:
             url_segment = id_match.group(1)
             number_match = re.match('^(\d+)', url_segment)
@@ -250,10 +223,10 @@ class Web_Editor(http.Controller):
             }
         return {
             'attachment': attachment.read(['id'])[0],
-            'original': (attachment.original_id or attachment).read(['id', 'image_src', 'mimetype', 'name'])[0],
+            'original': (attachment.original_id or attachment).read(['id', 'image_src', 'mimetype'])[0],
         }
 
-    def _attachment_create(self, name='', data=False, url=False, res_id=False, res_model='ir.ui.view', mimetype=None, original_id=None):
+    def _attachment_create(self, name='', data=False, url=False, res_id=False, res_model='ir.ui.view'):
         """Create and return a new attachment."""
         if not name and url:
             name = url.split("/").pop()
@@ -268,8 +241,6 @@ class Web_Editor(http.Controller):
             'public': res_model == 'ir.ui.view',
             'res_id': res_id,
             'res_model': res_model,
-            'mimetype': mimetype,
-            'original_id': original_id,
         }
 
         if data:
@@ -485,12 +456,37 @@ class Web_Editor(http.Controller):
             View = View.sudo()
         return View._render_template(xmlid, {k: values[k] for k in values if k in trusted_value_keys})
 
-    @http.route("/web_editor/crop_attachment", type="json", auth="user", website=True)
-    def crop_attachment(self, res_model=None, res_id=None, name=None, data=None, mimetype=None, original_id=None):
+    @http.route('/web_editor/modify_image/<model("ir.attachment"):attachment>', type="json", auth="user", website=True)
+    def modify_image(self, attachment, res_model=None, res_id=None, name=None, data=None, original_id=None):
         """
-        Creates a cropped attachment and returns its image_src to be inserted into the DOM
+        Creates a modified copy of an attachment and returns its image_src to be
+        inserted into the DOM.
         """
-        attachment = self._attachment_create(res_model=res_model, res_id=res_id, name=name, data=data, mimetype=mimetype, original_id=original_id)
+        fields = {
+            'original_id': attachment.id,
+            'datas': data,
+            'type': 'binary',
+            'res_model': res_model or 'ir.ui.view',
+        }
+        if fields['res_model'] == 'ir.ui.view':
+            fields['res_id'] = 0
+        elif res_id:
+            fields['res_id'] = res_id
+        if name:
+            fields['name'] = name
+        attachment = attachment.copy(fields)
+        if attachment.url:
+            # Don't keep url if modifying static attachment because static images
+            # are only served from disk and don't fallback to attachments.
+            if re.match(r'^/\w+/static/', attachment.url):
+                attachment.url = None
+            # Uniquify url by adding a path segment with the id before the name.
+            # This allows us to keep the unsplash url format so it still reacts
+            # to the unsplash beacon.
+            else:
+                url_fragments = attachment.url.split('/')
+                url_fragments.insert(-1, str(attachment.id))
+                attachment.url = '/'.join(url_fragments)
         if attachment.public:
             return attachment.image_src
         attachment.generate_access_token()
diff --git a/addons/web_editor/static/src/js/backend/field_html.js b/addons/web_editor/static/src/js/backend/field_html.js
index 82169c53fbac0212ef4ec36bc4cb8c30330da13b..215f7a698eaebff8bf0aeff63ba9eb2b80271608 100644
--- a/addons/web_editor/static/src/js/backend/field_html.js
+++ b/addons/web_editor/static/src/js/backend/field_html.js
@@ -107,7 +107,7 @@ var FieldHtml = basic_fields.DebouncedField.extend(TranslatableFieldMixin, {
             return this._super();
         }
         var _super = this._super.bind(this);
-        return this.wysiwyg.saveCroppedImages(this.$content).then(function () {
+        return this.wysiwyg.saveModifiedImages(this.$content).then(function () {
             return self.wysiwyg.save().then(function (result) {
                 self._isDirty = result.isDirty;
                 _super();
diff --git a/addons/web_editor/static/src/js/editor/editor.js b/addons/web_editor/static/src/js/editor/editor.js
index 5cd8113e7f97660242ba6ec7bbaae3e5bff117ef..1d6f34aa4cf46ea934e6ad04effadac30e954c14 100644
--- a/addons/web_editor/static/src/js/editor/editor.js
+++ b/addons/web_editor/static/src/js/editor/editor.js
@@ -171,7 +171,7 @@ var EditorMenuBar = Widget.extend({
         if (this.snippetsMenu) {
             await this.snippetsMenu.cleanForSave();
         }
-        await this.getParent().saveCroppedImages(this.rte.editable());
+        await this.getParent().saveModifiedImages(this.rte.editable());
         await this.rte.save();
 
         if (reload !== false) {
diff --git a/addons/web_editor/static/src/js/editor/image_processing.js b/addons/web_editor/static/src/js/editor/image_processing.js
new file mode 100644
index 0000000000000000000000000000000000000000..e775dfde42102a51342bef3c089d0db6bd27b773
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/image_processing.js
@@ -0,0 +1,133 @@
+odoo.define('web_editor.image_processing', function (require) {
+'use strict';
+
+// Fields returned by cropperjs 'getData' method, also need to be passed when
+// initializing the cropper to reuse the previous crop.
+const cropperDataFields = ['x', 'y', 'width', 'height', 'rotate', 'scaleX', 'scaleY'];
+const modifierFields = [
+    'filter',
+    'quality',
+    'mimetype',
+    'originalId',
+    'originalSrc',
+    'resizeWidth',
+    'aspectRatio',
+];
+/**
+ * Applies data-attributes modifications to an img tag and returns a dataURL
+ * containing the result. This function does not modify the original image.
+ *
+ * @param {HTMLImageElement} img the image to which modifications are applied
+ * @returns {string} dataURL of the image with the applied modifications
+ */
+async function applyModifications(img) {
+    const data = Object.assign({
+        filter: '#0000',
+        quality: '95',
+    }, img.dataset);
+    let {width, height, resizeWidth, quality, filter, mimetype, originalSrc} = data;
+    [width, height, resizeWidth] = [width, height, resizeWidth].map(s => parseFloat(s));
+    quality = parseInt(quality);
+
+    // Crop
+    const container = document.createElement('div');
+    const original = await loadImage(originalSrc);
+    container.appendChild(original);
+    await activateCropper(original, 0, data);
+    const croppedImg = $(original).cropper('getCroppedCanvas', {width, height});
+    $(original).cropper('destroy');
+
+    // Width
+    const result = document.createElement('canvas');
+    result.width = resizeWidth || croppedImg.width;
+    result.height = croppedImg.height * result.width / croppedImg.width;
+    const ctx = result.getContext('2d');
+    ctx.drawImage(croppedImg, 0, 0, croppedImg.width, croppedImg.height, 0, 0, result.width, result.height);
+
+    // Color filter
+    ctx.fillStyle = filter || '#0000';
+    ctx.fillRect(0, 0, result.width, result.height);
+
+    // Quality
+    return result.toDataURL(mimetype, quality / 100);
+}
+
+/**
+ * Loads an src into an HTMLImageElement.
+ *
+ * @param {String} src URL of the image to load
+ * @param {HTMLImageElement} [img] img element in which to load the image
+ * @returns {Promise<HTMLImageElement>} Promise that resolves to the loaded img
+ */
+function loadImage(src, img = new Image()) {
+    return new Promise((resolve, reject) => {
+        img.addEventListener('load', () => resolve(img), {once: true});
+        img.addEventListener('error', reject, {once: true});
+        img.src = src;
+    });
+}
+
+// Because cropperjs acquires images through XHRs on the image src and we don't
+// want to load big images over the network many times when adjusting quality
+// and filter, we create a local cache of the images using object URLs.
+const imageCache = new Map();
+/**
+ * Activates the cropper on a given image.
+ *
+ * @param {jQuery} $image the image on which to activate the cropper
+ * @param {Number} aspectRatio the aspectRatio of the crop box
+ * @param {DOMStringMap} dataset dataset containing the cropperDataFields
+ */
+async function activateCropper(image, aspectRatio, dataset) {
+    const src = image.getAttribute('src');
+    if (!imageCache.has(src)) {
+        const res = await fetch(src);
+        imageCache.set(src, URL.createObjectURL(await res.blob()));
+    }
+    image.src = imageCache.get(src);
+    $(image).cropper({
+        viewMode: 2,
+        dragMode: 'move',
+        autoCropArea: 1.0,
+        aspectRatio: aspectRatio,
+        data: _.mapObject(_.pick(dataset, ...cropperDataFields), value => parseFloat(value)),
+        // Can't use 0 because it's falsy and cropperjs will then use its defaults (200x100)
+        minContainerWidth: 1,
+        minContainerHeight: 1,
+    });
+    return new Promise(resolve => image.addEventListener('ready', resolve, {once: true}));
+}
+/**
+ * Marks an <img> with its attachment data (originalId, originalSrc, mimetype)
+ *
+ * @param {HTMLImageElement} img the image whose attachment data should be found
+ * @param {Function} rpc a function that can be used to make the RPC. Typically
+ *   this would be passed as 'this._rpc.bind(this)' from widgets.
+ */
+async function loadImageInfo(img, rpc) {
+    // If there is a marked originalSrc, the data is already loaded.
+    if (img.dataset.originalSrc) {
+        return;
+    }
+
+    const {original} = await rpc({
+        route: '/web_editor/get_image_info',
+        params: {src: img.getAttribute('src').split(/[?#]/)[0]},
+    });
+    // Check that url is local.
+    if (original && new URL(original.image_src, window.location.origin).origin === window.location.origin) {
+        img.dataset.originalId = original.id;
+        img.dataset.originalSrc = original.image_src;
+        img.dataset.mimetype = original.mimetype;
+    }
+}
+
+return {
+    applyModifications,
+    cropperDataFields,
+    activateCropper,
+    loadImageInfo,
+    loadImage,
+    removeOnImageChangeAttrs: [...cropperDataFields, ...modifierFields, 'aspectRatio'],
+};
+});
diff --git a/addons/web_editor/static/src/js/editor/rte.js b/addons/web_editor/static/src/js/editor/rte.js
index bf5c0db613d655b0878a3c619dae1150e1b9c48a..71217563ca751cd95199f0459b67749b22930840 100644
--- a/addons/web_editor/static/src/js/editor/rte.js
+++ b/addons/web_editor/static/src/js/editor/rte.js
@@ -458,6 +458,10 @@ var RTEWidget = Widget.extend({
 
         if (initialActiveElement && initialActiveElement !== document.activeElement) {
             initialActiveElement.focus();
+            // Range inputs don't support selection
+            if (initialActiveElement.matches('input[type=range]')) {
+                return;
+            }
             try {
                 initialActiveElement.selectionStart = initialSelectionStart;
                 initialActiveElement.selectionEnd = initialSelectionEnd;
diff --git a/addons/web_editor/static/src/js/editor/rte.summernote.js b/addons/web_editor/static/src/js/editor/rte.summernote.js
index 92d8cefe0bc2862bb3518d0aa72f412fcd263139..229acc0616cacc1c8fd2902c67d084767114a367 100644
--- a/addons/web_editor/static/src/js/editor/rte.summernote.js
+++ b/addons/web_editor/static/src/js/editor/rte.summernote.js
@@ -12,7 +12,6 @@ const topBus = window.top.odoo.__DEBUG__.services['web.core'].bus;
 const {ColorpickerWidget} = require('web.Colorpicker');
 var ColorPaletteWidget = require('web_editor.ColorPalette').ColorPaletteWidget;
 var mixins = require('web.mixins');
-const session = require('web.session');
 var fonts = require('wysiwyg.fonts');
 var rte = require('web_editor.rte');
 var ServicesMixin = require('web.ServicesMixin');
@@ -1092,32 +1091,36 @@ var SummernoteManager = Class.extend(mixins.EventDispatcherMixin, ServicesMixin,
     },
 
     /**
-     * Create/Update cropped attachments.
+     * Create modified image attachments.
      *
      * @param {jQuery} $editable
      * @returns {Promise}
      */
-    saveCroppedImages: function ($editable) {
-        var defs = _.map($editable.find('.o_cropped_img_to_save'), async croppedImg => {
-            croppedImg.classList.remove('o_cropped_img_to_save');
-            // Cropping an image always creates a copy of the original, even if
-            // it was cropped previously, as the other cropped image may be used
-            // elsewhere if the snippet was duplicated or was saved as a custom one.
-            const croppedAttachmentSrc = await this._rpc({
-                route: '/web_editor/crop_attachment',
-                params: {
-                    res_model: croppedImg.dataset.resModel,
-                    res_id: parseInt(croppedImg.dataset.resId),
-                    name: croppedImg.dataset.originalName + '.crop',
-                    data: croppedImg.getAttribute('src').split(',')[1],
-                    mimetype: croppedImg.dataset.mimetype,
-                    original_id: parseInt(croppedImg.dataset.originalId),
-                },
-            });
-            croppedImg.setAttribute('src', croppedAttachmentSrc);
-            weWidgets.ImageCropWidget.prototype.removeOnSaveAttributes.forEach(attr => {
-                delete croppedImg.dataset[attr];
+    saveModifiedImages: function ($editable) {
+        const defs = _.map($editable, async editableEl => {
+            const {oeModel: resModel, oeId: resId} = editableEl.dataset;
+            const proms = [...editableEl.querySelectorAll('.o_modified_image_to_save')].map(async el => {
+                const isBackground = !el.matches('img');
+                el.classList.remove('o_modified_image_to_save');
+                // Modifying an image always creates a copy of the original, even if
+                // it was modified previously, as the other modified image may be used
+                // elsewhere if the snippet was duplicated or was saved as a custom one.
+                const newAttachmentSrc = await this._rpc({
+                    route: `/web_editor/modify_image/${el.dataset.originalId}`,
+                    params: {
+                        res_model: resModel,
+                        res_id: parseInt(resId),
+                        data: (isBackground ? el.dataset.bgSrc : el.getAttribute('src')).split(',')[1],
+                    },
+                });
+                if (isBackground) {
+                    $(el).css('background-image', `url('${newAttachmentSrc}')`);
+                    delete el.dataset.bgSrc;
+                } else {
+                    el.setAttribute('src', newAttachmentSrc);
+                }
             });
+            return Promise.all(proms);
         });
         return Promise.all(defs);
     },
@@ -1160,13 +1163,8 @@ var SummernoteManager = Class.extend(mixins.EventDispatcherMixin, ServicesMixin,
             return;
         }
         data.__alreadyDone = true;
-        new weWidgets.ImageCropWidget(this,
-            _.extend({
-                res_model: data.$editable.data('oe-model'),
-                res_id: data.$editable.data('oe-id'),
-            }, data.options || {}),
-            data.media
-        ).appendTo(data.$editable);
+        new weWidgets.ImageCropWidget(this, data.media)
+            .appendTo(data.$editable);
     },
     /**
      * Called when a demand to open a link dialog is received on the bus.
diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js
index 06263b231824e64a4dfa00c8acfb2d227dcbb202..20c73e39e99f37bec5981fc711a64b42ea039cf1 100644
--- a/addons/web_editor/static/src/js/editor/snippets.editor.js
+++ b/addons/web_editor/static/src/js/editor/snippets.editor.js
@@ -219,6 +219,9 @@ var SnippetEditor = Widget.extend({
         if (this.$target.data('name') !== undefined) {
             return this.$target.data('name');
         }
+        if (this.$target.is('img')) {
+            return _t("Image");
+        }
         if (this.$target.parent('.row').length) {
             return _t("Column");
         }
diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js
index 1c603eff10cbfd5c43d1002d975c22166f2499a1..ae40fd76a635b59657ee4166f95161ab107b2898 100644
--- a/addons/web_editor/static/src/js/editor/snippets.options.js
+++ b/addons/web_editor/static/src/js/editor/snippets.options.js
@@ -9,6 +9,12 @@ var Widget = require('web.Widget');
 var ColorPaletteWidget = require('web_editor.ColorPalette').ColorPaletteWidget;
 const weUtils = require('web_editor.utils');
 var weWidgets = require('wysiwyg.widgets');
+const {
+    loadImage,
+    loadImageInfo,
+    applyModifications,
+    removeOnImageChangeAttrs,
+} = require('web_editor.image_processing');
 
 var qweb = core.qweb;
 var _t = core._t;
@@ -1424,6 +1430,49 @@ const DatetimePickerUserValueWidget = InputUserValueWidget.extend({
     },
 });
 
+const RangeUserValueWidget = UserValueWidget.extend({
+    tagName: 'we-range',
+    events: {
+        'change input': '_onInputChange',
+    },
+
+    /**
+     * @override
+     */
+    async start() {
+        await this._super(...arguments);
+        this.input = document.createElement('input');
+        this.input.type = "range";
+        this.input.className = "custom-range";
+        this.containerEl.appendChild(this.input);
+    },
+
+    //--------------------------------------------------------------------------
+    // Public
+    //--------------------------------------------------------------------------
+
+    /**
+     * @override
+     */
+    setValue(value, methodName) {
+        this.input.value = value;
+        return this._super(...arguments);
+    },
+
+    //--------------------------------------------------------------------------
+    // Handlers
+    //--------------------------------------------------------------------------
+
+    /**
+     * @private
+     */
+    _onInputChange(ev) {
+        this._value = ev.target.value;
+        this._onUserValueChange(ev);
+    },
+});
+
+
 const userValueWidgetsRegistry = {
     'we-button': ButtonUserValueWidget,
     'we-checkbox': CheckboxUserValueWidget,
@@ -1433,6 +1482,7 @@ const userValueWidgetsRegistry = {
     'we-colorpicker': ColorpickerUserValueWidget,
     'we-datetimepicker': DatetimePickerUserValueWidget,
     'we-imagepicker': ImagepickerUserValueWidget,
+    'we-range': RangeUserValueWidget,
 };
 
 /**
@@ -2662,6 +2712,385 @@ registry['sizing_y'] = registry.sizing.extend({
     },
 });
 
+/*
+ * Abstract option to be extended by the ImageOptimize and BackgroundOptimize
+ * options that handles all the common parts.
+ */
+const ImageHandlerOption = SnippetOptionWidget.extend({
+
+    /**
+     * @override
+     */
+    async willStart() {
+        const _super = this._super.bind(this);
+        await this._loadImageInfo();
+        // Make sure image is loaded because we need its naturalWidth to render XML
+        const img = this._getImg();
+        await new Promise((resolve, reject) => {
+            if (img.complete) {
+                return resolve();
+            }
+            img.addEventListener('load', resolve, {once: true});
+            img.addEventListener('error', resolve, {once: true});
+        });
+        return _super(...arguments);
+    },
+
+    //--------------------------------------------------------------------------
+    // Options
+    //--------------------------------------------------------------------------
+
+    /**
+     * @see this.selectClass for parameters
+     */
+    selectWidth(previewMode, widgetValue, params) {
+        this._getImg().dataset.resizeWidth = widgetValue;
+        return this._applyOptions();
+    },
+    /**
+     * @see this.selectClass for parameters
+     */
+    setFilter(previewMode, widgetValue, params) {
+        this._getImg().dataset.filter = this._normalizeColor(widgetValue);
+        return this._applyOptions();
+    },
+    /**
+     * @see this.selectClass for parameters
+     */
+    setQuality(previewMode, widgetValue, params) {
+        this._getImg().dataset.quality = widgetValue;
+        return this._applyOptions();
+    },
+
+    //--------------------------------------------------------------------------
+    // Private
+    //--------------------------------------------------------------------------
+
+    /**
+     * @override
+     */
+    _computeVisibility() {
+        const src = this._getImg().getAttribute('src');
+        return src && src !== '/';
+    },
+    /**
+     * @override
+     */
+    _computeWidgetState(methodName, params) {
+        const img = this._getImg();
+        switch (methodName) {
+            case 'selectWidth':
+                return img.naturalWidth;
+            case 'setFilter':
+                return img.dataset.filter;
+            case 'setQuality':
+                return img.dataset.quality || 95;
+        }
+        return this._super(...arguments);
+    },
+    /**
+     * @override
+     */
+    async _renderCustomXML(uiFragment) {
+        if (!this.originalSrc) {
+            return [...uiFragment.childNodes].forEach(node => {
+                if (node.matches('.o_we_external_warning')) {
+                    node.classList.remove('d-none');
+                } else {
+                    node.remove();
+                }
+            });
+        }
+        const img = this._getImg();
+        const $select = $(uiFragment).find('we-select[data-name=width_select_opt]');
+        (await this._computeAvailableWidths()).forEach(([value, label]) => {
+            $select.append(`<we-button data-select-width="${value}">${label}</we-button>`);
+        });
+        const qualityRange = uiFragment.querySelector('we-range');
+        if (img.dataset.mimetype !== 'image/jpeg') {
+            qualityRange.remove();
+        }
+    },
+    /**
+     * Returns a list of valid widths for a given image.
+     *
+     * @private
+     */
+    async _computeAvailableWidths() {
+        const img = this._getImg();
+        const original = await loadImage(this.originalSrc);
+        const maxWidth = img.dataset.width ? img.naturalWidth : original.naturalWidth;
+        const optimizedWidth = Math.min(maxWidth, this._computeMaxDisplayWidth());
+        this.optimizedWidth = optimizedWidth;
+        const widths = {
+            128: '128px',
+            256: '256px',
+            512: '512px',
+            1024: '1024px',
+            1920: '1920px',
+        };
+        widths[img.naturalWidth] = _.str.sprintf(_t("%spx"), img.naturalWidth);
+        widths[optimizedWidth] = _.str.sprintf(_t("%dpx (Suggested)"), optimizedWidth);
+        widths[maxWidth] = _.str.sprintf(_t("%dpx (Original)"), maxWidth);
+        return Object.entries(widths)
+            .filter(([width]) => width <= maxWidth)
+            .sort(([v1], [v2]) => v1 - v2);
+    },
+    /**
+     * Applies all selected options on the original image.
+     *
+     * @private
+     */
+    async _applyOptions() {
+        const img = this._getImg();
+        if (!['image/jpeg', 'image/png'].includes(img.dataset.mimetype)) {
+            this.originalId = null;
+            return;
+        }
+        const dataURL = await applyModifications(img);
+        const weight = dataURL.split(',')[1].length / 4 * 3;
+        this.$el.find('.o_we_image_weight').text(`${(weight / 1024).toFixed(1)}kb`);
+        img.classList.add('o_modified_image_to_save');
+        return loadImage(dataURL, img);
+    },
+    /**
+     * Loads the image's attachment info.
+     *
+     * @private
+     */
+    async _loadImageInfo() {
+        const img = this._getImg();
+        await loadImageInfo(img, this._rpc.bind(this));
+        if (!img.dataset.originalId) {
+            this.originalId = null;
+            this.originalSrc = null;
+            return;
+        }
+        this.originalId = img.dataset.originalId;
+        this.originalSrc = img.dataset.originalSrc;
+    },
+    /**
+     * Returns the image that is currently being modified.
+     *
+     * @private
+     * @abstract
+     * @returns {HTMLImageElement} the image to use for modifications
+     */
+    _getImg() {},
+    /**
+     * Computes the image's maximum display width.
+     *
+     * @private
+     * @abstract
+     * @returns {Int} the maximum width at which the image can be displayed
+     */
+    _computeMaxDisplayWidth() {},
+
+    //--------------------------------------------------------------------------
+    // Util
+    //--------------------------------------------------------------------------
+
+    /**
+     * Normalize a color into a css value usable by the canvas rendering context.
+     *
+     * @private
+     * @param {string} color the color to normalize into a css value
+     */
+    _normalizeColor(color) {
+        if (!ColorpickerWidget.isCSSColor(color)) {
+            const style = window.getComputedStyle(document.documentElement);
+            color = style.getPropertyValue('--' + color).trim();
+            color = ColorpickerWidget.normalizeCSSColor(color);
+        }
+        return color;
+    },
+});
+
+/**
+ * Controls image width and quality.
+ */
+registry.ImageOptimize = ImageHandlerOption.extend({
+    /**
+     * @override
+     */
+    start() {
+        this.$target.on('image_changed.ImageOptimization', this._onImageChanged.bind(this));
+        this.$target.on('image_cropped.ImageOptimization', this._onImageCropped.bind(this));
+        return this._super(...arguments);
+    },
+    /**
+     * @override
+     */
+    destroy() {
+        this.$target.off('.ImageOptimization');
+        return this._super(...arguments);
+    },
+
+    //--------------------------------------------------------------------------
+    // Private
+    //--------------------------------------------------------------------------
+
+    /**
+     * @override
+     */
+    _computeMaxDisplayWidth() {
+        // TODO: read widths from computed style in case container widths are not default
+        const displayWidth = this._getImg().clientWidth;
+        // If the image is in a column, it might get bigger on smaller screens.
+        // We use col-lg for this in snippets, so they get bigger on the md breakpoint
+        if (this.$target.closest('[class*="col-lg"]').length) {
+            // container and o_container_small have maximum inner width of 690px on the md breakpoint
+            if (this.$target.closest('.container, .o_container_small').length) {
+                return Math.min(1920, Math.max(displayWidth, 690));
+            }
+            // A container-fluid's max inner width is 962px on the md breakpoint
+            return Math.min(1920, Math.max(displayWidth, 962));
+        }
+        // If it's not in a col-lg, it's probably not going to change size depending on breakpoints
+        return displayWidth;
+    },
+    /**
+     * @override
+     */
+    _getImg() {
+        return this.$target[0];
+    },
+
+    //--------------------------------------------------------------------------
+    // Handlers
+    //--------------------------------------------------------------------------
+
+    /**
+     * Reloads image data and auto-optimizes the new image.
+     *
+     * @private
+     * @param {Event} ev
+     */
+    async _onImageChanged(ev) {
+        this.trigger_up('snippet_edition_request', {exec: async () => {
+            await this._loadImageInfo();
+            await this._rerenderXML();
+            this._getImg().dataset.resizeWidth = this.optimizedWidth;
+            await this._applyOptions();
+            await this.updateUI();
+        }});
+    },
+    /**
+     * Available widths will change, need to rerender the width select.
+     *
+     * @private
+     * @param {Event} ev
+     */
+    async _onImageCropped(ev) {
+        await this._rerenderXML();
+    },
+});
+
+/**
+ * Returns the src value from a css value related to a background image
+ * (e.g. "url('blabla')" => "blabla" / "none" => "").
+ *
+ * @param {string} value
+ * @returns {string}
+ */
+const getSrcFromCssValue = value => {
+    var srcValueWrapper = /url\(['"]*|['"]*\)|^none$/g;
+    return value && value.replace(srcValueWrapper, '') || '';
+};
+
+/**
+ * Controls background image width and quality.
+ */
+registry.BackgroundOptimize = ImageHandlerOption.extend({
+    /**
+     * @override
+     */
+    start() {
+        this.$target.on('background_changed.BackgroundOptimize', this._onBackgroundChanged.bind(this));
+        return this._super(...arguments);
+    },
+    /**
+     * @override
+     */
+    destroy() {
+        this.$target.off('.BackgroundOptimize');
+        return this._super(...arguments);
+    },
+    /**
+     * Marks the target for creation of an attachment and copies data attributes
+     * to the target so that they can be restored on this.img in later editions.
+     *
+     * @override
+     */
+    async cleanForSave() {
+        const img = this._getImg();
+        if (img.matches('.o_modified_image_to_save')) {
+            this.$target.addClass('o_modified_image_to_save');
+            Object.entries(img.dataset).forEach(([key, value]) => {
+                this.$target[0].dataset[key] = value;
+            });
+            this.$target[0].dataset.bgSrc = img.getAttribute('src');
+        }
+    },
+
+    //--------------------------------------------------------------------------
+    // Private
+    //--------------------------------------------------------------------------
+
+    /**
+     * @override
+     */
+    _getImg() {
+        return this.img;
+    },
+    /**
+     * @override
+     */
+    _computeMaxDisplayWidth() {
+        return 1920;
+    },
+    /**
+     * @override
+     */
+    async _applyOptions() {
+        await this._super(...arguments);
+        this.$target.css('background-image', `url('${this._getImg().getAttribute('src')}')`);
+    },
+    /**
+     * Initializes this.img to an image with the background image url as src.
+     *
+     * @override
+     */
+    async _loadImageInfo() {
+        this.img = new Image();
+        Object.entries(this.$target[0].dataset).forEach(([key, value]) => {
+            this.img.dataset[key] = value;
+        });
+        const src = new URL(getSrcFromCssValue(this.$target.css('background-image')), window.location.origin);
+        // Make URL relative because that is how image urls are stored in the database.
+        this.img.src = src.origin === window.location.origin ? src.pathname : src;
+        return await this._super(...arguments);
+    },
+
+    //--------------------------------------------------------------------------
+    // Handlers
+    //--------------------------------------------------------------------------
+
+    /**
+     * Reloads image data when the background is changed.
+     *
+     * @private
+     */
+    async _onBackgroundChanged(ev, previewMode) {
+        if (!previewMode) {
+            this.trigger_up('snippet_edition_request', {exec: async () => {
+                await this._loadImageInfo();
+                await this._rerenderXML();
+            }});
+        }
+    },
+});
+
 /**
  * Handles the edition of snippet's background image.
  */
@@ -2687,12 +3116,14 @@ registry.background = SnippetOptionWidget.extend({
      * @see this.selectClass for parameters
      */
     background: async function (previewMode, widgetValue, params) {
-        if (previewMode === 'reset') {
-            return this._setCustomBackground(this.__customImageSrc, previewMode);
-        }
-        if (!previewMode) {
+        if (previewMode === true) {
+            this.__customImageSrc = this._getSrcFromCssValue();
+        } else if (previewMode === 'reset') {
+            widgetValue = this.__customImageSrc;
+        } else {
             this.__customImageSrc = widgetValue;
         }
+
         if (widgetValue) {
             this.$target.css('background-image', `url('${widgetValue}')`);
             this.$target.addClass('oe_img_bg');
@@ -2700,6 +3131,20 @@ registry.background = SnippetOptionWidget.extend({
             this.$target.css('background-image', '');
             this.$target.removeClass('oe_img_bg');
         }
+
+        if (previewMode === 'reset') {
+            return new Promise(resolve => {
+                // Will update the UI of the correct widgets for all options
+                // related to the same $target/editor
+                this.trigger_up('snippet_option_update', {
+                    previewMode: 'reset',
+                    onSuccess: () => resolve(),
+                });
+            });
+        } else {
+            removeOnImageChangeAttrs.forEach(attr => delete this.$target[0].dataset[attr]);
+            this.$target.trigger('background_changed', [previewMode]);
+        }
     },
 
     //--------------------------------------------------------------------------
@@ -2732,38 +3177,13 @@ registry.background = SnippetOptionWidget.extend({
     //--------------------------------------------------------------------------
 
     /**
-     * Returns the src value from a css value related to a background image
-     * (e.g. "url('blabla')" => "blabla" / "none" => "").
+     * Returns the background image's src.
      *
      * @private
-     * @param {string} value
      * @returns {string}
      */
-    _getSrcFromCssValue: function (value) {
-        if (value === undefined) {
-            value = this.$target.css('background-image');
-        }
-        var srcValueWrapper = /url\(['"]*|['"]*\)|^none$/g;
-        return value && value.replace(srcValueWrapper, '') || '';
-    },
-    /**
-     * Sets the given value as custom background image.
-     *
-     * @private
-     * @param {string} value
-     * @returns {Promise}
-     */
-    _setCustomBackground: async function (value, previewMode) {
-        this.__customImageSrc = value;
-        this.background(false, this.__customImageSrc, {});
-        await new Promise(resolve => {
-            // Will update the UI of the correct widgets for all options
-            // related to the same $target/editor
-            this.trigger_up('snippet_option_update', {
-                previewMode: previewMode,
-                onSuccess: () => resolve(),
-            });
-        });
+    _getSrcFromCssValue: function () {
+        return getSrcFromCssValue(this.$target.css('background-image'));
     },
     /**
      * @override
@@ -2801,17 +3221,6 @@ registry.background = SnippetOptionWidget.extend({
         await this.background(previewMode, '', {});
         return true;
     },
-    /**
-     * Called on media dialog save (when choosing a snippet's background) ->
-     * sets the resulting media as the snippet's background somehow.
-     *
-     * @private
-     * @param {Object} data
-     * @returns {Promise}
-     */
-    _onSaveMediaDialog: async function (data) {
-        await this._setCustomBackground($(data).attr('src'));
-    },
 });
 
 /**
@@ -3009,19 +3418,13 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({
         window.setTimeout(() => $(document).on('click.bgposition', this._onDocumentClicked.bind(this)), 0);
     },
     /**
-     * Returns the src value from a css value related to a background image
-     * (e.g. "url('blabla')" => "blabla" / "none" => "").
+     * Returns the background image's src.
      *
      * @private
-     * @param {string} value
      * @returns {string}
      */
-    _getSrcFromCssValue: function (value) {
-        if (value === undefined) {
-            value = this.$target.css('background-image');
-        }
-        var srcValueWrapper = /url\(['"]*|['"]*\)|^none$/g;
-        return value && value.replace(srcValueWrapper, '') || '';
+    _getSrcFromCssValue: function () {
+        return getSrcFromCssValue(this.$target.css('background-image'));
     },
     /**
      * Returns the difference between the target's size and the background's
diff --git a/addons/web_editor/static/src/js/wysiwyg/root.js b/addons/web_editor/static/src/js/wysiwyg/root.js
index 8b644b6faaef0dc10837360386b6647acd04dd69..dc87ff9e0be46d07b31586a66923101f2435e8c6 100644
--- a/addons/web_editor/static/src/js/wysiwyg/root.js
+++ b/addons/web_editor/static/src/js/wysiwyg/root.js
@@ -9,7 +9,7 @@ var WysiwygRoot = Widget.extend({
     assetLibs: ['web_editor.compiled_assets_wysiwyg'],
     _loadLibsTplRoute: '/web_editor/public_render_template',
 
-    publicMethods: ['isDirty', 'save', 'getValue', 'setValue', 'getEditable', 'on', 'trigger', 'focus', 'saveCroppedImages'],
+    publicMethods: ['isDirty', 'save', 'getValue', 'setValue', 'getEditable', 'on', 'trigger', 'focus', 'saveModifiedImages'],
 
     /**
      *   @see 'web_editor.wysiwyg' module
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js b/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js
index 91f76bba5d4c2ff125965f4adaf59e08e5b38d58..27444e061af29cdf6de8defdefa26190d34476c4 100644
--- a/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js
@@ -2,13 +2,11 @@ odoo.define('wysiwyg.widgets.ImageCropWidget', function (require) {
 'use strict';
 
 const core = require('web.core');
-const qweb = core.qweb;
 const Widget = require('web.Widget');
+const {applyModifications, cropperDataFields, activateCropper, loadImage, loadImageInfo} = require('web_editor.image_processing');
 
 const _t = core._t;
 
-// Fields returned by cropper lib 'getData' method
-const cropperDataFields = ['x', 'y', 'width', 'height', 'rotate', 'scaleX', 'scaleY'];
 const ImageCropWidget = Widget.extend({
     template: ['wysiwyg.widgets.crop'],
     xmlDependencies: ['/web_editor/static/src/xml/wysiwyg.xml'],
@@ -17,41 +15,16 @@ const ImageCropWidget = Widget.extend({
         // zoom event is triggered by the cropperjs library when the user zooms.
         'zoom': '_onCropZoom',
     },
-    // Crop attributes that are saved with the DOM. Should only be removed when the image is changed.
-    persistentAttributes: [
-        ...cropperDataFields,
-        'aspectRatio',
-    ],
-    // Attributes that are used to keep data from one crop to the next in the same session
-    // If present, should be used by the cropper instead of querying db
-    sessionAttributes: [
-        'attachmentId',
-        'originalSrc',
-        'originalId',
-        'originalName',
-        'mimetype',
-    ],
-    // Attributes that are used by saveCroppedImages to create or update attachments
-    saveAttributes: [
-        'resModel',
-        'resId',
-        'attachmentId',
-        'originalId',
-        'originalName',
-        'mimetype',
-    ],
 
     /**
      * @constructor
      */
-    init(parent, options, media) {
+    init(parent, media) {
         this._super(...arguments);
         this.media = media;
         this.$media = $(media);
         // Needed for editors in iframes.
         this.document = media.ownerDocument;
-        // Used for res_model and res_id
-        this.options = options;
         // key: ratio identifier, label: displayed to user, value: used by cropper lib
         this.aspectRatios = {
             "0/0": {label: _t("Free"), value: 0},
@@ -65,48 +38,26 @@ const ImageCropWidget = Widget.extend({
         this.initialSrc = src;
         this.aspectRatio = data.aspectRatio || "0/0";
         this.mimetype = data.mimetype || src.endsWith('.png') ? 'image/png' : 'image/jpeg';
-        this.isCroppable = src.startsWith('data:') || new URL(src, window.location.origin).origin === window.location.origin;
     },
     /**
      * @override
      */
     async willStart() {
         await this._super.apply(this, arguments);
-        if (!this.isCroppable) {
-            return;
-        }
-        // If there is a marked originalSrc, a previous crop has already happened,
-        // we won't find the original from the data-url. Reuse the data from the previous crop.
+        await loadImageInfo(this.media, this._rpc.bind(this));
         if (this.media.dataset.originalSrc) {
-            this.sessionAttributes.forEach(attr => {
-                this[attr] = this.media.dataset[attr];
-            });
-            return;
-        }
-
-        // Get id, mimetype and originalSrc.
-        const {attachment, original} = await this._rpc({
-            route: '/web_editor/get_image_info',
-            params: {src: this.initialSrc.split(/[?#]/)[0]},
-        });
-        if (!attachment) {
-            // Local image that doesn't have an attachment, don't allow crop?
-            // In practice, this can happen if an image is directly linked with its
-            // static url and there is no corresponding attachment, (eg, logo in mass_mailing)
-            this.isCroppable = false;
+            this.originalSrc = this.media.dataset.originalSrc;
+            this.originalId = this.media.dataset.originalId;
             return;
         }
-        this.originalId = original.id;
-        this.originalSrc = original.image_src;
-        this.originalName = original.name;
-        this.mimetype = original.mimetype;
-        this.attachmentId = attachment.id;
+        // Couldn't find an attachment: not croppable.
+        this.uncroppable = true;
     },
     /**
      * @override
      */
     async start() {
-        if (!this.isCroppable) {
+        if (this.uncroppable) {
             this.displayNotification({
               type: 'warning',
               title: _t("This image is an external image"),
@@ -118,8 +69,7 @@ const ImageCropWidget = Widget.extend({
         const $cropperWrapper = this.$('.o_we_cropper_wrapper');
 
         // Replacing the src with the original's so that the layout is correct.
-        this.media.setAttribute('src', this.originalSrc);
-        await new Promise(resolve => this.media.addEventListener('load', resolve, {once: true}));
+        await loadImage(this.originalSrc, this.media);
         this.$cropperImage = this.$('.o_we_cropper_img');
         const cropperImage = this.$cropperImage[0];
         [cropperImage.style.width, cropperImage.style.height] = [this.$media.width() + 'px', this.$media.height() + 'px'];
@@ -130,18 +80,8 @@ const ImageCropWidget = Widget.extend({
         offset.top += parseInt(this.$media.css('padding-right'));
         $cropperWrapper.offset(offset);
 
-        cropperImage.setAttribute('src', this.originalSrc);
-        await new Promise(resolve => cropperImage.addEventListener('load', resolve, {once: true}));
-        this.$cropperImage.cropper({
-            viewMode: 2,
-            dragMode: 'move',
-            autoCropArea: 1.0,
-            aspectRatio: this.aspectRatios[this.aspectRatio].value,
-            data: _.mapObject(_.pick(this.media.dataset, ...cropperDataFields), value => parseFloat(value)),
-            // Can't use 0 because it's falsy and the lib will then use its defaults (200x100)
-            minContainerWidth: 1,
-            minContainerHeight: 1,
-        });
+        await loadImage(this.originalSrc, cropperImage);
+        await activateCropper(cropperImage, this.aspectRatios[this.aspectRatio].value, this.media.dataset);
         core.bus.trigger('deactivate_snippet');
 
         this._onDocumentMousedown = this._onDocumentMousedown.bind(this);
@@ -173,27 +113,20 @@ const ImageCropWidget = Widget.extend({
      *
      * @private
      */
-    _save() {
-        // Mark the media for later creation/update of cropped attachment
-        this.media.classList.add('o_cropped_img_to_save');
+    async _save() {
+        // Mark the media for later creation of cropped attachment
+        this.media.classList.add('o_modified_image_to_save');
 
-        this.allAttributes.forEach(attr => {
+        [...cropperDataFields, 'aspectRatio'].forEach(attr => {
             delete this.media.dataset[attr];
             const value = this._getAttributeValue(attr);
             if (value) {
                 this.media.dataset[attr] = value;
             }
         });
-
-        // Update the media with base64 source for preview before saving
-        const cropperData = this.$cropperImage.cropper('getData');
-        const canvas = this.$cropperImage.cropper('getCroppedCanvas', {
-            width: cropperData.width,
-            height: cropperData.height,
-        });
-        // 1 is the quality if the image is jpeg (in the range O-1), defaults to .92
-        this.initialSrc = canvas.toDataURL(this.mimetype, 1);
-        // src will be set to this.initialSrc in the destroy method
+        delete this.media.dataset.resizeWidth;
+        this.initialSrc = await applyModifications(this.media);
+        this.$media.trigger('image_cropped');
         this.destroy();
     },
     /**
@@ -202,12 +135,6 @@ const ImageCropWidget = Widget.extend({
      * @private
      */
     _getAttributeValue(attr) {
-        switch (attr) {
-            case 'resModel':
-                return this.options.res_model;
-            case 'resId':
-                return this.options.res_id;
-        }
         if (cropperDataFields.includes(attr)) {
             return this.$cropperImage.cropper('getData')[attr];
         }
@@ -282,16 +209,5 @@ const ImageCropWidget = Widget.extend({
     },
 });
 
-const proto = ImageCropWidget.prototype;
-proto.allAttributes = [...new Set([
-    ...proto.persistentAttributes,
-    ...proto.sessionAttributes,
-    ...proto.saveAttributes,
-])];
-proto.removeOnSaveAttributes = [...new Set([
-    ...proto.sessionAttributes,
-    ...proto.saveAttributes,
-])];
-
 return ImageCropWidget;
 });
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/image_optimize_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/image_optimize_dialog.js
deleted file mode 100644
index 1c655026f32c9dfc6b348b9b01a0ec80d0176d0b..0000000000000000000000000000000000000000
--- a/addons/web_editor/static/src/js/wysiwyg/widgets/image_optimize_dialog.js
+++ /dev/null
@@ -1,330 +0,0 @@
-odoo.define('wysiwyg.widgets.image_optimize_dialog', function (require) {
-'use strict';
-
-var core = require('web.core');
-var Dialog = require('web.Dialog');
-
-var _t = core._t;
-
-var ImageOptimizeDialog = Dialog.extend({
-    template: 'wysiwyg.widgets.image.optimize',
-    xmlDependencies: (Dialog.prototype.xmlDependencies || []).concat([
-        '/web_editor/static/src/xml/wysiwyg.xml',
-    ]),
-    events: _.extend({}, Dialog.prototype.events, {
-        'click .o_we_width_preset': '_onWidthPresetClick',
-        'input #o_we_height': '_onHeightInput',
-        'input #o_we_name_input': '_onNameInput',
-        'input #o_we_optimize_quality': '_onOptimizeQualityInput',
-        'input #o_we_quality_input': '_onQualityInput',
-        'input #o_we_width': '_onWidthInput',
-        'input .o_we_quality_range': '_onQualityRangeInput',
-    }),
-
-    /**
-     * @constructor
-     */
-    init: function (parent, params, options) {
-        this._super(parent, _.extend({}, {
-            title: _t("Improve your Image"),
-            size: 'large',
-            buttons: [
-                {text: _t("Optimize"), classes: 'btn-primary o_we_save', close: false, click: this._onOptimizeClick.bind(this)},
-                {text: _t("Keep Original"), close: false, click: this._onKeepOriginalClick.bind(this)}
-            ],
-        }, options));
-        this.params = params;
-    },
-    /**
-     * @override
-     */
-    willStart: async function () {
-        const _super = this._super.bind(this);
-        const {isExisting, attachment, optimizedWidth} = this.params;
-        this.isExisting = isExisting;
-        this.attachment = attachment;
-        let original = attachment;
-        if (attachment.original_id) {
-            [original] = await this._rpc({
-                model: 'ir.attachment',
-                method: 'read',
-                args: [[attachment.original_id[0]], ['image_width', 'image_height']],
-            });
-        }
-        this.image_width = original.image_width;
-        this.image_height = original.image_height;
-        // We do not support resizing and quality for:
-        //  - SVG because it doesn't make sense
-        //  - GIF because our current code is not made to handle all the frames
-        this.disableResize = ['image/jpeg', 'image/jpe', 'image/jpg', 'image/png'].indexOf(this.attachment.mimetype) === -1;
-        this.disableQuality = this.disableResize;
-        this.toggleQuality = this.attachment.mimetype === 'image/png';
-        this.optimizedWidth = Math.min(optimizedWidth || this.image_width, this.image_width);
-        this.defaultQuality = this.isExisting ? 100 : 80;
-        this.defaultWidth = parseInt(this.isExisting ? this.image_width : this.optimizedWidth);
-        this.defaultHeight = parseInt(this.isExisting || !this.image_width ? this.image_height :
-            this.optimizedWidth / this.image_width * this.image_height);
-
-        this.suggestedWidths = [];
-        this._addSuggestedWidth(128, '128');
-        this._addSuggestedWidth(256, '256');
-        this._addSuggestedWidth(512, '512');
-        this._addSuggestedWidth(1024, '1024');
-        this._addSuggestedWidth(this.optimizedWidth,
-            _.str.sprintf(_t("%d (Suggested)"), this.optimizedWidth));
-        this.suggestedWidths.push({
-            'width': this.image_width,
-            'text': _.str.sprintf(_t("%d (Original)"), this.image_width),
-        });
-        this.suggestedWidths = _.sortBy(this.suggestedWidths, 'width');
-        this._updatePreview = _.debounce(this._updatePreview.bind(this), 300);
-        return _super(...arguments);
-    },
-    /**
-     * @override
-     */
-    start: function () {
-        var self = this;
-        var promise = this._super.apply(this, arguments);
-        this.$nameInput = this.$('#o_we_name_input');
-        this.$previewImage = this.$('.o_we_preview_image');
-        this.$saveButton = this.$footer.find('.o_we_save');
-
-        // The following selectors might not contain anything:
-        // - depending on disableResize
-        this.$widthPresets = this.$('.o_we_width_preset');
-        this.$widthInput = this.$('#o_we_width');
-        this.$heightInput = this.$('#o_we_height');
-        // - depending on disableQuality
-        this.$qualityRange = this.$('.o_we_quality_range');
-        this.$qualityInput = this.$('#o_we_quality_input');
-        // - depending on toggleQuality
-        this.$qualityCheckBox = this.$('#o_we_optimize_quality');
-
-        this._updatePreview();
-        this._validateForm();
-        this.opened().then(function () {
-            self.$nameInput.focus();
-        });
-        return promise;
-    },
-
-    //--------------------------------------------------------------------------
-    // Private
-    //--------------------------------------------------------------------------
-
-    /**
-     * Adds a size to the list of suggested width, only if it is smaller than
-     * the image width (we don't scale up images).
-     *
-     * @private
-     * @param {number} size: integer
-     * @param {string} text
-     */
-    _addSuggestedWidth: function (size, text) {
-        if (size < this.image_width) {
-            this.suggestedWidths.push({
-                width: size,
-                text: text,
-            });
-        }
-    },
-    /**
-     * Gets the current quality from the input, adapts it to match what is
-     * expected by image tools, and returns it.
-     *
-     * The adaptation consists of transforming the quality 100 to 0 to disable
-     * optimizations, and to convert values between 95 and 99 to 95 because
-     * values above 95 results in large files with hardly any gain in image
-     * quality.
-     *
-     * See `quality` parameter at:
-     * https://pillow.readthedocs.io/en/4.0.x/handbook/image-file-formats.html#jpeg
-     *
-     * @private
-     * @returns {number} the quality as integer
-     */
-    _getAdaptedQuality: function () {
-        var quality = 0;
-        if (this.$qualityCheckBox.length) {
-            if (this.$qualityCheckBox.is(":checked")) {
-                // any value will do, as long as it is not 100 or falsy
-                quality = 50;
-            }
-        } else if (this.$qualityRange.length) {
-            quality = parseInt(this.$qualityRange.val() || 0);
-        }
-        if (quality === 100) {
-            quality = 0;
-        }
-        return Math.min(95, quality);
-    },
-    /**
-     * Updates the preview to match the current settings, and also highlights
-     * the width preset buttons if applicable.
-     *
-     * @private
-     */
-    _updatePreview: function () {
-        if (!this._validatePreview()) {
-            return;
-        }
-        var width = parseInt(this.$widthInput.val() || 0);
-        var height = parseInt(this.$heightInput.val() || 0);
-        this.$previewImage.attr('src', _.str.sprintf('/web/image/%d/%dx%d?quality=%d',
-            (this.attachment.original_id || [this.attachment.id])[0], width, height, this._getAdaptedQuality()));
-        this.$widthPresets.removeClass('active');
-        _.each(this.$widthPresets, function (button) {
-            var $button = $(button);
-            if (parseInt($button.data('width')) === width) {
-                $button.addClass('active');
-            }
-        });
-    },
-    /**
-     * Validates the form and toggles the save button accordingly.
-     *
-     * @private
-     * @returns {boolean} whether the form is valid
-     */
-    _validateForm: function () {
-        var name = this.$nameInput.val();
-        var isValid = name && this._validatePreview();
-        this.$saveButton.prop('disabled', !isValid);
-        return isValid;
-    },
-    /**
-     * Validates the preview values.
-     *
-     * @private
-     * @returns {boolean} whether the preview values are valid
-     */
-    _validatePreview: function () {
-        var isValid = true;
-        var quality = this._getAdaptedQuality();
-        var width = parseInt(this.$widthInput.val() || 0);
-        var height = parseInt(this.$heightInput.val() || 0);
-        if (quality < 0 || quality > 100) {
-            isValid = false;
-        }
-        if (width < 0 || width > this.image_width) {
-            isValid = false;
-        }
-        if (height < 0 || height > this.image_height) {
-            isValid = false;
-        }
-        return isValid;
-    },
-
-    //--------------------------------------------------------------------------
-    // Handlers
-    //--------------------------------------------------------------------------
-
-    /**
-     * @private
-     */
-    _onHeightInput: function () {
-        var height = parseInt(this.$heightInput.val()) || 0;
-        this.$widthInput
-            .val(parseInt(height / this.image_height * this.image_width));
-        this._updatePreview();
-        this._validateForm();
-    },
-    /**
-     * @private
-     */
-    _onKeepOriginalClick: function () {
-        if (!this.isExisting) {
-            this.trigger_up('attachment_updated', this.attachment);
-        }
-        this.close();
-    },
-    /**
-     * @private
-     */
-    _onNameInput: function () {
-        this._validateForm();
-    },
-    /**
-     * Handles clicking on the save button: updates the given attachment with
-     * the selected settings and triggers up the `attachment_updated`` event,
-     * then closes the dialog.
-     *
-     * @private
-     * @returns {Promise}
-     */
-    _onOptimizeClick: function () {
-        var self = this;
-        var name = this.$nameInput.val();
-        var params = {
-            'name': name,
-            'quality': this._getAdaptedQuality(),
-            'width': parseInt(this.$widthInput.val() || 0),
-            'height': parseInt(this.$heightInput.val() || 0),
-        };
-        if (this.isExisting && !this.attachment.original_id) {
-            params['copy'] = true;
-        }
-        return this._rpc({
-            route: _.str.sprintf('/web_editor/attachment/%d/update', this.attachment.id),
-            params: params,
-        }).then(function (attachment) {
-            self.trigger_up('attachment_updated', attachment);
-            self.close();
-        });
-    },
-    /**
-     * @private
-     */
-    _onQualityInput: function () {
-        var quality = parseInt(this.$qualityInput.val() || 0);
-        // adapt the quality to what will actually happen
-        if (quality === 0) {
-            quality = 100;
-        }
-        // prevent flickering when clearing the input to type something else
-        if (!quality) {
-            return;
-        }
-        this.$qualityRange.val(quality);
-        this._onQualityRangeInput();
-    },
-    /**
-     * @private
-     */
-    _onQualityRangeInput: function () {
-        var quality = parseInt(this.$qualityRange.val() || 0);
-        this.$qualityInput.val(quality);
-        this._updatePreview();
-        this._validateForm();
-    },
-    /**
-     * @private
-     */
-    _onOptimizeQualityInput: function () {
-        this._updatePreview();
-    },
-    /**
-     * @private
-     */
-    _onWidthInput: function () {
-        var width = parseInt(this.$widthInput.val() || 0);
-        this.$heightInput
-            .val(parseInt(width / this.image_width * this.image_height));
-        this._updatePreview();
-        this._validateForm();
-    },
-    /**
-     * @private
-     */
-    _onWidthPresetClick: function (ev) {
-        ev.preventDefault();
-        this.$widthInput.val(parseInt($(ev.target).data('width')));
-        this._onWidthInput();
-    },
-});
-
-return {
-    ImageOptimizeDialog: ImageOptimizeDialog,
-};
-});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
index 645b49c7ead774e0bda21d3478539f0b994492b2..09fddb2869b6a73c7f82a1781d584601c0a70eba 100644
--- a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
@@ -6,11 +6,10 @@ var core = require('web.core');
 var Dialog = require('web.Dialog');
 var dom = require('web.dom');
 var fonts = require('wysiwyg.fonts');
-var ImageOptimizeDialog = require('wysiwyg.widgets.image_optimize_dialog').ImageOptimizeDialog;
 var utils = require('web.utils');
 var Widget = require('web.Widget');
 var session = require('web.session');
-const ImageCropWidget = require('wysiwyg.widgets.ImageCropWidget');
+const {removeOnImageChangeAttrs} = require('web_editor.image_processing');
 
 var QWeb = core.qweb;
 var _t = core._t;
@@ -123,7 +122,6 @@ var FileWidget = SearchableMediaWidget.extend({
         'input .o_we_url_input': '_onURLInputChange',
         'click .o_existing_attachment_cell': '_onAttachmentClick',
         'click .o_existing_attachment_remove': '_onRemoveClick',
-        'click .o_existing_attachment_optimize': '_onExistingOptimizeClick',
         'click .o_load_more': '_onLoadMoreClick',
     }),
     existingAttachmentsTemplate: undefined,
@@ -131,14 +129,6 @@ var FileWidget = SearchableMediaWidget.extend({
     IMAGE_MIMETYPES: ['image/gif', 'image/jpe', 'image/jpeg', 'image/jpg', 'image/gif', 'image/png', 'image/svg+xml'],
     NUMBER_OF_ATTACHMENTS_TO_DISPLAY: 30,
 
-    // This factor is used to take into account that an image displayed in a BS
-    // column might get bigger when displayed on a smaller breakpoint if that
-    // breakpoint leads to have less columns.
-    // Eg. col-lg-6 -> 480px per column -> col-md-12 -> 720px per column -> 1.5
-    // However this will not be enough if going from 3 or more columns to 1, but
-    // in that case, we consider it a snippet issue.
-    OPTIMIZE_SIZE_FACTOR: 1.5,
-
     /**
      * @constructor
      */
@@ -260,20 +250,6 @@ var FileWidget = SearchableMediaWidget.extend({
             .replace(allImgClasses, ' ')
             .replace(allImgClassModifiers, ' ');
     },
-    /**
-     * Computes and returns the width that a new attachment should have to
-     * ideally occupy the space where it will be inserted.
-     * Only relevant for images.
-     *
-     * @see options.mediaWidth
-     * @see OPTIMIZE_SIZE_FACTOR
-     *
-     * @private
-     * @returns {integer}
-     */
-    _computeOptimizedWidth: function () {
-        return Math.min(1920, parseInt(this.options.mediaWidth * this.OPTIMIZE_SIZE_FACTOR));
-    },
     /**
      * Returns the domain for attachments used in media dialog.
      * We look for attachments related to the current document. If there is a value for the model
@@ -355,52 +331,6 @@ var FileWidget = SearchableMediaWidget.extend({
             }
         });
     },
-    /**
-     * Opens the image optimize dialog for the given attachment.
-     *
-     * Hides the media dialog while the optimize dialog is open to avoid an
-     * overlap of modals.
-     *
-     * @private
-     * @param {object} attachment
-     * @param {boolean} isExisting: whether this is a new attachment that was
-     *  just uploaded, or an existing attachment
-     * @returns {Promise} resolved with the updated attachment object when the
-     *  optimize dialog is saved. Rejected if the dialog is otherwise closed.
-     */
-    _openImageOptimizeDialog: function (attachment, isExisting, $attachmentCell) {
-        var self = this;
-        var promise = new Promise(function (resolve, reject) {
-            self.trigger_up('hide_parent_dialog_request');
-            var optimizeDialog = new ImageOptimizeDialog(self, {
-                attachment: attachment,
-                isExisting: isExisting,
-                optimizedWidth: self._computeOptimizedWidth(),
-            }).open();
-            optimizeDialog.on('attachment_updated', self, function (ev) {
-                optimizeDialog.off('closed');
-                if (self.$media[0].getAttribute('src') === attachment.image_src) {
-                    self.$media[0].src = ev.data.image_src;
-                }
-                Object.assign(attachment, ev.data);
-                $attachmentCell.find('img')[0].src = attachment.image_src;
-                resolve(attachment);
-            });
-            optimizeDialog.on('closed', self, function () {
-                self.noSave = true;
-                if (isExisting) {
-                    reject();
-                } else {
-                    resolve(attachment);
-                }
-            });
-        });
-        var always = () => {
-            self.trigger_up('show_parent_dialog_request');
-        };
-        promise.then(always).guardedCatch(always);
-        return promise;
-    },
     /**
      * Renders the existing attachments and returns the result as a string.
      *
@@ -446,11 +376,6 @@ var FileWidget = SearchableMediaWidget.extend({
             return this.media;
         }
 
-        // Auto optimize unoptimized images.
-        if (['image/jpeg', 'image/jpe', 'image/jpg', 'image/png'].includes(img.mimetype) && img.type === 'binary' && !img.original_id) {
-            img = await this._optimizeAttachment(img);
-        }
-
         if (!img.public && !img.access_token) {
             await this._rpc({
                 model: 'ir.attachment',
@@ -496,30 +421,14 @@ var FileWidget = SearchableMediaWidget.extend({
             this.$media.css(style);
         }
 
-        // Remove crop related attributes
-        ImageCropWidget.prototype.allAttributes.forEach(attr => {
+        // Remove image modification attributes
+        removeOnImageChangeAttrs.forEach(attr => {
             delete this.media.dataset[attr];
         });
-        this.media.classList.remove('o_cropped_img_to_save');
+        this.media.classList.remove('o_modified_image_to_save');
+        this.$media.trigger('image_changed');
         return this.media;
     },
-    /**
-     * Creates and returns an optimized copy of an attachment.
-     *
-     * @private
-     * @param {object} attachment
-     */
-    _optimizeAttachment: function (attachment) {
-        return this._rpc({
-            route: `/web_editor/attachment/${attachment.id}/update`,
-            params: {
-                copy: true,
-                name: attachment.name,
-                quality: attachment.mimetype === 'image/png' ? 0 : 80,
-                width: this._computeOptimizedWidth(),
-            },
-        });
-    },
     /**
      * @param {object} attachment
      * @param {boolean} [save=true] to save the given attachment in the DOM and
@@ -576,18 +485,6 @@ var FileWidget = SearchableMediaWidget.extend({
         var attachment = _.find(this.attachments, {id: $attachment.data('id')});
         this._selectAttachement(attachment, !this.options.multiImages);
     },
-    /**
-     * @private
-     */
-    _onExistingOptimizeClick: function (ev) {
-        var $a = $(ev.currentTarget).closest('.o_existing_attachment_cell');
-        var id = parseInt($a.data('id'), 10);
-        var attachment = _.findWhere(this.attachments, {id: id});
-        ev.stopPropagation();
-        return this._openImageOptimizeDialog(attachment, true, $a).then(newAttachment => {
-            this._handleNewAttachment(newAttachment);
-        });
-    },
     /**
      * Handles change of the file input: create attachments with the new files
      * and open the Preview dialog for each of them. Locks the save button until
diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
index fe7399fdebeab91dac3b0e9656706025ffc7b0e7..0ba27dcf17258c02bd30a55057a59c9f9aa39cf5 100644
--- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
+++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
@@ -158,8 +158,8 @@ var Wysiwyg = Widget.extend({
      * @param {jQuery} $editable
      * @returns {Promise}
      */
-    saveCroppedImages: function ($editable) {
-        return this._summernoteManager.saveCroppedImages($editable);
+    saveModifiedImages: function ($editable) {
+        return this._summernoteManager.saveModifiedImages($editable);
     },
     /**
      * @param {String} value
diff --git a/addons/web_editor/static/src/scss/wysiwyg.scss b/addons/web_editor/static/src/scss/wysiwyg.scss
index b3fa17b91162af054265a88e160eb4f8af560bdf..9b368607a2882d6ddd382f3ac3eab66ded35bc2f 100644
--- a/addons/web_editor/static/src/scss/wysiwyg.scss
+++ b/addons/web_editor/static/src/scss/wysiwyg.scss
@@ -363,9 +363,7 @@ body .modal {
 
 // Highlight selected image/icon
 %o-we-selected-image {
-    $o-selected-image-color: rgba(150, 150, 220, 0.3);
-    background: $o-selected-image-color;
-    outline: 3px solid $o-selected-image-color;
+    outline: 3px solid rgba(150, 150, 220, 0.3);
 }
 
 img.o_we_selected_image {
@@ -375,6 +373,11 @@ img.o_we_selected_image {
 .fa.o_we_selected_image::before {
     @extend %o-we-selected-image;
 }
+// Override default image selection color from portal. It prevents your from
+// seeing the images' quality clearly in the wysiwyg.
+img::selection {
+    background: transparent;
+}
 
 .fa.mx-auto,
 img.o_we_custom_image.mx-auto {
diff --git a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss
index 1ba4425100f1541660f9c85034eba8a31b72a20a..c31fd32d194c806b637ba52c3ad64c80217e7dc6 100644
--- a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss
+++ b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss
@@ -539,6 +539,11 @@ body.editor_enable.editor_has_snippets {
             }
         }
 
+        we-range input {
+            flex-grow: 1;
+            width: 1px;
+        }
+
         //----------------------------------------------------------------------
         // Layout Utils
         //----------------------------------------------------------------------
diff --git a/addons/web_editor/static/src/xml/wysiwyg.xml b/addons/web_editor/static/src/xml/wysiwyg.xml
index a5f4ddb0f74d48e8686fb9b7dfdc466be82c0e43..2e63efb2aac62f0e429d269840f40a44730dd225 100644
--- a/addons/web_editor/static/src/xml/wysiwyg.xml
+++ b/addons/web_editor/static/src/xml/wysiwyg.xml
@@ -307,7 +307,6 @@
     <t t-name="wysiwyg.widgets.image.existing.attachment">
         <t t-set="isOptimized" t-value="!!attachment.original_id"/>
         <div t-attf-class="o_existing_attachment_cell position-relative bg-light #{isOptimized and 'o_we_attachment_optimized d-none'}" t-att-data-id="attachment.id">
-            <i t-if="attachment.type === 'binary'" class="fa fa-cog o_existing_attachment_optimize p-2" title="Optimize" role="img" aria-label="Optimize"/>
             <t t-call="wysiwyg.widgets.file.existing.remove"/>
             <div t-attf-class="d-flex align-items-center justify-content-center h-100 w-100">
                 <!-- 256 is 2 * ImageWidget.MIN_ROW_HEIGHT, hopefully the highest display width of any image -->
diff --git a/addons/web_editor/views/editor.xml b/addons/web_editor/views/editor.xml
index 873e45d64c3a0063a34efc02f987dcf5592e6118..2c340c56c6110550c10027fcc505dcd00d2b6fff 100644
--- a/addons/web_editor/views/editor.xml
+++ b/addons/web_editor/views/editor.xml
@@ -39,10 +39,10 @@
     <script type="text/javascript" src="/web_editor/static/src/js/editor/editor.js"/>
     <script type="text/javascript" src="/web_editor/static/src/js/editor/rte.js"/>
     <script type="text/javascript" src="/web_editor/static/src/js/editor/rte.summernote.js"/>
+    <script type="text/javascript" src="/web_editor/static/src/js/editor/image_processing.js"/>
 
     <!-- widgets & plugins -->
 
-    <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/widgets/image_optimize_dialog.js"/>
     <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/widgets/media.js"/>
     <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/widgets/dialog.js"/>
     <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js"/>
diff --git a/addons/web_editor/views/snippets.xml b/addons/web_editor/views/snippets.xml
index 91169152a6cf0815a5f6d7f69467dd40ad22957a..cbadd7fb7e7700fb846616d658f3b4f882df607e 100644
--- a/addons/web_editor/views/snippets.xml
+++ b/addons/web_editor/views/snippets.xml
@@ -37,6 +37,19 @@
     </div>
 </template>
 
+<template id="image_optimization_controls">
+    <we-range string="Quality" data-set-quality=""/>
+    <!-- Select filled in JS -->
+    <we-select string="Width" data-name="width_select_opt" data-no-preview="true"/>
+    <span class="o_we_image_weight text-right"/>
+    <we-colorpicker string="Color filter" data-set-filter="" data-no-preview="true" data-excluded="common, theme"/>
+    <div class="o_we_external_warning d-none">
+        <we-title title="Quality options are unavailable for external images. If you want to change this image's quality, please first download it from the original source and upload it in Odoo.">
+            <i class="fa fa-warning" /> External Image
+        </we-title>
+    </div>
+</template>
+
 <!-- options (data-selector, data-drop-in, data-drop-near, data-js to link js object ) -->
 <template id="snippet_options">
     <!-- t-field -->
@@ -49,6 +62,11 @@
 
     <div data-js="VersionControl"
          data-selector="[data-snippet]"/>
+
+    <div data-js="ImageOptimize"
+         data-selector="img">
+         <t t-call="web_editor.image_optimization_controls"/>
+     </div>
 </template>
 
 <!-- default block -->
diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js
index d31c5c6b4ef984ffb1044258d9578453dfa1103e..18599dfa9559657881553d0585a11a0cd586cf5f 100644
--- a/addons/website/static/src/js/editor/snippets.options.js
+++ b/addons/website/static/src/js/editor/snippets.options.js
@@ -518,6 +518,18 @@ options.Class.include({
     },
 });
 
+options.registry.BackgroundOptimize.include({
+    /**
+     * @override
+     */
+    _computeVisibility() {
+        if (this.$target.hasClass('o_background_video')) {
+            return false;
+        }
+        return this._super(...arguments);
+    },
+});
+
 options.registry.background.include({
     background: async function (previewMode, widgetValue, params) {
         if (previewMode === 'reset' && this.videoSrc) {
diff --git a/addons/website/views/snippets/snippets.xml b/addons/website/views/snippets/snippets.xml
index 4ba548d7bd78ce3da85fa16f44bb700fdcddba13..5f66dd3fffdd98d96b9e8d3850b92f8bb9577c8b 100644
--- a/addons/website/views/snippets/snippets.xml
+++ b/addons/website/views/snippets/snippets.xml
@@ -282,10 +282,17 @@
     </div>
 
     <!-- Background Image -->
+    <t t-set="bg_selector" t-value="'section, .parallax, .carousel-item'"/>
+    <t t-set="bg_exclude" t-value="'.s_hr, .s_image_gallery, .o_gallery .carousel-item'"/>
     <div data-js="background" string="Background"
-        data-selector="section, .parallax, .carousel-item"
-        data-exclude=".s_hr, .s_image_gallery, .o_gallery .carousel-item">
-        <we-imagepicker string="Background" data-background="" data-allow-videos=":not(.parallax, .s_parallax_bg)"/>
+        t-att-data-selector="bg_selector"
+        t-att-data-exclude="bg_exclude">
+        <we-imagepicker string="Background" data-background="" data-allow-videos=":not(.parallax, .s_parallax_bg)" data-name="bg_img_opt"/>
+    </div>
+    <div data-js="BackgroundOptimize" string="Background"
+        t-att-data-selector="bg_selector"
+        t-att-data-exclude="bg_exclude">
+        <t t-call="web_editor.image_optimization_controls"/>
     </div>
 
     <!-- Background Image Size/Position -->