Skip to content

719 Improve quality of cropped images#721

Merged
aarondrabeck merged 6 commits intomasterfrom
719-improve-quality-of-cropped-images
Mar 18, 2026
Merged

719 Improve quality of cropped images#721
aarondrabeck merged 6 commits intomasterfrom
719-improve-quality-of-cropped-images

Conversation

@kaseyhinton
Copy link
Collaborator

@kaseyhinton kaseyhinton commented Mar 12, 2026

closes #719

Not ready to merge. Need extensive testing and in depth review from aaron when he gets back.

The upgraded cropper (image smoothing quality set to high, output quality set to maximum) produces significantly larger image data. This exacerbates an existing issue where canvas.toDataURL() encodes the entire image as an in-memory base64 string, causing UI freezes and main thread blocking on larger images.

To accommodate the higher quality output, the cropper now follows MDN's recommended approach for handling large canvas images in the browser:

Warning: toDataURL() encodes the whole image in an in-memory string. For larger images, this can have performance implications, and may even overflow browsers' URL length limit when assigned to HTMLImageElement.src. You should generally prefer toBlob() instead, in combination with URL.createObjectURL().

The implementation was refactored to use canvas.toBlob() with URL.createObjectURL(), which avoids the expensive base64 encoding entirely. Object URL lifecycle management was also added to prevent memory leaks when files are removed, replaced, or the input is reset.

Additionally, this fixes a bug where the circle mask was always re-encoding the image as PNG (the default when toDataURL() is called without a MIME type), ignoring the platform-specific format selection.


Some metrics from testing

AI Photo
Original 3584 x 1184 7.3mb
Cropped 3584 x 1183 164kb
Upgraded 3584 x 1183 3.8mb

4k Photo From Pexels
Original 4826 x 3063 2.5mb
Cropped 4826 x 3062 2.2mb
Upgraded 4826 x 3062 18.4mb

upgraded cropper means processed with image smoothing quality high and data url quality set to max


Note

Medium Risk
Changes image cropping/preview generation from toDataURL() to toBlob() + object URLs and adds URL revocation; regressions could affect image quality, memory usage, and attachment preview behavior across browsers.

Overview
Improves cropped-image output by setting canvas smoothing to high, introducing configurable options.outputQuality (default 0.8), and generating previews/files via canvas.toBlob() + URL.createObjectURL() instead of base64 toDataURL().

Adds object-URL lifecycle management: the crop dialog now clears src/ready state on close, and titanium-smart-attachment-input revokes blob: preview URLs when files are replaced, deleted, or reset to reduce memory leaks. The demo adds a toggle to set outputQuality: 1 for testing.

Written by Cursor Bugbot for commit d3114e3. This will update automatically on new commits. Configure here.

@kaseyhinton
Copy link
Collaborator Author

Old cropper in action. With a super high resolution photo you can see the UI lock/freeze happen.

OldCropper.mp4

@kaseyhinton
Copy link
Collaborator Author

New cropper in action.

NewCropper.mp4

@kaseyhinton
Copy link
Collaborator Author

The AI Photo problem example.

AIExample.mp4

@aarondrabeck
Copy link
Member

@cursor review

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Circle mask applies double lossy compression degrading quality
    • The circle mask is now applied directly to the crop canvas before a single final encode, removing the previous lossy encode-decode-encode path.

Create PR

Or push these changes by commenting:

@cursor push e5a8c96b84
Preview (e5a8c96b84)
diff --git a/packages/web/titanium/smart-attachment-input/crop-and-save-image-dialog.ts b/packages/web/titanium/smart-attachment-input/crop-and-save-image-dialog.ts
--- a/packages/web/titanium/smart-attachment-input/crop-and-save-image-dialog.ts
+++ b/packages/web/titanium/smart-attachment-input/crop-and-save-image-dialog.ts
@@ -19,7 +19,7 @@
 
 import Bowser from 'bowser';
 const LoaderGif = new URL('./images/duck-loader.gif', import.meta.url).href;
-const DEFAULT_IMAGE_QUALITY = 0.80;
+const DEFAULT_IMAGE_QUALITY = 0.8;
 
 export declare type CropperOptions = {
   shape?: 'square' | 'circle';
@@ -164,39 +164,29 @@
     });
   }
 
-  async #applyCircleMask(sourceUrl: string): Promise<Blob> {
-    const canvas = document.createElement('canvas');
-    const image = new Image();
+  #applyCircleMask(canvas: HTMLCanvasElement) {
+    const size = Math.min(canvas.width, canvas.height);
+    if (canvas.width !== size || canvas.height !== size) {
+      const sourceCanvas = document.createElement('canvas');
+      sourceCanvas.width = size;
+      sourceCanvas.height = size;
+      const sourceCtx = sourceCanvas.getContext('2d') as CanvasRenderingContext2D;
+      sourceCtx.drawImage(canvas, 0, 0);
+      canvas.width = size;
+      canvas.height = size;
+      const resizedCtx = canvas.getContext('2d') as CanvasRenderingContext2D;
+      resizedCtx.drawImage(sourceCanvas, 0, 0);
+    }
 
-    const blobPromise = new Promise<Blob>((resolve, reject) => {
-      image.onload = async () => {
-        const size = Math.min(image.naturalWidth, image.naturalHeight);
+    const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
+    ctx.globalCompositeOperation = 'destination-in';
 
-        canvas.width = size;
-        canvas.height = size;
+    ctx.fillStyle = '#000';
+    ctx.beginPath();
+    ctx.arc(size * 0.5, size * 0.5, size * 0.5, 0, 2 * Math.PI);
+    ctx.fill();
 
-        const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
-        ctx.drawImage(image, 0, 0);
-
-        ctx.globalCompositeOperation = 'destination-in';
-
-        ctx.fillStyle = '#000';
-        ctx.beginPath();
-        ctx.arc(size * 0.5, size * 0.5, size * 0.5, 0, 2 * Math.PI);
-        ctx.fill();
-
-        ctx.globalCompositeOperation = 'source-over';
-
-        try {
-          resolve(await this.#canvasToBlob(canvas, this.#mimeType, this.options?.outputQuality ?? DEFAULT_IMAGE_QUALITY));
-        } catch (e) {
-          reject(e);
-        }
-      };
-    });
-    image.src = sourceUrl;
-
-    return blobPromise;
+    ctx.globalCompositeOperation = 'source-over';
   }
 
   // adapted from https://fengyuanchen.github.io/cropperjs/v2/api/cropper-selection.html#limit-boundaries
@@ -487,16 +477,10 @@
                 return;
               }
 
-              const blob = await this.#canvasToBlob(canvas, this.#mimeType, this.options?.outputQuality ?? DEFAULT_IMAGE_QUALITY);
-
-              let previewBlob: Blob;
               if (this.options?.shape === 'circle') {
-                const tempUrl = URL.createObjectURL(blob);
-                previewBlob = await this.#applyCircleMask(tempUrl);
-                URL.revokeObjectURL(tempUrl);
-              } else {
-                previewBlob = blob;
+                this.#applyCircleMask(canvas);
               }
+              const previewBlob = await this.#canvasToBlob(canvas, this.#mimeType, this.options?.outputQuality ?? DEFAULT_IMAGE_QUALITY);
 
               const previewUrl = URL.createObjectURL(previewBlob);
               const file = this.blobToFile(previewBlob, this.#changeFileExtension(this.fileName, this.#extension));

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

URL.revokeObjectURL(tempUrl);
} else {
previewBlob = blob;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circle mask applies double lossy compression degrading quality

Medium Severity

When shape is 'circle', the image is lossy-compressed twice: once at line 490 via #canvasToBlob (raw canvas → WebP), then decoded back to pixels inside #applyCircleMask and lossy-compressed again at line 191 via a second #canvasToBlob call with the same WebP format and quality. The old code avoided this because #applyCircleMask called toDataURL() without a MIME type, defaulting to lossless PNG for the second pass. This regression specifically affects non-Apple platforms (where #mimeType is image/webp) and directly contradicts the PR's goal of improving image quality. Applying the circle mask directly to the canvas from $toCanvas() before the single #canvasToBlob call would eliminate the encode-decode-encode cycle entirely.

Additional Locations (1)
Fix in Cursor Fix in Web

@kaseyhinton
Copy link
Collaborator Author

@cursor push e5a8c96

@cursor
Copy link

cursor bot commented Mar 17, 2026

Skipping Bugbot: Unable to authenticate your request. Please make sure Bugbot is properly installed and configured for this repository.

@aarondrabeck aarondrabeck merged commit f9a932f into master Mar 18, 2026
1 check passed
@aarondrabeck aarondrabeck deleted the 719-improve-quality-of-cropped-images branch March 18, 2026 15:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improve quality of cropped images

3 participants