-
Notifications
You must be signed in to change notification settings - Fork 30.4k
[web] Implement stepped image downscaling for CanvasKit and Skwasm #184741
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
auto-submit
merged 10 commits into
flutter:master
from
harryterkelsen:fix-downscaling-image-web
Apr 23, 2026
Merged
Changes from 7 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
37babcd
[web] Implement stepped image downscaling for CanvasKit and Skwasm
harryterkelsen 633907d
Use src rect in downscaling logic
harryterkelsen d7b3d69
Handle extreme aspect ratios. Limit cache size
harryterkelsen e8d4713
Add suggestions from Gemini review
harryterkelsen 9a64505
Move temporary paint out of loop
harryterkelsen d6081f1
Only create paint object if needed
harryterkelsen f04b3d7
Refactor image drawing code into helper function
harryterkelsen 2266eb1
Address comments
harryterkelsen 1f63148
Merge branch 'main' into fix-downscaling-image-web
harryterkelsen d208dd8
Merge branch 'main' into fix-downscaling-image-web
harryterkelsen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
200 changes: 200 additions & 0 deletions
200
engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,200 @@ | ||
| // Copyright 2013 The Flutter Authors. All rights reserved. | ||
| // Use of this source code is governed by a BSD-style license that can be | ||
| // found in the LICENSE file. | ||
|
|
||
| import 'dart:math' as math; | ||
|
|
||
| import 'package:meta/meta.dart'; | ||
| import 'package:ui/ui.dart' as ui; | ||
|
|
||
| /// A callback type used to draw a portion of an image onto a canvas. | ||
| /// | ||
| /// This is used to abstract the drawing operation so it can be implemented | ||
| /// differently for CanvasKit and Skwasm. | ||
| typedef RawDrawImageRect = | ||
| void Function(ui.Canvas canvas, ui.Image image, ui.Rect src, ui.Rect dst); | ||
|
|
||
| /// Determines whether an image draw operation should use iterative downscaling. | ||
| /// | ||
| /// Iterative downscaling is used when both dimensions are being scaled down | ||
| /// to less than half of their source size, and the filter quality is at least | ||
| /// [ui.FilterQuality.medium]. | ||
| bool shouldIterativelyDownscale(ui.Rect src, ui.Rect dst, ui.Paint paint) { | ||
| return (dst.width < src.width / 2 || dst.height < src.height / 2) && | ||
| dst.width >= 1 && | ||
| dst.height >= 1 && | ||
| paint.filterQuality.index >= ui.FilterQuality.medium.index; | ||
| } | ||
|
|
||
| /// A cache for downscaled images. | ||
| /// | ||
| /// This cache is used to avoid repeatedly downscaling the same image to the | ||
| /// same target size. | ||
| class DownscaledImageCache { | ||
| DownscaledImageCache._(); | ||
|
|
||
| /// The singleton instance of the cache. | ||
| static final DownscaledImageCache instance = DownscaledImageCache._(); | ||
|
|
||
| /// The key is the ref-counting box of the image (CkCountedRef or CountedRef). | ||
| /// We use the box as the key so that cloned images (which share the same box) | ||
| /// can use the same cached downscaled image. | ||
| final Map<Object, Map<(ui.Rect, int, int), ui.Image>> _cache = {}; | ||
|
harryterkelsen marked this conversation as resolved.
|
||
|
|
||
| /// The maximum number of downscaled variants to cache per image. | ||
| static const int _maxVariantsPerImage = 10; | ||
|
|
||
| /// Gets a cached downscaled image for the given [box], source rect, and target size. | ||
| ui.Image? get(Object box, ui.Rect src, int width, int height) { | ||
| final Map<(ui.Rect, int, int), ui.Image>? sizes = _cache[box]; | ||
| if (sizes == null) { | ||
| return null; | ||
| } | ||
| final key = (src, width, height); | ||
| final ui.Image? image = sizes[key]; | ||
| if (image != null) { | ||
| // Promote to most recent (insertion order). | ||
| sizes.remove(key); | ||
| sizes[key] = image; | ||
| } | ||
| return image; | ||
| } | ||
|
|
||
| /// Puts a downscaled image into the cache for the given [box], source rect, and target size. | ||
| void put(Object box, ui.Rect src, int width, int height, ui.Image image) { | ||
| final Map<(ui.Rect, int, int), ui.Image> sizes = _cache.putIfAbsent(box, () => {}); | ||
| final key = (src, width, height); | ||
|
|
||
| // Remove if exists to refresh insertion order. | ||
| final ui.Image? oldImage = sizes.remove(key); | ||
| if (oldImage != null && oldImage != image) { | ||
| oldImage.dispose(); | ||
| } | ||
|
|
||
| sizes[key] = image; | ||
|
|
||
| // Limit size. | ||
| if (sizes.length > _maxVariantsPerImage) { | ||
| final (ui.Rect, int, int) firstKey = sizes.keys.first; | ||
| final ui.Image? firstImage = sizes.remove(firstKey); | ||
| firstImage?.dispose(); | ||
| } | ||
| } | ||
|
|
||
| /// Disposes all cached downscaled images for the given [box]. | ||
| void disposeForBox(Object box) { | ||
| final Map<(ui.Rect, int, int), ui.Image>? sizes = _cache.remove(box); | ||
| if (sizes != null) { | ||
| for (final ui.Image image in sizes.values) { | ||
| image.dispose(); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Retrieves a downscaled image from the cache or creates it if it doesn't exist. | ||
| /// | ||
| /// The [box] is the ref-counting box of the original image (e.g., `CkCountedRef` | ||
| /// or `CountedRef`). We use the box as the key so that cloned images (which | ||
| /// share the same box) can use the same cached downscaled image. | ||
| ui.Image getOrCreateDownscaledImage({ | ||
| required Object box, | ||
| required ui.Image originalImage, | ||
| required ui.Rect src, | ||
| required int targetWidth, | ||
| required int targetHeight, | ||
| required RawDrawImageRect rawDraw, | ||
| }) { | ||
| final DownscaledImageCache cache = DownscaledImageCache.instance; | ||
| final ui.Image? cached = cache.get(box, src, targetWidth, targetHeight); | ||
| if (cached != null) { | ||
| return cached; | ||
| } | ||
|
|
||
| final ui.Image downscaled = createSteppedDownscaledImage( | ||
| originalImage: originalImage, | ||
| src: src, | ||
| targetWidth: targetWidth, | ||
| targetHeight: targetHeight, | ||
| rawDraw: rawDraw, | ||
| ); | ||
|
|
||
| cache.put(box, src, targetWidth, targetHeight, downscaled); | ||
| return downscaled; | ||
| } | ||
|
harryterkelsen marked this conversation as resolved.
|
||
|
|
||
| /// Creates a high-quality downscaled image by repeatedly drawing the image at | ||
| /// half scale. | ||
| /// | ||
| /// This avoids aliasing artifacts that occur when downscaling an image by a | ||
| /// large factor in a single step due to Skia not using mipmaps on the web. | ||
| @visibleForTesting | ||
| ui.Image createSteppedDownscaledImage({ | ||
| required ui.Image originalImage, | ||
| required ui.Rect src, | ||
| required int targetWidth, | ||
| required int targetHeight, | ||
| required RawDrawImageRect rawDraw, | ||
| }) { | ||
| assert(targetWidth < src.width / 2 || targetHeight < src.height / 2); | ||
| var currentImage = originalImage; | ||
| var currentSrc = src; | ||
|
|
||
| ui.Image drawImageScaled({ | ||
| required RawDrawImageRect rawDraw, | ||
| required ui.Image image, | ||
| required ui.Rect src, | ||
| required int width, | ||
| required int height, | ||
| }) { | ||
| final recorder = ui.PictureRecorder(); | ||
| final canvas = ui.Canvas(recorder); | ||
|
|
||
| rawDraw(canvas, image, src, ui.Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble())); | ||
|
|
||
| final ui.Picture picture = recorder.endRecording(); | ||
| final ui.Image result = picture.toImageSync(width, height); | ||
| picture.dispose(); | ||
| return result; | ||
| } | ||
|
|
||
| while (currentSrc.width > targetWidth * 2 || currentSrc.height > targetHeight * 2) { | ||
| final int nextWidth = math.max(1, math.max(targetWidth, currentSrc.width ~/ 2)); | ||
| final int nextHeight = math.max(1, math.max(targetHeight, currentSrc.height ~/ 2)); | ||
|
|
||
| final ui.Image nextImage = drawImageScaled( | ||
| rawDraw: rawDraw, | ||
| image: currentImage, | ||
| src: currentSrc, | ||
| width: nextWidth, | ||
| height: nextHeight, | ||
| ); | ||
|
|
||
| if (currentImage != originalImage) { | ||
| currentImage.dispose(); | ||
| } | ||
|
|
||
| currentImage = nextImage; | ||
| currentSrc = ui.Rect.fromLTWH(0, 0, nextWidth.toDouble(), nextHeight.toDouble()); | ||
| } | ||
|
harryterkelsen marked this conversation as resolved.
|
||
|
|
||
| // Optimization: If we reached the target size exactly in the loop, we can | ||
| // return the last intermediate image directly. | ||
| if (currentSrc.width.toInt() == targetWidth && currentSrc.height.toInt() == targetHeight) { | ||
| return currentImage; | ||
| } | ||
|
|
||
| final ui.Image finalImage = drawImageScaled( | ||
| rawDraw: rawDraw, | ||
| image: currentImage, | ||
| src: currentSrc, | ||
| width: targetWidth, | ||
| height: targetHeight, | ||
| ); | ||
|
|
||
| if (currentImage != originalImage) { | ||
| currentImage.dispose(); | ||
| } | ||
|
|
||
| return finalImage; | ||
| } | ||
|
harryterkelsen marked this conversation as resolved.
harryterkelsen marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.