From 3b7caba768bd2a259b426df3dcbe37da128957ab Mon Sep 17 00:00:00 2001 From: Harry Terkelsen <1961493+harryterkelsen@users.noreply.github.com> Date: Tue, 13 Jan 2026 17:07:10 -0800 Subject: [PATCH] [canvaskit] Fix image decoding in CPU-only mode (#180706) In CPU-only rendering mode, we were using `MakeLazyImageFromTextureSource` which only works if a WebGL context is available (which it isn't in CPU-only mode). This fixes the problem by using `MakeImageFromCanvasImageSource` which uses a 2d canvas when we have fallen back to software rendering. Fixes https://github.com/flutter/flutter/issues/175423 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- .../src/engine/canvaskit/canvaskit_api.dart | 20 ++++- .../lib/src/engine/canvaskit/image.dart | 43 ++++++----- .../engine/canvaskit/image_web_codecs.dart | 30 +++++--- .../lib/src/engine/canvaskit/renderer.dart | 45 ++++++++--- .../lib/src/engine/canvaskit/surface.dart | 3 + .../web_ui/test/ui/image_cpu_only_test.dart | 76 +++++++++++++++++++ 6 files changed, 171 insertions(+), 46 deletions(-) create mode 100644 engine/src/flutter/lib/web_ui/test/ui/image_cpu_only_test.dart diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart index 9bb6ef9780c..89b2ad6563e 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart @@ -170,11 +170,23 @@ extension type CanvasKit(JSObject _) implements JSObject { bool srcIsPremultiplied, ); - SkImage? MakeLazyImageFromTextureSourceWithInfo(Object src, SkPartialImageInfo info) => - _MakeLazyImageFromTextureSource2(src.toJSAnyShallow, info); + SkImage? MakeLazyImageFromTextureSourceWithInfo(Object src, SkPartialImageInfo info) { + assert( + !CanvasKitRenderer.instance.isSoftware, + 'Cannot use `MakeLazyImageFromTextureSourceWithInfo` in CPU-only mode.', + ); + return _MakeLazyImageFromTextureSource2(src.toJSAnyShallow, info); + } - SkImage? MakeLazyImageFromImageBitmap(DomImageBitmap imageBitmap, bool hasPremultipliedAlpha) => - _MakeLazyImageFromTextureSource3(imageBitmap, 0, hasPremultipliedAlpha); + SkImage? MakeLazyImageFromImageBitmap(DomImageBitmap imageBitmap, bool hasPremultipliedAlpha) { + assert( + !CanvasKitRenderer.instance.isSoftware, + 'Cannot use `MakeLazyImageFromImageBitmap` in CPU-only mode.', + ); + return _MakeLazyImageFromTextureSource3(imageBitmap, 0, hasPremultipliedAlpha); + } + + external SkImage? MakeImageFromCanvasImageSource(JSAny src); } extension type CanvasKitModule(JSObject _) implements JSObject { diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image.dart index ed42d1861a5..1bec48d6314 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image.dart @@ -115,7 +115,12 @@ class CkResizingCodec extends ResizingCodec { scaledHeight, ); final DomImageBitmap bitmap = offscreenCanvas.transferToImageBitmap(); - final SkImage? skImage = canvasKit.MakeLazyImageFromImageBitmap(bitmap, true); + SkImage? skImage; + if (CanvasKitRenderer.instance.isSoftware) { + skImage = canvasKit.MakeImageFromCanvasImageSource(bitmap); + } else { + skImage = canvasKit.MakeLazyImageFromImageBitmap(bitmap, true); + } // Resize the canvas to 0x0 to cause the browser to eagerly reclaim its // memory. @@ -137,16 +142,21 @@ ui.Image createCkImageFromImageElement( int naturalWidth, int naturalHeight, ) { - final SkImage? skImage = canvasKit.MakeLazyImageFromTextureSourceWithInfo( - image, - SkPartialImageInfo( - alphaType: canvasKit.AlphaType.Premul, - colorType: canvasKit.ColorType.RGBA_8888, - colorSpace: SkColorSpaceSRGB, - width: naturalWidth.toDouble(), - height: naturalHeight.toDouble(), - ), - ); + SkImage? skImage; + if (CanvasKitRenderer.instance.isSoftware) { + skImage = canvasKit.MakeImageFromCanvasImageSource(image); + } else { + skImage = canvasKit.MakeLazyImageFromTextureSourceWithInfo( + image, + SkPartialImageInfo( + alphaType: canvasKit.AlphaType.Premul, + colorType: canvasKit.ColorType.RGBA_8888, + colorSpace: SkColorSpaceSRGB, + width: naturalWidth.toDouble(), + height: naturalHeight.toDouble(), + ), + ); + } if (skImage == null) { throw ImageCodecException('Failed to create image from Image.decode'); } @@ -334,16 +344,7 @@ Future skiaInstantiateWebImageCodec( debugSource: url, ); } else { - final DomBlob blob = createDomBlob([list.buffer]); - final codec = CkImageBlobCodec(blob, chunkCallback: chunkCallback); - - try { - await codec.decode(); - return codec; - } on ImageCodecException { - codec.dispose(); - return CkAnimatedImage.decodeFromBytes(list, url); - } + return CkAnimatedImage.decodeFromBytes(list, url); } } } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image_web_codecs.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image_web_codecs.dart index 399fad8f1d0..8864109f522 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image_web_codecs.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image_web_codecs.dart @@ -42,16 +42,26 @@ class CkBrowserImageDecoder extends BrowserImageDecoder { @override ui.Image generateImageFromVideoFrame(VideoFrame frame) { - final SkImage? skImage = canvasKit.MakeLazyImageFromTextureSourceWithInfo( - frame, - SkPartialImageInfo( - alphaType: canvasKit.AlphaType.Premul, - colorType: canvasKit.ColorType.RGBA_8888, - colorSpace: SkColorSpaceSRGB, - width: frame.displayWidth, - height: frame.displayHeight, - ), - ); + SkImage? skImage; + if (CanvasKitRenderer.instance.isSoftware) { + final int width = frame.displayWidth.toInt(); + final int height = frame.displayHeight.toInt(); + final DomHTMLCanvasElement canvas = createDomCanvasElement(width: width, height: height); + final DomCanvasRenderingContext2D ctx = canvas.context2D; + ctx.drawImage(frame, 0, 0); + skImage = canvasKit.MakeImageFromCanvasImageSource(canvas); + } else { + skImage = canvasKit.MakeLazyImageFromTextureSourceWithInfo( + frame, + SkPartialImageInfo( + alphaType: canvasKit.AlphaType.Premul, + colorType: canvasKit.ColorType.RGBA_8888, + colorSpace: SkColorSpaceSRGB, + width: frame.displayWidth, + height: frame.displayHeight, + ), + ); + } if (skImage == null) { throw ImageCodecException( "Failed to create image from pixel data decoded using the browser's ImageDecoder.", diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/renderer.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/renderer.dart index 451319043ac..26018c88a2e 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/renderer.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/renderer.dart @@ -23,6 +23,9 @@ class CanvasKitRenderer extends Renderer { @override String get rendererTag => 'canvaskit'; + /// Whether the renderer is using software rendering. + bool get isSoftware => _pictureToImageSurface.isSoftware; + late final FlutterFontCollection _fontCollection = isExperimentalWebParagraph ? WebFontCollection() : SkiaFontCollection(); @@ -211,7 +214,12 @@ class CanvasKitRenderer extends Renderer { @override ui.Image createImageFromImageBitmap(DomImageBitmap imageBitmap) { - final SkImage? skImage = canvasKit.MakeLazyImageFromImageBitmap(imageBitmap, true); + SkImage? skImage; + if (isSoftware) { + skImage = canvasKit.MakeImageFromCanvasImageSource(imageBitmap); + } else { + skImage = canvasKit.MakeLazyImageFromImageBitmap(imageBitmap, true); + } if (skImage == null) { throw Exception('Failed to convert image bitmap to an SkImage.'); } @@ -234,16 +242,31 @@ class CanvasKitRenderer extends Renderer { )); return createImageFromImageBitmap(bitmap); } - final SkImage? skImage = canvasKit.MakeLazyImageFromTextureSourceWithInfo( - object, - SkPartialImageInfo( - width: width.toDouble(), - height: height.toDouble(), - alphaType: canvasKit.AlphaType.Premul, - colorType: canvasKit.ColorType.RGBA_8888, - colorSpace: SkColorSpaceSRGB, - ), - ); + SkImage? skImage; + if (isSoftware) { + if (object.isA()) { + // If the object is a VideoFrame, we need to draw it to a canvas first to + // avoid a bug in CanvasKit where MakeImageFromCanvasImageSource doesn't + // work with VideoFrames. + final DomHTMLCanvasElement canvas = createDomCanvasElement(width: width, height: height); + final DomCanvasRenderingContext2D ctx = canvas.context2D; + ctx.drawImage(object as VideoFrame, 0, 0); + skImage = canvasKit.MakeImageFromCanvasImageSource(canvas); + } else { + skImage = canvasKit.MakeImageFromCanvasImageSource(object); + } + } else { + skImage = canvasKit.MakeLazyImageFromTextureSourceWithInfo( + object, + SkPartialImageInfo( + width: width.toDouble(), + height: height.toDouble(), + alphaType: canvasKit.AlphaType.Premul, + colorType: canvasKit.ColorType.RGBA_8888, + colorSpace: SkColorSpaceSRGB, + ), + ); + } if (skImage == null) { throw Exception('Failed to convert image bitmap to an SkImage.'); diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/surface.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/surface.dart index 058f34a91b2..c5ebc1f6d2e 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/surface.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/surface.dart @@ -52,6 +52,9 @@ abstract class CkSurface extends Surface { return true; } + /// Whether this surface is using software rendering. + bool get isSoftware => !supportsWebGl; + String? _fallbackToSoftwareReason; /// When true, the surface will fail to create a GL context and fall back to diff --git a/engine/src/flutter/lib/web_ui/test/ui/image_cpu_only_test.dart b/engine/src/flutter/lib/web_ui/test/ui/image_cpu_only_test.dart new file mode 100644 index 00000000000..9a6f59d9e53 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/ui/image_cpu_only_test.dart @@ -0,0 +1,76 @@ +// 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:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/src/engine.dart'; +import 'package:ui/ui.dart' as ui; +import 'package:ui/ui_web/src/ui_web.dart' as ui_web; +import 'package:web_engine_tester/golden_tester.dart'; + +import '../common/test_initialization.dart'; +import 'utils.dart'; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +Future testMain() async { + setUpUnitTests(withImplicitView: true, setUpTestViewDimensions: false); + + test('Renders image from encoded bytes in CPU-only mode', () async { + debugOverrideJsConfiguration(JsFlutterConfiguration(canvasKitForceCpuOnly: true)); + + // A 1x1 yellow PNG image. + const kBase64Png = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='; + final Uint8List bytes = base64.decode(kBase64Png); + + final ui.Codec codec = await ui.instantiateImageCodec(bytes); + final ui.FrameInfo frameInfo = await codec.getNextFrame(); + final ui.Image image = frameInfo.image; + + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder, const ui.Rect.fromLTWH(0, 0, 10, 10)); + + // Draw the image scaled up so we can see it + canvas.drawImageRect( + image, + const ui.Rect.fromLTWH(0, 0, 1, 1), + const ui.Rect.fromLTWH(0, 0, 10, 10), + ui.Paint(), + ); + + await drawPictureUsingCurrentRenderer(recorder.endRecording()); + + await matchGoldenFile('image_cpu_only.png', region: const ui.Rect.fromLTWH(0, 0, 10, 10)); + + debugOverrideJsConfiguration(null); // Reset configuration + }); + + test('Renders image from URL in CPU-only mode', () async { + debugOverrideJsConfiguration(JsFlutterConfiguration(canvasKitForceCpuOnly: true)); + + final Uri uri = Uri.base.resolve('/test/ui/image/sample_image1.png'); + final ui.Codec codec = await ui_web.createImageCodecFromUrl(uri); + final ui.FrameInfo frameInfo = await codec.getNextFrame(); + final ui.Image image = frameInfo.image; + + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder, const ui.Rect.fromLTWH(0, 0, 100, 100)); + + // Draw the image + canvas.drawImage(image, ui.Offset.zero, ui.Paint()); + + await drawPictureUsingCurrentRenderer(recorder.endRecording()); + + await matchGoldenFile('image_cpu_only_url.png', region: const ui.Rect.fromLTWH(0, 0, 100, 100)); + + debugOverrideJsConfiguration(null); // Reset configuration + }); +}