719 Improve quality of cropped images#721
Conversation
|
Old cropper in action. With a super high resolution photo you can see the UI lock/freeze happen. OldCropper.mp4 |
|
New cropper in action. NewCropper.mp4 |
|
The AI Photo problem example. AIExample.mp4 |
|
@cursor review |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
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.
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; | ||
| } |
There was a problem hiding this comment.
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)
…-quality-of-cropped-images
|
Skipping Bugbot: Unable to authenticate your request. Please make sure Bugbot is properly installed and configured for this repository. |



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:
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()totoBlob()+ 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 configurableoptions.outputQuality(default0.8), and generating previews/files viacanvas.toBlob()+URL.createObjectURL()instead of base64toDataURL().Adds object-URL lifecycle management: the crop dialog now clears
src/ready state on close, andtitanium-smart-attachment-inputrevokesblob:preview URLs when files are replaced, deleted, or reset to reduce memory leaks. The demo adds a toggle to setoutputQuality: 1for testing.Written by Cursor Bugbot for commit d3114e3. This will update automatically on new commits. Configure here.